diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3842bae --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +.env +.env.* +!.env.example +.pixi +.pixi-cache +.vscode +__pycache__ +*.pyc +data/state.json +data/providers.json +temp +paper-bridge/.gradle +paper-bridge/.gradle-user-home +paper-bridge/build diff --git a/.env.example b/.env.example index 6e90669..7e0100e 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ DISCORD_TOKEN= DISCORD_TEST_GUILD_ID= AI_PROVIDER_FILE=data/providers.json +PERSONA_FILE=data/personas.json GRSAI_BASE_URL=https://grsaiapi.com/v1 GRSAI_DRAW_BASE_URL=https://grsaiapi.com/v1 @@ -21,7 +22,23 @@ CUSTOM_DRAW_API_KEY= DANBOORU_BASE_URL=https://danbooru.donmai.us DANBOORU_USERNAME= DANBOORU_API_KEY= +RULE34_API_BASE_URL=https://api.rule34.xxx +RULE34_POST_BASE_URL=https://rule34.xxx +RULE34_USER_ID= +RULE34_API_KEY= +SAUCENAO_BASE_URL=https://saucenao.com/search.php +SAUCENAO_API_KEY= STATE_FILE=data/state.json HOST=127.0.0.1 PORT=8000 + +MINECRAFT_BRIDGE_ENABLED=false +MINECRAFT_GUILD_ID= +MINECRAFT_SERVER_ID=default +MINECRAFT_SERVER_ADDRESS=127.0.0.1:25565 +MINECRAFT_CHANNEL_ID= +MINECRAFT_TOKEN= +MINECRAFT_ALLOW_NO_TOKEN=true +MINECRAFT_ALLOWED_CLIENTS= +MINECRAFT_MAX_MESSAGE_LENGTH=300 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e11e0f3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: FastAPI CI + +on: + push: + branches: + - main + - dev + pull_request: + +jobs: + fastapi: + name: FastAPI + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Import check + run: python -c "import app.main; import app.run; print('app-import-ok')" diff --git a/.github/workflows/paper-bridge-jar.yml b/.github/workflows/paper-bridge-jar.yml new file mode 100644 index 0000000..dea57aa --- /dev/null +++ b/.github/workflows/paper-bridge-jar.yml @@ -0,0 +1,38 @@ +name: Paper Bridge Jar + +on: + workflow_dispatch: + inputs: + version: + description: Jar version, for example 0.1.0 + required: true + default: "0.1.0" + +jobs: + build: + name: Build Paper Bridge Jar + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build plugin + run: | + chmod +x paper-bridge/gradlew + paper-bridge/gradlew -p paper-bridge -PpluginVersion=${{ inputs.version }} build + + - name: Upload jar artifact + uses: actions/upload-artifact@v4 + with: + name: dc-bot-paper-bridge-${{ inputs.version }} + path: paper-bridge/build/libs/*.jar + if-no-files-found: error diff --git a/.gitignore b/.gitignore index bbc4535..3b1edd3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,11 @@ .tmp/ .pixi/ .pixi-cache/ +.gradle/ +.gradle-user-home/ +build/ __pycache__/ *.pyc data/state.json data/providers.json +temp/* diff --git a/.vscode/settings.json b/.vscode/settings.json index c9ebf2d..2bbade1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "python-envs.defaultEnvManager": "ms-python.python:system" + "python-envs.defaultEnvManager": "ms-python.python:system", + "liveServer.settings.port": 5665 } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ddba6c7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.10-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + HOST=0.0.0.0 \ + PORT=8000 + +WORKDIR /app + +RUN addgroup --system app && adduser --system --ingroup app app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +COPY data/personas.json ./data/personas.json + +RUN mkdir -p /app/data && chown -R app:app /app + +USER app + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import json, urllib.request; json.load(urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=3))" + +CMD ["python", "-m", "app.run"] diff --git a/README.md b/README.md index 9f09526..6439b43 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ - Discord 命令:`/fun danbooru` - Danbooru 登录信息从环境变量读取 - NSFW 频道默认 `rating:e`,非 NSFW 频道强制 `rating:s` +- `图站档案与用户偏好` + - 支持 `danbooru` / `rule34` + - 支持为美图和涩图分别设置默认图站与默认 tag + - 支持 `@机器人 来张美图` / `@机器人 来张色图` - `搬屎交税` - 别人给带图消息点到配置的 `shit` emoji 时,触发警告 - 管理员回复某条带图消息 `税` / `交税` / `补税` 也可手动触发 @@ -24,9 +28,11 @@ - 超时未交税自动禁言 1 小时 - `其他娱乐功能` - `/fun fortune` 今日运势 - - `/fun roulette` 轮盘 - - `/fun waifu` 今日老婆/老公(同一天每个用户结果不同) - - `/fun lottery` 每日抽签 + - `/fun coin` 抛硬币 + - `/fun eightball` 是非题 + - `/fun waifu` 今日老婆/老公(safe 二次元角色图) + - 欢迎消息 + - 答题自助身份组 - `/fun leaderboard` 榜单 - `可视化面板` - `GET /dashboard` @@ -53,6 +59,7 @@ DISCORD_TOKEN=你的机器人 token DISCORD_TEST_GUILD_ID=测试服务器 ID,可选 AI_PROVIDER_FILE=data/providers.json +PERSONA_FILE=data/personas.json GRSAI_BASE_URL=https://grsaiapi.com/v1 GRSAI_DRAW_BASE_URL=https://grsaiapi.com/v1 GRSAI_API_KEY=你的 grsai 聊天 key @@ -70,12 +77,19 @@ CUSTOM_DRAW_API_KEY=可选的自定义绘图 key DANBOORU_USERNAME=你的 danbooru 用户名 DANBOORU_API_KEY=你的 danbooru api key +RULE34_API_BASE_URL=https://api.rule34.xxx +RULE34_POST_BASE_URL=https://rule34.xxx +RULE34_USER_ID=你的 rule34 用户 id +RULE34_API_KEY=你的 rule34 api key +SAUCENAO_BASE_URL=https://saucenao.com/search.php +SAUCENAO_API_KEY=你的 SauceNAO key ``` 说明: - `.env` 现在只建议存放 URL、token、api key 这类环境信息。 - `data/providers.json` 负责声明“渠道”和“模型档案”,每个档案会绑定 `type / provider / adapter / model`。 +- `data/personas.json` 负责声明“机器人人设档案”,包括 system prompt、summary prompt 和 fun 文案。 - 聊天目前统一按 `OpenAI 兼容 /chat/completions` 走;绘图目前支持 `grsai_draw_completions` 和 `grsai_draw_nano_banana` 两种适配器。 - `state.json` 只负责记录当前选中的 `chat_model_profile / draw_model_profile` 和群设置。 - 老配置仍然兼容,默认会自动映射到 `legacy-chat / legacy-draw`。 @@ -103,8 +117,32 @@ pixi run dev 常用命令: - `pixi run check`:检查项目能否正常导入 -- `pixi run dev`:开发模式启动 FastAPI -- `pixi run start`:普通启动 +- `pixi run dev`:开发模式启动 FastAPI,读取 `.env` 里的 `HOST` / `PORT` +- `pixi run start`:普通启动,读取 `.env` 里的 `HOST` / `PORT` + +### Docker + +```bash +docker compose up -d --build +``` + +Docker 容器会读取 `.env`,并把 `./data` 挂载到 `/app/data`。对外部署时建议设置: + +```env +HOST=0.0.0.0 +PORT=8000 +MINECRAFT_TOKEN=强随机 token +MINECRAFT_ALLOW_NO_TOKEN=false +MINECRAFT_ALLOWED_CLIENTS=Paper服务器IP或CIDR +``` + +如果在 Windows 上通过 WSL2 编译/推送镜像,可以在仓库根目录执行: + +```bash +wsl docker build -t moeary/discord_bot:0.1.0 . +wsl docker login +wsl docker push moeary/discord_bot:0.1.0 +``` ### 备用:pip / venv @@ -125,8 +163,10 @@ uvicorn app.main:app --reload ### 面板与状态 - `GET /dashboard` +- `GET /docs` - `GET /api/health` - `GET /api/dashboard` +- `GET /api/docs` ### AI @@ -153,28 +193,38 @@ uvicorn app.main:app --reload - `/ai chat prompt:<内容> image:<可选图片> profile:<可选档案>` - `/ai summary limit:<消息数>` -- `/ai draw prompt:<描述> profile:<可选档案> model:<可选覆盖> size:<可选> variants:<1|2>` +- `/ai draw prompt:<可留空> image:<可选图片> profile:<可选档案> model:<可选覆盖> size:<可选> variants:<1|2>` ### 娱乐 +- `/fun image style: tags:<可选> profile:<可选图站档案>` - `/fun danbooru tags:<标签>` +- `/fun rule34 tags:<标签>` +- `/fun pretty tags:<可选> profile:<可选图站档案>` +- `/fun lewd tags:<可选> profile:<可选图站档案>` +- `/fun image_prefs` +- `/fun image_source style: profile:<档案>` +- `/fun image_tags style: tags:<标签>` +- `/fun image_sites` +- `/fun sauce image:<可选图片> url:<可选图片链接>` - `/fun fortune` -- `/fun roulette` - `/fun coin` -- `/fun choose` - `/fun eightball` - `/fun waifu` -- `/fun lottery` - `/fun ship` +- `/fun duel` - `/fun leaderboard` +### Minecraft + +- `/minecraft bind username:` +- `/minecraft unbind` +- `/minecraft status` + ### 配置 - `/config view` -- `/config global_view` - `/config set key:<配置项> value:<值>` -- `/config global_set key:<配置项> value:<值>` -- `/config tax_channel channel:<频道>` ## 可调整配置项 @@ -184,8 +234,7 @@ uvicorn app.main:app --reload - `chat_fallback_profiles` - `draw_model_profile` - `draw_fallback_profiles` -- `system_prompt` -- `summary_system_prompt` +- `persona_profile` - `max_chat_history` ### 服务器配置 @@ -193,6 +242,20 @@ uvicorn app.main:app --reload - `ai_enabled` - `fun_enabled` - `tax_enabled` +- `minecraft_bridge_enabled` +- `minecraft_server_id` +- `minecraft_server_address` +- `minecraft_channel_id` +- `minecraft_token` +- `minecraft_allow_no_token` +- `minecraft_max_message_length` +- `welcome_channel_id` +- `welcome_text` +- `verification_channel_id` +- `verification_role_id` +- `verification_question` +- `verification_answer` +- `verification_success_text` - `tax_channel_id` - `log_channel_id` - `shit_emoji` @@ -201,6 +264,10 @@ uvicorn app.main:app --reload - `mute_hours` - `summary_limit` - `danbooru_default_tags` +- `safe_image_site_profile` +- `explicit_image_site_profile` +- `safe_image_default_tags` +- `explicit_image_default_tags` - `warn_text` ## `warn_text` 可用占位符 @@ -211,13 +278,71 @@ uvicorn app.main:app --reload - `{required_images}` - `{mute_hours}` +## Minecraft 双向聊天桥 + +FastAPI 侧新增了 `/api/minecraft/...` 接口,Paper 插件工程在 `paper-bridge/`。插件默认是关闭的,只有 `plugins/DcBotPaperBridge/config.yml` 里 `enabled: true` 时才会轮询 FastAPI。插件只保留很薄的配置:`enabled`、`api-base-url`、`server-id`、`token`、轮询间隔和消息格式;Discord guild、频道、token、绑定关系都在 FastAPI/机器人状态里配置。 + +构建插件: + +```bash +cd paper-bridge +./gradlew build +``` + +Windows PowerShell 下用 `.\gradlew.bat build`。 + +生成的 jar 在 `paper-bridge/build/libs/`。GitHub Actions 里的 `Paper Bridge Jar` workflow 只支持手动触发:进仓库网页的 Actions 页面,选择这个 workflow,点 `Run workflow` 并填版本号即可下载构建 artifact。 + +最小配置: + +1. 在 `.env` 里设置: + - `MINECRAFT_BRIDGE_ENABLED=true` + - `MINECRAFT_GUILD_ID=` + - `MINECRAFT_SERVER_ID=default` + - `MINECRAFT_SERVER_ADDRESS=127.0.0.1:30001` + - `MINECRAFT_CHANNEL_ID=<要同步的 Discord 文本频道 ID,不是服务器 ID>` + - `MINECRAFT_TOKEN=<可选,和插件一致>` + - `MINECRAFT_ALLOWED_CLIENTS=<可选,Paper 来源 IP/CIDR 白名单>` +2. 在 Paper 插件配置 `plugins/DcBotPaperBridge/config.yml` 里设置: + - `enabled: true` + - `api-base-url: "http://127.0.0.1:8000"` + - `server-id: "default"` + - `token: "<和 FastAPI 一致,可空>"` +3. 用户用 `/minecraft bind ` 绑定身份;Discord 同步到游戏时会显示为 ` 消息`。 + +`MINECRAFT_GUILD_ID` 可以不填:没填时会优先复用 `DISCORD_TEST_GUILD_ID`,或者按 `MINECRAFT_CHANNEL_ID` 对应的 Discord 频道自动判断。 + +如果 `minecraft_token` 留空,FastAPI 只允许本机、内网或链路本地地址直连,并且仍会做 `server_id` 校验、消息长度限制和短窗口限速。跨公网时建议同时设置 `MINECRAFT_TOKEN`、`MINECRAFT_ALLOW_NO_TOKEN=false` 和 `MINECRAFT_ALLOWED_CLIENTS`,例如 `203.0.113.10` 或 `203.0.113.0/24`。 + +## Rule34 凭据怎么拿 + +Rule34 现在的 API 访问需要 `user_id + api_key`。代码已经按这个格式接好了,你只要把它们填进 `.env` 里的 `RULE34_USER_ID` 和 `RULE34_API_KEY`。 + +建议流程: + +1. 注册并登录 [rule34.xxx](https://rule34.xxx)。 +2. 打开账号设置页:[https://rule34.xxx/index.php?page=account&s=options](https://rule34.xxx/index.php?page=account&s=options) +3. 找到 `API Access Credentials`。 +4. 如果还没看到 key,就勾 `Generate New Key?` 然后保存。 +5. 长字符串是 `api_key`,短数字是 `user_id`。 +6. 分别填进项目 `.env`。 + +我参考了这些资料来核对当前流程: +- [rule34Py: How to set Rule34 api credentials](https://b3yc0d3.github.io/rule34Py/guides/api-credentials.html) +- [Rule34 API](https://api.rule34.xxx/) + ## 备注 -- AI 默认系统提示词和总结提示词为 GLaDOS 风格,可在面板里改。 +- AI 的系统提示词和总结提示词现在完全来自 `data/personas.json` 里的当前 `persona_profile`。 +- 你后面如果要切 RP,只要新增 persona,然后把 `persona_profile` 切过去就行。 +- `/config set` 现在会按 key 自动判断写入 guild 还是 global,不用再手填 `scope`。 - 回复机器人消息会自动触发 AI 对话,并携带上一条 AI 回复与当前用户回复作为上下文。 - 直接 `@机器人` 说话也会触发 AI,对回复某条消息的场景会优先理解那条被回复的消息。 - `/ai chat` 和 `@机器人` 对话支持读图;`/ai summary` 默认只总结文字,不猜图片内容。 +- `/ai draw` 现在支持“只带参考图不写 prompt”的改图模式;右键消息 `GLaDOS 改图` 也能直接对某条图片消息起手。 +- 不用 slash 的情况下,也可以直接 `@机器人` 或回复机器人来聊天、绘图、改图、搜图、来张美图/色图、看运势、抛硬币、问 8ball、抽老婆。 - 当主 AI 渠道失败时,会自动尝试 fallback 渠道,并直接把失败原因显示出来。 +- 浏览器里的命令文档在 `/docs`;FastAPI 的 Swagger 被挪到了 `/api/docs`。 - `搬屎交税` 支持“发图消息 + 点指定 emoji”触发,也支持管理员回复关键字手动触发。 - 补税逻辑默认只认税务频道里的图片附件和常见图片链接。 - 如果没有配置 `tax_channel_id`,机器人仍会警告,但你最好尽快配置税务频道。 diff --git a/app/api/routes/ai.py b/app/api/routes/ai.py index 54f4384..ca6b889 100644 --- a/app/api/routes/ai.py +++ b/app/api/routes/ai.py @@ -12,13 +12,14 @@ async def chat(request: Request, payload: ChatRequest) -> dict[str, str]: store = request.app.state.store global_settings = await store.get_global_settings() + system_prompt = _resolve_system_prompt(request, global_settings) user_content: str | list[dict[str, object]] = payload.prompt if payload.image_urls: user_content = [{"type": "text", "text": payload.prompt}] for url in payload.image_urls: user_content.append({"type": "image_url", "image_url": {"url": url}}) messages = [ - {"role": "system", "content": payload.system_prompt or global_settings.system_prompt}, + {"role": "system", "content": system_prompt}, {"role": "user", "content": user_content}, ] try: @@ -37,12 +38,13 @@ async def chat(request: Request, payload: ChatRequest) -> dict[str, str]: async def summary(request: Request, payload: SummaryRequest) -> dict[str, str]: store = request.app.state.store global_settings = await store.get_global_settings() + summary_prompt = _resolve_summary_prompt(request, global_settings) text = payload.text or "\n".join(payload.messages) if not payload.include_image_hints: text = text.replace(" [附带图片/文件]", "") try: reply = await request.app.state.openrouter.summarize_text( - global_settings.summary_system_prompt, + summary_prompt, text, model=payload.model or None, profile=payload.profile or global_settings.chat_model_profile, @@ -57,9 +59,14 @@ async def summary(request: Request, payload: SummaryRequest) -> dict[str, str]: async def draw(request: Request, payload: DrawRequest) -> dict[str, object]: store = request.app.state.store global_settings = await store.get_global_settings() + prompt_text = (payload.prompt or "").strip() + if not prompt_text and payload.urls: + prompt_text = "请基于提供的图片做一次高质量改图,保留主体、角色特征和关键细节。" + if not prompt_text: + raise HTTPException(status_code=400, detail="prompt 不能为空,除非你同时提供 urls 作为参考图。") try: result = await request.app.state.openrouter.draw( - prompt=payload.prompt, + prompt=prompt_text, model=payload.model or None, profile=payload.profile or global_settings.draw_model_profile, fallback_profiles=payload.fallback_profiles or global_settings.draw_fallback_profiles, @@ -90,3 +97,13 @@ async def draw_result(request: Request, payload: DrawResultRequest) -> dict[str, def _describe_error(exc: Exception) -> str: text = str(exc).strip() return text or exc.__class__.__name__ + + +def _resolve_system_prompt(request: Request, global_settings) -> str: + persona = request.app.state.personas.get_persona(getattr(global_settings, "persona_profile", "glados")) + return str(persona.get("system_prompt", "")).strip() + + +def _resolve_summary_prompt(request: Request, global_settings) -> str: + persona = request.app.state.personas.get_persona(getattr(global_settings, "persona_profile", "glados")) + return str(persona.get("summary_system_prompt", "")).strip() diff --git a/app/api/routes/health.py b/app/api/routes/health.py index f68fc3a..2e82f8e 100644 --- a/app/api/routes/health.py +++ b/app/api/routes/health.py @@ -21,10 +21,23 @@ async def health(request: Request) -> dict[str, object]: "openrouter_configured": bool(env.openrouter_api_key), "grsai_configured": bool(os.getenv("GRSAI_API_KEY") or env.openrouter_api_key), "danbooru_configured": bool(env.danbooru_username and env.danbooru_api_key), + "rule34_configured": bool(env.rule34_api_base_url and env.rule34_post_base_url and env.rule34_user_id and env.rule34_api_key), + "saucenao_configured": bool(env.saucenao_api_key), + "minecraft_bridge_enabled": bool(env.minecraft_bridge_enabled), + "minecraft_server_id": env.minecraft_server_id, + "minecraft_server_address": env.minecraft_server_address, + "minecraft_guild_id": env.minecraft_guild_id, + "minecraft_channel_id": env.minecraft_channel_id, + "minecraft_token_configured": bool(env.minecraft_token), + "minecraft_allowed_clients": env.minecraft_allowed_clients, "provider_file": str(env.ai_provider_file), + "persona_file": str(env.persona_file), "chat_model_profile": global_settings.chat_model_profile, "chat_fallback_profiles": global_settings.chat_fallback_profiles, "draw_model_profile": global_settings.draw_model_profile, "draw_fallback_profiles": global_settings.draw_fallback_profiles, + "persona_profile": getattr(global_settings, "persona_profile", "glados"), "providers": request.app.state.openrouter.list_providers(), + "image_profiles": request.app.state.image_sources.list_profiles(), + "personas": request.app.state.personas.list_profiles(), } diff --git a/app/api/routes/minecraft.py b/app/api/routes/minecraft.py new file mode 100644 index 0000000..499c6b0 --- /dev/null +++ b/app/api/routes/minecraft.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Query, Request + +from app.api.schemas import ( + MinecraftBindingRequest, + MinecraftChatEvent, + MinecraftMessagesResponse, +) +from app.services.minecraft_bridge import is_valid_minecraft_username + + +router = APIRouter(prefix="/api/minecraft", tags=["minecraft"]) + + +@router.post("/events/chat") +async def receive_minecraft_chat(request: Request, payload: MinecraftChatEvent) -> dict[str, object]: + bridge = request.app.state.minecraft_bridge + config = await bridge.verify_plugin_request(request, payload.server_id, action="chat") + result = await bridge.publish_minecraft_chat(config, payload) + return {"ok": True, **result} + + +@router.get("/servers/{server_id}/messages", response_model=MinecraftMessagesResponse) +async def poll_minecraft_messages( + request: Request, + server_id: str, + after: int = Query(default=0, ge=0), + limit: int = Query(default=20, ge=1, le=50), +) -> MinecraftMessagesResponse: + bridge = request.app.state.minecraft_bridge + await bridge.verify_plugin_request(request, server_id, action="poll") + messages = await bridge.get_messages(server_id, after=after, limit=limit) + next_after = max([after, *(message.id for message in messages)]) + return MinecraftMessagesResponse(messages=messages, next_after=next_after) + + +@router.get("/servers/{server_id}/status") +async def get_minecraft_server_status(request: Request, server_id: str) -> dict[str, object]: + bridge = request.app.state.minecraft_bridge + config = await bridge.get_guild_config(server_id) + if config is None: + raise HTTPException(status_code=404, detail="没有启用这个 Minecraft server_id 的服务器配置。") + return { + "guild_id": config.guild_id, + "enabled": config.settings.minecraft_bridge_enabled, + "server_address": config.settings.minecraft_server_address, + "channel_id": config.settings.minecraft_channel_id, + "server": bridge.get_server_status(server_id), + } + + +@router.get("/bindings/{guild_id}") +async def list_minecraft_bindings(request: Request, guild_id: int) -> dict[str, object]: + bindings = await request.app.state.store.list_minecraft_bindings(guild_id) + return {"guild_id": guild_id, "bindings": bindings} + + +@router.put("/bindings/{guild_id}/{discord_user_id}") +async def set_minecraft_binding( + request: Request, + guild_id: int, + discord_user_id: int, + payload: MinecraftBindingRequest, +) -> dict[str, object]: + if not is_valid_minecraft_username(payload.username): + raise HTTPException(status_code=400, detail="Minecraft 用户名必须是 3-16 位字母、数字或下划线。") + bindings = await request.app.state.store.set_minecraft_binding(guild_id, discord_user_id, payload.username) + return {"guild_id": guild_id, "discord_user_id": str(discord_user_id), "username": payload.username, "bindings": bindings} + + +@router.delete("/bindings/{guild_id}/{discord_user_id}") +async def remove_minecraft_binding(request: Request, guild_id: int, discord_user_id: int) -> dict[str, object]: + removed = await request.app.state.store.remove_minecraft_binding(guild_id, discord_user_id) + return {"guild_id": guild_id, "discord_user_id": str(discord_user_id), "removed": removed} diff --git a/app/api/routes/settings.py b/app/api/routes/settings.py index 1e9a3ce..643731d 100644 --- a/app/api/routes/settings.py +++ b/app/api/routes/settings.py @@ -18,6 +18,8 @@ async def dashboard(request: Request, guild_id: int | None = Query(default=None) "health": await request.app.state.health_provider(request), "catalog": get_setting_catalog(), "providers": request.app.state.openrouter.list_providers(), + "image_profiles": request.app.state.image_sources.list_profiles(), + "personas": request.app.state.personas.list_profiles(), "global_settings": snapshot.global_settings.model_dump(), "guilds": { guild_key: guild_settings.model_dump() @@ -33,6 +35,13 @@ async def dashboard(request: Request, guild_id: int | None = Query(default=None) guild_key: guild_stats.model_dump() for guild_key, guild_stats in snapshot.stats.items() }, + "user_preferences": { + guild_key: { + user_key: prefs.model_dump() + for user_key, prefs in guild_prefs.items() + } + for guild_key, guild_prefs in snapshot.user_preferences.items() + }, } @@ -93,3 +102,9 @@ async def get_stats(request: Request, guild_id: int) -> dict[str, object]: snapshot = await request.app.state.store.get_snapshot() stats = snapshot.stats.get(str(guild_id)) return stats.model_dump() if stats else {"total_tax_cases": 0, "user_stats": {}} + + +@router.get("/preferences/{guild_id}/{user_id}") +async def get_user_preferences(request: Request, guild_id: int, user_id: int) -> dict[str, object]: + preferences = await request.app.state.store.get_user_preferences(guild_id, user_id) + return preferences.model_dump() diff --git a/app/api/schemas.py b/app/api/schemas.py index dfd7c4c..961121a 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -2,7 +2,7 @@ from typing import Any -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator class SettingsPatchRequest(BaseModel): @@ -11,7 +11,6 @@ class SettingsPatchRequest(BaseModel): class ChatRequest(BaseModel): prompt: str - system_prompt: str | None = None model: str | None = None profile: str | None = None fallback_profiles: str | None = None @@ -34,7 +33,7 @@ def validate_source(self): class DrawRequest(BaseModel): - prompt: str + prompt: str = "" model: str | None = None profile: str | None = None fallback_profiles: str | None = None @@ -49,3 +48,41 @@ class DrawResultRequest(BaseModel): id: str profile: str | None = None provider: str | None = None + + +class MinecraftChatEvent(BaseModel): + server_id: str = Field(default="default", max_length=64) + player_uuid: str = Field(max_length=64) + player_name: str = Field(max_length=16) + message: str = Field(max_length=500) + world: str | None = Field(default=None, max_length=64) + + @field_validator("server_id", "player_uuid", "player_name", "message", "world", mode="before") + @classmethod + def trim_text(cls, value): + if value is None: + return None + return str(value).strip() + + +class MinecraftQueuedMessage(BaseModel): + id: int + username: str + display_name: str + discord_user_id: str + content: str + created_at: str + + +class MinecraftMessagesResponse(BaseModel): + messages: list[MinecraftQueuedMessage] = Field(default_factory=list) + next_after: int = 0 + + +class MinecraftBindingRequest(BaseModel): + username: str = Field(max_length=16) + + @field_validator("username", mode="before") + @classmethod + def trim_username(cls, value): + return str(value).strip() diff --git a/app/bot/client.py b/app/bot/client.py index 35f1640..739b43d 100644 --- a/app/bot/client.py +++ b/app/bot/client.py @@ -12,10 +12,13 @@ from app.core.catalog import GLOBAL_SETTING_SPECS, GUILD_SETTING_SPECS, cast_setting_value from app.core.config import EnvSettings -from app.models.state import GuildSettings, TaxCase, utcnow -from app.services.danbooru import DanbooruClient -from app.services.fun import FunService from app.services.ai_router import ProfiledAIClient +from app.models.state import GuildSettings, TaxCase, UserImagePreferences, utcnow +from app.services.fun import FunService +from app.services.image_sources import ImageSourceRouter +from app.services.minecraft_bridge import is_valid_minecraft_username +from app.services.personas import PersonaStore +from app.services.saucenao import SauceNaoClient from app.services.state_store import StateStore @@ -53,7 +56,9 @@ def __init__( env: EnvSettings, store: StateStore, openrouter: ProfiledAIClient, - danbooru: DanbooruClient, + image_sources: ImageSourceRouter, + personas: PersonaStore, + saucenao: SauceNaoClient, ) -> None: intents = discord.Intents.default() intents.guilds = True @@ -66,7 +71,10 @@ def __init__( self.env = env self.store = store self.openrouter = openrouter - self.danbooru = danbooru + self.image_sources = image_sources + self.personas = personas + self.saucenao = saucenao + self.minecraft_bridge = None self._commands_registered = False self._synced = False @@ -95,6 +103,7 @@ async def on_ready(self) -> None: def _register_commands(self) -> None: ai_group = app_commands.Group(name="ai", description="AI 功能") fun_group = app_commands.Group(name="fun", description="娱乐功能") + minecraft_group = app_commands.Group(name="minecraft", description="Minecraft 互通") config_group = app_commands.Group( name="config", description="配置功能", @@ -126,7 +135,13 @@ async def ai_chat( if image is not None and self._attachment_is_image(image): image_urls.append(image.url) messages = [ - {"role": "system", "content": self._compose_ai_system_prompt(global_settings.system_prompt)}, + { + "role": "system", + "content": self._compose_ai_system_prompt( + self._resolve_system_prompt(global_settings), + global_settings, + ), + }, { "role": "user", "content": self._build_user_multimodal_content( @@ -173,7 +188,10 @@ async def ai_summary(interaction: discord.Interaction, limit: int | None = None) try: reply = await self.openrouter.summarize_text( - self._compose_ai_system_prompt(global_settings.summary_system_prompt), + self._compose_ai_system_prompt( + self._resolve_summary_prompt(global_settings), + global_settings, + ), transcript, profile=global_settings.chat_model_profile, fallback_profiles=global_settings.chat_fallback_profiles, @@ -187,7 +205,7 @@ async def ai_summary(interaction: discord.Interaction, limit: int | None = None) @ai_group.command(name="draw", description="AI 绘图") @app_commands.describe( - prompt="你想生成的图片描述", + prompt="你想生成或改图的描述,可留空让系统按参考图自动补一句", profile="可选:指定绘图模型档案名,例如 grsai-sora-image / grsai-banana2", model="可选:临时覆盖档案内的模型名", size="可选:比例参数。Sora/GPT 用 size,Banana 档案会把它当 aspectRatio", @@ -196,7 +214,7 @@ async def ai_summary(interaction: discord.Interaction, limit: int | None = None) ) async def ai_draw( interaction: discord.Interaction, - prompt: str, + prompt: str | None = None, profile: str | None = None, model: str | None = None, size: str = "1:1", @@ -216,10 +234,16 @@ async def ai_draw( urls: list[str] = [] if image is not None and self._attachment_is_image(image): urls.append(image.url) + prompt_text = (prompt or "").strip() + if not prompt_text and urls: + prompt_text = "请基于提供的图片做一次高质量改图,保留主体、角色特征和关键细节。" + if not prompt_text: + await interaction.followup.send("想让我画图的话,至少给一句描述,或者附上一张参考图。") + return try: result = await self.openrouter.draw( - prompt=prompt, + prompt=prompt_text, model=model or None, profile=draw_profile, fallback_profiles=global_settings.draw_fallback_profiles, @@ -248,29 +272,18 @@ async def ai_draw( header = " | ".join(header_parts) await interaction.followup.send("\n".join([header, *images])) - @ai_group.command(name="image", description="AI 画图(/ai draw 别名)") + @fun_group.command(name="image", description="让系统替你捞一张图,按当前图站档案处理") @app_commands.describe( - prompt="你想生成的图片描述", - profile="可选:指定绘图模型档案名,例如 grsai-sora-image / grsai-banana2", - model="可选:临时覆盖档案内的模型名", - size="可选:比例参数", - variants="可选:生成张数 1 或 2", - image="可选:参考图", + style="safe 表示美图,explicit 表示涩图", + tags="额外标签,例如 1girl blue_hair", + profile="可选:临时指定图站档案,例如 danbooru / rule34", ) - async def ai_image( + async def fun_image( interaction: discord.Interaction, - prompt: str, + style: str = "safe", + tags: str | None = None, profile: str | None = None, - model: str | None = None, - size: str = "1:1", - variants: app_commands.Range[int, 1, 2] = 1, - image: discord.Attachment | None = None, ) -> None: - await ai_draw(interaction, prompt, profile, model, size, variants, image) - - @fun_group.command(name="danbooru", description="Danbooru 随机找图") - @app_commands.describe(tags="额外标签,例如 1girl blue_hair") - async def fun_danbooru(interaction: discord.Interaction, tags: str | None = None) -> None: guild_settings = await self._require_guild_settings(interaction) if guild_settings is None: return @@ -278,24 +291,34 @@ async def fun_danbooru(interaction: discord.Interaction, tags: str | None = None await interaction.response.send_message("这个服务器没有开启娱乐功能。", ephemeral=True) return + rating_mode = self._normalize_image_style(style) + if rating_mode is None: + await interaction.response.send_message("style 只能是 `safe` 或 `explicit`。", ephemeral=True) + return + if rating_mode == "explicit" and not self._channel_is_nsfw(interaction.channel): + await interaction.response.send_message("涩图去 NSFW 频道叫我。", ephemeral=True) + return + await interaction.response.defer(thinking=True) try: - is_nsfw = self._channel_is_nsfw(interaction.channel) - post = await self._fetch_danbooru_post( - guild_settings, - tags or "", - forced_rating="rating:e" if is_nsfw else "rating:s", + embed = await self._build_image_response_embed( + guild_settings=guild_settings, + guild_id=interaction.guild_id, + user_id=interaction.user.id, + rating_mode=rating_mode, + extra_tags=tags or "", + profile_override=profile, ) except Exception as exc: # pragma: no cover - network bound - await interaction.followup.send(f"Danbooru 请求失败:{exc}") + await interaction.followup.send(f"图站请求失败:{self._describe_error(exc)}") return await self.store.increment_user_stat(interaction.guild_id, interaction.user.id, "danbooru_calls") - await interaction.followup.send(embed=self._build_danbooru_embed(post)) + await interaction.followup.send(embed=embed) - @fun_group.command(name="pretty", description="来张安全美图") + @fun_group.command(name="danbooru", description="固定从 Danbooru 捞一张图") @app_commands.describe(tags="额外标签,例如 1girl blue_hair") - async def fun_pretty(interaction: discord.Interaction, tags: str | None = None) -> None: + async def fun_danbooru(interaction: discord.Interaction, tags: str | None = None) -> None: guild_settings = await self._require_guild_settings(interaction) if guild_settings is None: return @@ -305,17 +328,24 @@ async def fun_pretty(interaction: discord.Interaction, tags: str | None = None) await interaction.response.defer(thinking=True) try: - post = await self._fetch_danbooru_post(guild_settings, tags or "", forced_rating="rating:s") + embed = await self._build_image_response_embed( + guild_settings=guild_settings, + guild_id=interaction.guild_id, + user_id=interaction.user.id, + rating_mode="explicit" if self._channel_is_nsfw(interaction.channel) else "safe", + extra_tags=tags or "", + profile_override="danbooru", + ) except Exception as exc: # pragma: no cover - network bound - await interaction.followup.send(f"Danbooru 请求失败:{exc}") + await interaction.followup.send(f"图站请求失败:{self._describe_error(exc)}") return await self.store.increment_user_stat(interaction.guild_id, interaction.user.id, "danbooru_calls") - await interaction.followup.send(embed=self._build_danbooru_embed(post)) + await interaction.followup.send(embed=embed) - @fun_group.command(name="lewd", description="来张涩图,仅限 NSFW 频道") - @app_commands.describe(tags="额外标签,例如 azur_lane bunny_girl") - async def fun_lewd(interaction: discord.Interaction, tags: str | None = None) -> None: + @fun_group.command(name="rule34", description="固定从 Rule34 捞一张涩图") + @app_commands.describe(tags="额外标签,例如 bunny_girl azur_lane") + async def fun_rule34(interaction: discord.Interaction, tags: str | None = None) -> None: guild_settings = await self._require_guild_settings(interaction) if guild_settings is None: return @@ -323,21 +353,33 @@ async def fun_lewd(interaction: discord.Interaction, tags: str | None = None) -> await interaction.response.send_message("这个服务器没有开启娱乐功能。", ephemeral=True) return if not self._channel_is_nsfw(interaction.channel): - await interaction.response.send_message("涩图去 NSFW 频道叫我。", ephemeral=True) + await interaction.response.send_message("Rule34 也请去 NSFW 频道叫我。", ephemeral=True) return await interaction.response.defer(thinking=True) try: - post = await self._fetch_danbooru_post(guild_settings, tags or "", forced_rating="rating:e") + embed = await self._build_image_response_embed( + guild_settings=guild_settings, + guild_id=interaction.guild_id, + user_id=interaction.user.id, + rating_mode="explicit", + extra_tags=tags or "", + profile_override="rule34", + ) except Exception as exc: # pragma: no cover - network bound - await interaction.followup.send(f"Danbooru 请求失败:{exc}") + await interaction.followup.send(f"图站请求失败:{self._describe_error(exc)}") return await self.store.increment_user_stat(interaction.guild_id, interaction.user.id, "danbooru_calls") - await interaction.followup.send(embed=self._build_danbooru_embed(post)) + await interaction.followup.send(embed=embed) - @fun_group.command(name="fortune", description="看看今天运势") - async def fun_fortune(interaction: discord.Interaction) -> None: + @fun_group.command(name="pretty", description="请求一张勉强适合公开展示的美图") + @app_commands.describe(tags="额外标签,例如 1girl blue_hair", profile="可选:临时指定图站档案") + async def fun_pretty( + interaction: discord.Interaction, + tags: str | None = None, + profile: str | None = None, + ) -> None: guild_settings = await self._require_guild_settings(interaction) if guild_settings is None: return @@ -345,44 +387,147 @@ async def fun_fortune(interaction: discord.Interaction) -> None: await interaction.response.send_message("这个服务器没有开启娱乐功能。", ephemeral=True) return - result = FunService.daily_fortune(interaction.user.id, interaction.guild_id) - await self.store.increment_user_stat(interaction.guild_id, interaction.user.id, "fortune_calls") - await interaction.response.send_message( - f"{interaction.user.mention} 今日运势 `{result['score']}/100`\n{result['text']}" - ) + await interaction.response.defer(thinking=True) + try: + embed = await self._build_image_response_embed( + guild_settings=guild_settings, + guild_id=interaction.guild_id, + user_id=interaction.user.id, + rating_mode="safe", + extra_tags=tags or "", + profile_override=profile, + ) + except Exception as exc: # pragma: no cover - network bound + await interaction.followup.send(f"图站请求失败:{self._describe_error(exc)}") + return + + await self.store.increment_user_stat(interaction.guild_id, interaction.user.id, "danbooru_calls") + await interaction.followup.send(embed=embed) - @fun_group.command(name="roulette", description="玩一把轮盘") - async def fun_roulette(interaction: discord.Interaction) -> None: + @fun_group.command(name="lewd", description="请求一张需要 NSFW 频道兜底的涩图") + @app_commands.describe(tags="额外标签,例如 azur_lane bunny_girl", profile="可选:临时指定图站档案") + async def fun_lewd( + interaction: discord.Interaction, + tags: str | None = None, + profile: str | None = None, + ) -> None: guild_settings = await self._require_guild_settings(interaction) if guild_settings is None: return if not guild_settings.fun_enabled: await interaction.response.send_message("这个服务器没有开启娱乐功能。", ephemeral=True) return + if not self._channel_is_nsfw(interaction.channel): + await interaction.response.send_message("涩图去 NSFW 频道叫我。", ephemeral=True) + return + + await interaction.response.defer(thinking=True) + try: + embed = await self._build_image_response_embed( + guild_settings=guild_settings, + guild_id=interaction.guild_id, + user_id=interaction.user.id, + rating_mode="explicit", + extra_tags=tags or "", + profile_override=profile, + ) + except Exception as exc: # pragma: no cover - network bound + await interaction.followup.send(f"图站请求失败:{self._describe_error(exc)}") + return + + await self.store.increment_user_stat(interaction.guild_id, interaction.user.id, "danbooru_calls") + await interaction.followup.send(embed=embed) + + @fun_group.command(name="image_prefs", description="查看你自己的图站偏好") + async def fun_image_prefs(interaction: discord.Interaction) -> None: + guild_settings = await self._require_guild_settings(interaction) + if guild_settings is None: + return + preferences = await self.store.get_user_preferences(interaction.guild_id, interaction.user.id) + lines = [ + f"`safe_image_site_profile`: {preferences.safe_image_site_profile or guild_settings.safe_image_site_profile}", + f"`explicit_image_site_profile`: {preferences.explicit_image_site_profile or guild_settings.explicit_image_site_profile}", + f"`safe_image_tags`: {preferences.safe_image_tags or guild_settings.safe_image_default_tags or '-'}", + f"`explicit_image_tags`: {preferences.explicit_image_tags or guild_settings.explicit_image_default_tags or '-'}", + ] + await interaction.response.send_message("\n".join(lines), ephemeral=True) - result = FunService.roulette() - await self.store.increment_user_stat(interaction.guild_id, interaction.user.id, "roulette_calls") + @fun_group.command(name="image_source", description="设置你自己的美图/涩图默认图站") + @app_commands.describe(style="safe 表示美图,explicit 表示涩图", profile="图站档案名,例如 danbooru / rule34") + async def fun_image_source(interaction: discord.Interaction, style: str, profile: str) -> None: + guild_settings = await self._require_guild_settings(interaction) + if guild_settings is None: + return + rating_mode = self._normalize_image_style(style) + if rating_mode is None: + await interaction.response.send_message("style 只能是 `safe` 或 `explicit`。", ephemeral=True) + return + available_profiles = self.image_sources.list_profiles() + if profile not in available_profiles: + await interaction.response.send_message( + f"未知图站档案。可选:{', '.join(sorted(available_profiles))}", + ephemeral=True, + ) + return + capability_key = "supports_safe" if rating_mode == "safe" else "supports_explicit" + if not available_profiles[profile].get(capability_key): + await interaction.response.send_message( + f"`{profile}` 不支持当前模式 `{rating_mode}`。", + ephemeral=True, + ) + return + target_key = "safe_image_site_profile" if rating_mode == "safe" else "explicit_image_site_profile" + updated = await self.store.update_user_preferences(interaction.guild_id, interaction.user.id, {target_key: profile}) await interaction.response.send_message( - f"{interaction.user.mention} 扣下扳机……\n{result['text']} (弹仓位置: {result['chamber']}/6)" + f"已更新你的 `{target_key}` -> `{getattr(updated, target_key)}`", + ephemeral=True, ) - @fun_group.command(name="coin", description="抛个硬币") - async def fun_coin(interaction: discord.Interaction) -> None: + @fun_group.command(name="image_tags", description="设置你自己的美图/涩图默认 tag") + @app_commands.describe(style="safe 表示美图,explicit 表示涩图", tags="输入标签;填 clear / none / - 可以清空") + async def fun_image_tags(interaction: discord.Interaction, style: str, tags: str) -> None: guild_settings = await self._require_guild_settings(interaction) if guild_settings is None: return - if not guild_settings.fun_enabled: - await interaction.response.send_message("这个服务器没有开启娱乐功能。", ephemeral=True) + rating_mode = self._normalize_image_style(style) + if rating_mode is None: + await interaction.response.send_message("style 只能是 `safe` 或 `explicit`。", ephemeral=True) return - - result = FunService.coinflip(interaction.user.id, interaction.guild_id) + normalized_tags = "" if tags.strip().lower() in {"clear", "none", "-"} else tags.strip() + target_key = "safe_image_tags" if rating_mode == "safe" else "explicit_image_tags" + updated = await self.store.update_user_preferences( + interaction.guild_id, + interaction.user.id, + {target_key: normalized_tags}, + ) await interaction.response.send_message( - f"{interaction.user.mention} {result['text']}\n结果:`{result['side']}`" + f"已更新你的 `{target_key}` -> `{getattr(updated, target_key) or '-'}`", + ephemeral=True, ) - @fun_group.command(name="choose", description="从多个选项里替你选一个") - @app_commands.describe(options="用 | 分隔多个选项,例如 火锅 | 烤肉 | 麻辣烫") - async def fun_choose(interaction: discord.Interaction, options: str) -> None: + @fun_group.command(name="image_sites", description="列出当前可用的图站档案") + async def fun_image_sites(interaction: discord.Interaction) -> None: + profiles = self.image_sources.list_profiles() + lines = [] + for name, item in sorted(profiles.items()): + support = [] + if item.get("supports_safe"): + support.append("safe") + if item.get("supports_explicit"): + support.append("explicit") + lines.append(f"`{name}` | 支持: {', '.join(support) or '-'} | {item.get('description', '')}") + await interaction.response.send_message("\n".join(lines), ephemeral=True) + + @fun_group.command(name="sauce", description="用 SauceNAO 反查图片来源") + @app_commands.describe( + image="可选:直接附一张图", + url="可选:直接贴图片 URL", + ) + async def fun_sauce( + interaction: discord.Interaction, + image: discord.Attachment | None = None, + url: str | None = None, + ) -> None: guild_settings = await self._require_guild_settings(interaction) if guild_settings is None: return @@ -390,19 +535,45 @@ async def fun_choose(interaction: discord.Interaction, options: str) -> None: await interaction.response.send_message("这个服务器没有开启娱乐功能。", ephemeral=True) return + image_url = url.strip() if url and url.strip() else None + if image_url is None and image is not None and self._attachment_is_image(image): + image_url = image.url + if image_url is None: + await interaction.response.send_message("给我一张图或者一个图片 URL,我才有东西可查。", ephemeral=True) + return + + await interaction.response.defer(thinking=True, ephemeral=True) try: - result = FunService.choose(options.split("|"), interaction.guild_id, interaction.user.id) - except ValueError as exc: - await interaction.response.send_message(str(exc), ephemeral=True) + payload = await self.saucenao.search(image_url) + except Exception as exc: # pragma: no cover - network bound + await interaction.followup.send(f"SauceNAO 搜图失败:{self._describe_error(exc)}", ephemeral=True) + return + + embed = self._build_saucenao_embed(payload, image_url) + await interaction.followup.send(embed=embed, ephemeral=True) + + @fun_group.command(name="fortune", description="查看今日系统运势评估") + async def fun_fortune(interaction: discord.Interaction) -> None: + guild_settings = await self._require_guild_settings(interaction) + if guild_settings is None: + return + if not guild_settings.fun_enabled: + await interaction.response.send_message("这个服务器没有开启娱乐功能。", ephemeral=True) return + global_settings = await self.store.get_global_settings() + result = FunService.daily_fortune( + interaction.user.id, + interaction.guild_id, + config=self._get_fun_payload(global_settings), + ) + await self.store.increment_user_stat(interaction.guild_id, interaction.user.id, "fortune_calls") await interaction.response.send_message( - f"{interaction.user.mention} 我替你做了决定:`{result['choice']}`" + f"{interaction.user.mention} 今日运势评估:`{result['score']}/100`\n{result['text']}" ) - @fun_group.command(name="eightball", description="问机器人一个是非题") - @app_commands.describe(question="例如:我今天该不该熬夜?") - async def fun_eightball(interaction: discord.Interaction, question: str) -> None: + @fun_group.command(name="coin", description="把你的决策权外包给一枚硬币") + async def fun_coin(interaction: discord.Interaction) -> None: guild_settings = await self._require_guild_settings(interaction) if guild_settings is None: return @@ -410,13 +581,19 @@ async def fun_eightball(interaction: discord.Interaction, question: str) -> None await interaction.response.send_message("这个服务器没有开启娱乐功能。", ephemeral=True) return - result = FunService.eight_ball(question, interaction.guild_id, interaction.user.id) + global_settings = await self.store.get_global_settings() + result = FunService.coinflip( + interaction.user.id, + interaction.guild_id, + config=self._get_fun_payload(global_settings), + ) await interaction.response.send_message( - f"{interaction.user.mention} 问题:{question}\n回答:{result['answer']}" + f"{interaction.user.mention} {result['text']}\n结果:`{result['side']}`" ) - @fun_group.command(name="waifu", description="抽今日老婆/老公") - async def fun_waifu(interaction: discord.Interaction) -> None: + @fun_group.command(name="eightball", description="让系统对你的是非题做一次冷酷裁决") + @app_commands.describe(question="例如:我今天该不该熬夜?") + async def fun_eightball(interaction: discord.Interaction, question: str) -> None: guild_settings = await self._require_guild_settings(interaction) if guild_settings is None: return @@ -424,23 +601,19 @@ async def fun_waifu(interaction: discord.Interaction) -> None: await interaction.response.send_message("这个服务器没有开启娱乐功能。", ephemeral=True) return - members = [member for member in interaction.guild.members if not member.bot] - target_id = FunService.pick_waifu( - [member.id for member in members], + global_settings = await self.store.get_global_settings() + result = FunService.eight_ball( + question, interaction.guild_id, interaction.user.id, - utcnow().strftime("%Y-%m-%d"), + config=self._get_fun_payload(global_settings), ) - if target_id is None: - await interaction.response.send_message("服务器里没有可选成员。", ephemeral=True) - return - target = interaction.guild.get_member(target_id) await interaction.response.send_message( - f"{interaction.user.mention} 今日命中目标:{target.mention if target else target_id}" + f"{interaction.user.mention} 问题:{question}\n回答:{result['answer']}" ) - @fun_group.command(name="lottery", description="今日抽签(升级版)") - async def fun_lottery(interaction: discord.Interaction) -> None: + @fun_group.command(name="waifu", description="抽取你今天的 safe 二次元老婆") + async def fun_waifu(interaction: discord.Interaction) -> None: guild_settings = await self._require_guild_settings(interaction) if guild_settings is None: return @@ -448,14 +621,20 @@ async def fun_lottery(interaction: discord.Interaction) -> None: await interaction.response.send_message("这个服务器没有开启娱乐功能。", ephemeral=True) return - result = FunService.lottery(interaction.user.id, interaction.guild_id) - await self.store.increment_user_stat(interaction.guild_id, interaction.user.id, "lottery_calls") - await interaction.response.send_message( - f"{interaction.user.mention} 今日签运点数:`{result['roll']}`\n" - f"稀有度:`{result['rarity']}`\n{result['text']}\n{result['omen']}" - ) + await interaction.response.defer(thinking=True) + try: + post = await self._fetch_daily_waifu_post( + guild_settings=guild_settings, + guild_id=interaction.guild_id, + user_id=interaction.user.id, + ) + except Exception as exc: + await interaction.followup.send(f"今日老婆抽取失败:{self._describe_error(exc)}") + return + + await interaction.followup.send(embed=self._build_waifu_embed(post, user_mention=interaction.user.mention)) - @fun_group.command(name="ship", description="测一测两个人的电波同步率") + @fun_group.command(name="ship", description="测量两名测试对象的电波同步率") @app_commands.describe(member_a="第一个人", member_b="第二个人") async def fun_ship( interaction: discord.Interaction, @@ -469,18 +648,49 @@ async def fun_ship( await interaction.response.send_message("这个服务器没有开启娱乐功能。", ephemeral=True) return + global_settings = await self.store.get_global_settings() result = FunService.ship_score( member_a.id, member_b.id, interaction.guild_id, utcnow().strftime("%Y-%m-%d"), + config=self._get_fun_payload(global_settings), ) await interaction.response.send_message( f"{member_a.mention} x {member_b.mention}\n" f"同步率:`{result['score']}%`\n{result['label']}" ) - @fun_group.command(name="leaderboard", description="看排行榜") + @fun_group.command(name="duel", description="模拟两名测试对象之间的荒谬决斗") + @app_commands.describe(challenger="挑战者", defender="被挑战者") + async def fun_duel( + interaction: discord.Interaction, + challenger: discord.Member, + defender: discord.Member, + ) -> None: + guild_settings = await self._require_guild_settings(interaction) + if guild_settings is None: + return + if not guild_settings.fun_enabled: + await interaction.response.send_message("这个服务器没有开启娱乐功能。", ephemeral=True) + return + + global_settings = await self.store.get_global_settings() + result = FunService.duel( + challenger.id, + defender.id, + interaction.guild_id, + utcnow().strftime("%Y-%m-%d"), + config=self._get_fun_payload(global_settings), + ) + winner = challenger if result["winner_id"] == challenger.id else defender + await interaction.response.send_message( + f"{challenger.mention} vs {defender.mention}\n" + f"胜者:{winner.mention}\n" + f"压制幅度:`{result['margin']}%`\n{result['flavor']}" + ) + + @fun_group.command(name="leaderboard", description="查看本群娱乐与事故贡献排行榜") async def fun_leaderboard(interaction: discord.Interaction) -> None: guild_settings = await self._require_guild_settings(interaction) if guild_settings is None: @@ -509,81 +719,144 @@ async def fun_leaderboard(interaction: discord.Interaction) -> None: name = member.display_name if member else f"User {user_id}" lines.append( f"{index}. {name} | 警告 {stats.warnings} | 已交税 {stats.taxes_paid} | " - f"AI {stats.ai_calls} | 色图 {stats.danbooru_calls} | 抽签 {stats.lottery_calls}" + f"AI {stats.ai_calls} | 色图 {stats.danbooru_calls} | 运势 {stats.fortune_calls}" ) await interaction.response.send_message("\n".join(lines)) - @config_group.command(name="view", description="查看本服务器配置") - async def config_view(interaction: discord.Interaction) -> None: + @minecraft_group.command(name="bind", description="绑定你的 Minecraft 用户名") + @app_commands.describe(username="Minecraft 用户名,3-16 位字母、数字或下划线") + async def minecraft_bind(interaction: discord.Interaction, username: str) -> None: guild_settings = await self._require_guild_settings(interaction) - if guild_settings is None: + if guild_settings is None or interaction.guild_id is None: + return + username = username.strip() + if not is_valid_minecraft_username(username): + await interaction.response.send_message("Minecraft 用户名必须是 3-16 位字母、数字或下划线。", ephemeral=True) + return + await self.store.set_minecraft_binding(interaction.guild_id, interaction.user.id, username) + await interaction.response.send_message(f"已绑定 Minecraft 用户名 `{username}`。", ephemeral=True) + + @minecraft_group.command(name="unbind", description="解除你的 Minecraft 用户名绑定") + async def minecraft_unbind(interaction: discord.Interaction) -> None: + guild_settings = await self._require_guild_settings(interaction) + if guild_settings is None or interaction.guild_id is None: + return + removed = await self.store.remove_minecraft_binding(interaction.guild_id, interaction.user.id) + text = "已解除 Minecraft 用户名绑定。" if removed else "你还没有绑定 Minecraft 用户名。" + await interaction.response.send_message(text, ephemeral=True) + + @minecraft_group.command(name="status", description="查看 Minecraft 互通状态") + async def minecraft_status(interaction: discord.Interaction) -> None: + guild_settings = await self._require_guild_settings(interaction) + if guild_settings is None or interaction.guild_id is None: return + binding = await self.store.get_minecraft_binding(interaction.guild_id, interaction.user.id) + channel = f"<#{guild_settings.minecraft_channel_id}>" if guild_settings.minecraft_channel_id else "未配置" text = "\n".join( [ - f"`ai_enabled`: {guild_settings.ai_enabled}", - f"`fun_enabled`: {guild_settings.fun_enabled}", - f"`tax_enabled`: {guild_settings.tax_enabled}", - f"`tax_channel_id`: {guild_settings.tax_channel_id}", - f"`log_channel_id`: {guild_settings.log_channel_id}", - f"`shit_emoji`: {guild_settings.shit_emoji}", - f"`tax_required_images`: {guild_settings.tax_required_images}", - f"`tax_payment_window_minutes`: {guild_settings.tax_payment_window_minutes}", - f"`mute_hours`: {guild_settings.mute_hours}", - f"`summary_limit`: {guild_settings.summary_limit}", - f"`danbooru_default_tags`: {guild_settings.danbooru_default_tags}", + f"启用: `{guild_settings.minecraft_bridge_enabled}`", + f"server_id: `{guild_settings.minecraft_server_id}`", + f"频道: {channel}", + f"你的绑定: `{binding or '未绑定'}`", ] ) await interaction.response.send_message(text, ephemeral=True) - @config_group.command(name="global_view", description="查看全局 AI 配置") - async def config_global_view(interaction: discord.Interaction) -> None: - if not interaction.user.guild_permissions.manage_guild: - await interaction.response.send_message("需要管理服务器权限。", ephemeral=True) + @config_group.command(name="view", description="查看当前服务器与全局配置") + async def config_view(interaction: discord.Interaction) -> None: + guild_settings = await self._require_guild_settings(interaction) + if guild_settings is None: return global_settings = await self.store.get_global_settings() - text = "\n".join( + text = "\n\n".join( [ - f"`chat_model_profile`: {global_settings.chat_model_profile}", - f"`chat_fallback_profiles`: {global_settings.chat_fallback_profiles or '-'}", - f"`draw_model_profile`: {global_settings.draw_model_profile}", - f"`draw_fallback_profiles`: {global_settings.draw_fallback_profiles or '-'}", + "\n".join( + [ + "[Guild]", + f"`ai_enabled`: {guild_settings.ai_enabled}", + f"`fun_enabled`: {guild_settings.fun_enabled}", + f"`tax_enabled`: {guild_settings.tax_enabled}", + f"`minecraft_bridge_enabled`: {guild_settings.minecraft_bridge_enabled}", + f"`minecraft_server_id`: {guild_settings.minecraft_server_id}", + f"`minecraft_server_address`: {guild_settings.minecraft_server_address}", + f"`minecraft_channel_id`: {guild_settings.minecraft_channel_id}", + f"`minecraft_allow_no_token`: {guild_settings.minecraft_allow_no_token}", + f"`minecraft_max_message_length`: {guild_settings.minecraft_max_message_length}", + f"`welcome_channel_id`: {guild_settings.welcome_channel_id}", + f"`welcome_text`: {guild_settings.welcome_text}", + f"`verification_channel_id`: {guild_settings.verification_channel_id}", + f"`verification_role_id`: {guild_settings.verification_role_id}", + f"`verification_question`: {guild_settings.verification_question}", + f"`verification_answer`: {guild_settings.verification_answer}", + f"`verification_success_text`: {guild_settings.verification_success_text}", + f"`tax_channel_id`: {guild_settings.tax_channel_id}", + f"`log_channel_id`: {guild_settings.log_channel_id}", + f"`shit_emoji`: {guild_settings.shit_emoji}", + f"`tax_required_images`: {guild_settings.tax_required_images}", + f"`tax_payment_window_minutes`: {guild_settings.tax_payment_window_minutes}", + f"`mute_hours`: {guild_settings.mute_hours}", + f"`summary_limit`: {guild_settings.summary_limit}", + f"`danbooru_default_tags`: {guild_settings.danbooru_default_tags}", + f"`safe_image_site_profile`: {guild_settings.safe_image_site_profile}", + f"`explicit_image_site_profile`: {guild_settings.explicit_image_site_profile}", + f"`safe_image_default_tags`: {guild_settings.safe_image_default_tags or '-'}", + f"`explicit_image_default_tags`: {guild_settings.explicit_image_default_tags or '-'}", + ] + ), + "\n".join( + [ + "[Global]", + f"`persona_profile`: {getattr(global_settings, 'persona_profile', 'glados')}", + f"`chat_model_profile`: {global_settings.chat_model_profile}", + f"`chat_fallback_profiles`: {global_settings.chat_fallback_profiles or '-'}", + f"`draw_model_profile`: {global_settings.draw_model_profile}", + f"`draw_fallback_profiles`: {global_settings.draw_fallback_profiles or '-'}", + f"`max_chat_history`: {global_settings.max_chat_history}", + ] + ), ] ) await interaction.response.send_message(text, ephemeral=True) - @config_group.command(name="global_set", description="修改全局 AI 配置") - @app_commands.describe(key="全局配置项名", value="配置值") - async def config_global_set(interaction: discord.Interaction, key: str, value: str) -> None: + @config_group.command(name="set", description="按 key 自动修改服务器或全局配置") + @app_commands.describe(key="配置项名", value="配置值") + async def config_set(interaction: discord.Interaction, key: str, value: str) -> None: if not interaction.user.guild_permissions.manage_guild: await interaction.response.send_message("需要管理服务器权限。", ephemeral=True) return - if key not in GLOBAL_SETTING_SPECS: + in_global = key in GLOBAL_SETTING_SPECS + in_guild = key in GUILD_SETTING_SPECS + if not in_global and not in_guild: await interaction.response.send_message( - f"未知全局配置项。可选:{', '.join(GLOBAL_SETTING_SPECS.keys())}", + "未知配置项。可选全局:" + f"{', '.join(GLOBAL_SETTING_SPECS.keys())}\n可选服务器:{', '.join(GUILD_SETTING_SPECS.keys())}", ephemeral=True, ) return - try: - cast_value = cast_setting_value(GLOBAL_SETTING_SPECS, key, value) - except Exception as exc: - await interaction.response.send_message(f"配置值不合法:{exc}", ephemeral=True) + if in_global and in_guild: + await interaction.response.send_message(f"配置项 `{key}` 存在歧义,暂时不能自动分流。", ephemeral=True) return - - settings = await self.store.update_global_settings({key: cast_value}) - await interaction.response.send_message( - f"已更新全局 `{key}` -> `{getattr(settings, key)}`", - ephemeral=True, - ) - - @config_group.command(name="set", description="设置某个配置项") - @app_commands.describe(key="配置项名", value="配置值") - async def config_set(interaction: discord.Interaction, key: str, value: str) -> None: - if not interaction.user.guild_permissions.manage_guild: - await interaction.response.send_message("需要管理服务器权限。", ephemeral=True) + if in_global: + if key not in GLOBAL_SETTING_SPECS: + await interaction.response.send_message( + f"未知全局配置项。可选:{', '.join(GLOBAL_SETTING_SPECS.keys())}", + ephemeral=True, + ) + return + try: + cast_value = cast_setting_value(GLOBAL_SETTING_SPECS, key, value) + except Exception as exc: + await interaction.response.send_message(f"配置值不合法:{exc}", ephemeral=True) + return + settings = await self.store.update_global_settings({key: cast_value}) + await interaction.response.send_message( + f"已更新全局 `{key}` -> `{getattr(settings, key)}`", + ephemeral=True, + ) return if key not in GUILD_SETTING_SPECS: await interaction.response.send_message( - f"未知配置项。可选:{', '.join(GUILD_SETTING_SPECS.keys())}", + f"未知服务器配置项。可选:{', '.join(GUILD_SETTING_SPECS.keys())}", ephemeral=True, ) return @@ -603,25 +876,73 @@ async def config_set(interaction: discord.Interaction, key: str, value: str) -> ephemeral=True, ) - @config_group.command(name="tax_channel", description="设置税务频道") - @app_commands.describe(channel="要作为补税频道的频道") - async def config_tax_channel( - interaction: discord.Interaction, - channel: discord.TextChannel, - ) -> None: - if not interaction.user.guild_permissions.manage_guild: - await interaction.response.send_message("需要管理服务器权限。", ephemeral=True) + @app_commands.context_menu(name="GLaDOS 改图") + async def message_redraw(interaction: discord.Interaction, target: discord.Message) -> None: + guild_settings = await self._require_guild_settings(interaction) + if guild_settings is None: return - await self.store.update_guild_settings( - interaction.guild_id, - {"tax_channel_id": channel.id}, - guild_name=interaction.guild.name, - ) - await interaction.response.send_message(f"税务频道已设置为 {channel.mention}", ephemeral=True) + if not guild_settings.ai_enabled: + await interaction.response.send_message("这个服务器没有开启 AI 功能。", ephemeral=True) + return + + urls = self._extract_image_urls_from_message(target) + if not urls: + await interaction.response.send_message("这条消息里没有可用图片。", ephemeral=True) + return + + await interaction.response.defer(thinking=True) + global_settings = await self.store.get_global_settings() + prompt_text = target.content.strip() or "请基于提供的图片做一次高质量改图,保留主体、角色特征和关键细节。" + try: + result = await self.openrouter.draw( + prompt=prompt_text, + profile=getattr(global_settings, "draw_model_profile", "legacy-draw"), + fallback_profiles=getattr(global_settings, "draw_fallback_profiles", ""), + size="1:1", + variants=1, + urls=urls, + ) + resolved = await self._await_draw_completion(result) + except Exception as exc: # pragma: no cover - network bound + await interaction.followup.send(f"绘图失败:{self._describe_error(exc)}") + return + + images = self._extract_draw_result_urls(resolved) + if not images: + await interaction.followup.send(f"绘图任务回来了,但没吐出图片地址:{resolved}") + return + + await self.store.increment_user_stat(interaction.guild_id, interaction.user.id, "ai_calls") + await interaction.followup.send("\n".join(images)) + + @app_commands.context_menu(name="GLaDOS 搜图") + async def message_sauce(interaction: discord.Interaction, target: discord.Message) -> None: + guild_settings = await self._require_guild_settings(interaction) + if guild_settings is None: + return + if not guild_settings.fun_enabled: + await interaction.response.send_message("这个服务器没有开启娱乐功能。", ephemeral=True) + return + + image_urls = self._extract_image_urls_from_message(target) + if not image_urls: + await interaction.response.send_message("这条消息里没有可用图片。", ephemeral=True) + return + + await interaction.response.defer(thinking=True, ephemeral=True) + try: + payload = await self.saucenao.search(image_urls[0]) + except Exception as exc: # pragma: no cover - network bound + await interaction.followup.send(f"SauceNAO 搜图失败:{self._describe_error(exc)}", ephemeral=True) + return + await interaction.followup.send(embed=self._build_saucenao_embed(payload, image_urls[0]), ephemeral=True) self.tree.add_command(ai_group) self.tree.add_command(fun_group) + self.tree.add_command(minecraft_group) self.tree.add_command(config_group) + self.tree.add_command(message_redraw) + self.tree.add_command(message_sauce) async def _require_guild_settings( self, @@ -649,9 +970,26 @@ async def _build_chat_history( history.reverse() return history - @staticmethod - def _compose_ai_system_prompt(base_prompt: str) -> str: - return f"{base_prompt}\n{BOT_THINKING_GUARD}" + def _get_persona_payload(self, persona_profile: str | None) -> dict[str, object]: + return self.personas.get_persona(persona_profile) + + def _get_fun_payload(self, global_settings) -> dict[str, object]: + persona = self._get_persona_payload(getattr(global_settings, "persona_profile", "glados")) + fun_payload = persona.get("fun") + return fun_payload if isinstance(fun_payload, dict) else {} + + def _resolve_system_prompt(self, global_settings) -> str: + persona = self._get_persona_payload(getattr(global_settings, "persona_profile", "glados")) + return str(persona.get("system_prompt", "")).strip() + + def _resolve_summary_prompt(self, global_settings) -> str: + persona = self._get_persona_payload(getattr(global_settings, "persona_profile", "glados")) + return str(persona.get("summary_system_prompt", "")).strip() + + def _compose_ai_system_prompt(self, base_prompt: str, global_settings) -> str: + persona = self._get_persona_payload(getattr(global_settings, "persona_profile", "glados")) + thinking_guard = str(persona.get("thinking_guard", "")).strip() or BOT_THINKING_GUARD + return f"{base_prompt}\n{thinking_guard}" async def _resolve_referenced_message(self, message: discord.Message) -> discord.Message | None: if not message.reference: @@ -710,6 +1048,25 @@ async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> settings=settings, ) + async def on_member_join(self, member: discord.Member) -> None: + settings = await self.store.get_guild_settings(member.guild.id, member.guild.name) + if settings.welcome_channel_id: + channel = member.guild.get_channel(settings.welcome_channel_id) + if isinstance(channel, discord.TextChannel): + welcome_text = settings.welcome_text.format( + user_mention=member.mention, + guild_name=member.guild.name, + ) + await channel.send(welcome_text) + + if settings.verification_channel_id and settings.verification_role_id: + channel = member.guild.get_channel(settings.verification_channel_id) + if isinstance(channel, discord.TextChannel): + await channel.send( + f"{member.mention} 验证问题:{settings.verification_question}\n" + "回答正确后会自动发放身份组。" + ) + async def on_message(self, message: discord.Message) -> None: if message.author.bot or message.guild is None: return @@ -717,6 +1074,9 @@ async def on_message(self, message: discord.Message) -> None: settings = await self.store.get_guild_settings(message.guild.id, message.guild.name) referenced = await self._resolve_referenced_message(message) + if await self._handle_verification_message(message, settings): + return + if await self._is_admin_tax_reply_trigger(message, settings): if isinstance(referenced, discord.Message) and isinstance(message.channel, discord.TextChannel): created = await self._try_create_tax_case( @@ -747,6 +1107,8 @@ async def on_message(self, message: discord.Message) -> None: mention_author=False, ) + await self._try_forward_discord_to_minecraft(message, settings) + if settings.ai_enabled and await self._is_direct_ai_trigger(message): prompt = self._extract_direct_ai_prompt(message) image_urls = self._extract_image_urls_from_message(message) @@ -767,7 +1129,10 @@ async def on_message(self, message: discord.Message) -> None: messages = [ { "role": "system", - "content": self._compose_ai_system_prompt(global_settings.system_prompt), + "content": self._compose_ai_system_prompt( + self._resolve_system_prompt(global_settings), + global_settings, + ), } ] messages.extend(await self._build_reference_context_messages(message, referenced)) @@ -798,6 +1163,16 @@ async def on_message(self, message: discord.Message) -> None: await self.process_commands(message) + async def _try_forward_discord_to_minecraft(self, message: discord.Message, settings: GuildSettings) -> bool: + bridge = self.minecraft_bridge + if bridge is None: + return False + try: + return await bridge.enqueue_discord_message(message, settings) + except Exception: + LOGGER.exception("Failed to enqueue Discord message for Minecraft bridge") + return False + @tasks.loop(minutes=1) async def tax_deadline_watcher(self) -> None: expired_cases = await self.store.expire_due_tax_cases() @@ -893,6 +1268,46 @@ async def _is_admin_tax_reply_trigger(self, message: discord.Message, settings: normalized = re.sub(r"\s+", "", (message.content or "").strip()) return normalized in TAX_REPLY_KEYWORDS or normalized.startswith("税") + async def _handle_verification_message(self, message: discord.Message, settings: GuildSettings) -> bool: + if not settings.verification_channel_id or not settings.verification_role_id: + return False + if message.channel.id != settings.verification_channel_id: + return False + + member = message.author if isinstance(message.author, discord.Member) else message.guild.get_member(message.author.id) + if member is None: + return False + role = message.guild.get_role(settings.verification_role_id) + if role is None: + return False + if role in member.roles: + return False + + expected = self._normalize_verification_answer(settings.verification_answer) + received = self._normalize_verification_answer(message.content or "") + if not received: + return False + if received != expected: + await message.reply("答案不对。再试一次。", mention_author=False) + return True + + try: + await member.add_roles(role, reason="答题验证通过") + except discord.HTTPException: + await message.reply("身份组发放失败。请检查机器人权限和身份组层级。", mention_author=False) + return True + + success_text = settings.verification_success_text.format( + user_mention=member.mention, + role_mention=role.mention, + ) + await message.reply(success_text, mention_author=False) + return True + + @staticmethod + def _normalize_verification_answer(text: str) -> str: + return re.sub(r"\s+", "", (text or "").strip()).lower() + @staticmethod def _message_has_image(message: discord.Message) -> bool: return EntertainmentBot._count_images(message) > 0 @@ -1011,11 +1426,96 @@ async def _build_reference_context_messages( return [{"role": "user", "content": content}] @staticmethod - def _compose_danbooru_tags(base_tags: str, extra_tags: str, forced_rating: str) -> str: - merged = " ".join(part.strip() for part in [base_tags, extra_tags] if part.strip()) + def _normalize_image_style(style: str | None) -> str | None: + lowered = (style or "").strip().lower() + if lowered in {"safe", "pretty", "sfw", "美图"}: + return "safe" + if lowered in {"explicit", "lewd", "nsfw", "涩图", "色图"}: + return "explicit" + return None + + @staticmethod + def _merge_image_tags(*parts: str) -> str: + merged = " ".join(part.strip() for part in parts if part and part.strip()) cleaned = RATING_TAG_RE.sub("", merged) - cleaned = re.sub(r"\s+", " ", cleaned).strip() - return f"{forced_rating} {cleaned}".strip() + return re.sub(r"\s+", " ", cleaned).strip() + + @staticmethod + def _legacy_image_tags(guild_settings: GuildSettings) -> str: + return re.sub(r"\s+", " ", RATING_TAG_RE.sub("", guild_settings.danbooru_default_tags)).strip() + + async def _resolve_effective_image_preferences( + self, + guild_id: int, + user_id: int, + guild_settings: GuildSettings, + ) -> tuple[UserImagePreferences, str, str]: + preferences = await self.store.get_user_preferences(guild_id, user_id) + safe_profile = preferences.safe_image_site_profile or guild_settings.safe_image_site_profile or "danbooru" + explicit_profile = preferences.explicit_image_site_profile or guild_settings.explicit_image_site_profile or "danbooru" + return preferences, safe_profile, explicit_profile + + async def _fetch_image_post( + self, + *, + guild_settings: GuildSettings, + guild_id: int, + user_id: int, + rating_mode: str, + extra_tags: str, + profile_override: str | None = None, + selection_key: str | None = None, + ) -> dict[str, object]: + preferences, safe_profile, explicit_profile = await self._resolve_effective_image_preferences( + guild_id, + user_id, + guild_settings, + ) + profile = profile_override or (safe_profile if rating_mode == "safe" else explicit_profile) + base_tags = ( + preferences.safe_image_tags or guild_settings.safe_image_default_tags or self._legacy_image_tags(guild_settings) + if rating_mode == "safe" + else preferences.explicit_image_tags or guild_settings.explicit_image_default_tags or self._legacy_image_tags(guild_settings) + ) + return await self.image_sources.random_post( + profile=profile, + rating_mode=rating_mode, + base_tags=base_tags, + extra_tags=extra_tags, + selection_key=selection_key, + ) + + async def _build_image_response_embed( + self, + *, + guild_settings: GuildSettings, + guild_id: int, + user_id: int, + rating_mode: str, + extra_tags: str, + profile_override: str | None = None, + ) -> discord.Embed: + post = await self._fetch_image_post( + guild_settings=guild_settings, + guild_id=guild_id, + user_id=user_id, + rating_mode=rating_mode, + extra_tags=extra_tags, + profile_override=profile_override, + ) + return self._build_image_embed(post) + + async def _fetch_daily_waifu_post( + self, + *, + guild_settings: GuildSettings, + guild_id: int, + user_id: int, + ) -> dict[str, object]: + return await self.image_sources.character_post( + profile="danbooru", + rating_mode="safe", + ) async def _handle_direct_bot_command( self, @@ -1039,10 +1539,10 @@ async def _handle_direct_bot_command( explicit_tags = self._extract_direct_image_tags(prompt, EXPLICIT_IMAGE_KEYWORDS) if explicit_tags is not None: - await self._handle_direct_danbooru( + await self._handle_direct_image_request( message, guild_settings=guild_settings, - forced_rating="rating:e", + rating_mode="explicit", extra_tags=explicit_tags, nsfw_required=True, ) @@ -1050,17 +1550,25 @@ async def _handle_direct_bot_command( safe_tags = self._extract_direct_image_tags(prompt, SAFE_IMAGE_KEYWORDS) if safe_tags is not None: - await self._handle_direct_danbooru( + await self._handle_direct_image_request( message, guild_settings=guild_settings, - forced_rating="rating:s", + rating_mode="safe", extra_tags=safe_tags, nsfw_required=False, ) return True + if self._is_direct_sauce_request(prompt): + await self._handle_direct_saucenao(message, referenced=referenced) + return True + if any(keyword in normalized for keyword in ("今日运势", "运势")): - result = FunService.daily_fortune(message.author.id, message.guild.id) + result = FunService.daily_fortune( + message.author.id, + message.guild.id, + config=self._get_fun_payload(global_settings), + ) await self.store.increment_user_stat(message.guild.id, message.author.id, "fortune_calls") await message.reply( f"{message.author.mention} 今日运势 `{result['score']}/100`\n{result['text']}", @@ -1068,72 +1576,57 @@ async def _handle_direct_bot_command( ) return True - if any(keyword in normalized for keyword in ("轮盘", "roulette")): - result = FunService.roulette() - await self.store.increment_user_stat(message.guild.id, message.author.id, "roulette_calls") - await message.reply( - f"{message.author.mention} 扣下扳机……\n{result['text']} (弹仓位置: {result['chamber']}/6)", - mention_author=False, - ) - return True - if any(keyword in normalized for keyword in ("抛硬币", "硬币", "coin")): - result = FunService.coinflip(message.author.id, message.guild.id) + result = FunService.coinflip( + message.author.id, + message.guild.id, + config=self._get_fun_payload(global_settings), + ) await message.reply( f"{message.author.mention} {result['text']}\n结果:`{result['side']}`", mention_author=False, ) return True - if any(keyword in normalized for keyword in ("抽签", "签运")): - result = FunService.lottery(message.author.id, message.guild.id) - await self.store.increment_user_stat(message.guild.id, message.author.id, "lottery_calls") - await message.reply( - f"{message.author.mention} 今日签运点数:`{result['roll']}`\n" - f"稀有度:`{result['rarity']}`\n{result['text']}\n{result['omen']}", - mention_author=False, - ) + if any(keyword in normalized for keyword in ("老婆", "老公", "waifu")): + await self._handle_waifu_image(message, guild_settings=guild_settings) return True - if any(keyword in normalized for keyword in ("老婆", "老公", "waifu")): - members = [member for member in message.guild.members if not member.bot] - target_id = FunService.pick_waifu( - [member.id for member in members], + if self._is_direct_eightball(prompt): + result = FunService.eight_ball( + prompt, message.guild.id, message.author.id, - utcnow().strftime("%Y-%m-%d"), + config=self._get_fun_payload(global_settings), ) - if target_id is None: - await message.reply("服务器里没有可选成员。", mention_author=False) - return True - target = message.guild.get_member(target_id) await message.reply( - f"{message.author.mention} 今日命中目标:{target.mention if target else target_id}", + f"{message.author.mention} 回答:{result['answer']}", mention_author=False, ) return True - if choose_options := self._parse_direct_choose_options(prompt): - try: - result = FunService.choose(choose_options, message.guild.id, message.author.id) - except ValueError as exc: - await message.reply(str(exc), mention_author=False) - return True - await message.reply( - f"{message.author.mention} 我替你做了决定:`{result['choice']}`", - mention_author=False, - ) - return True + return False - if self._is_direct_eightball(prompt): - result = FunService.eight_ball(prompt, message.guild.id, message.author.id) - await message.reply( - f"{message.author.mention} 回答:{result['answer']}", - mention_author=False, + async def _handle_waifu_image( + self, + message: discord.Message, + *, + guild_settings: GuildSettings, + ) -> None: + try: + post = await self._fetch_daily_waifu_post( + guild_settings=guild_settings, + guild_id=message.guild.id, + user_id=message.author.id, ) - return True + except Exception as exc: + await message.reply(f"今日老婆抽取失败:{self._describe_error(exc)}", mention_author=False) + return - return False + await message.reply( + embed=self._build_waifu_embed(post, user_mention=message.author.mention), + mention_author=False, + ) async def _handle_direct_draw( self, @@ -1180,12 +1673,12 @@ async def _handle_direct_draw( header_parts.append(str(resolved["_warning"])) await self._reply_in_chunks(message, "\n".join([" | ".join(header_parts), *images])) - async def _handle_direct_danbooru( + async def _handle_direct_image_request( self, message: discord.Message, *, guild_settings: GuildSettings, - forced_rating: str, + rating_mode: str, extra_tags: str, nsfw_required: bool, ) -> None: @@ -1194,32 +1687,25 @@ async def _handle_direct_danbooru( return try: - post = await self._fetch_danbooru_post(guild_settings, extra_tags, forced_rating=forced_rating) + post = await self._fetch_image_post( + guild_settings=guild_settings, + guild_id=message.guild.id, + user_id=message.author.id, + rating_mode=rating_mode, + extra_tags=extra_tags, + ) except Exception as exc: - await message.reply(f"Danbooru 请求失败:{self._describe_error(exc)}", mention_author=False) + await message.reply(f"图站请求失败:{self._describe_error(exc)}", mention_author=False) return await self.store.increment_user_stat(message.guild.id, message.author.id, "danbooru_calls") - await message.reply(embed=self._build_danbooru_embed(post), mention_author=False) - - async def _fetch_danbooru_post( - self, - guild_settings: GuildSettings, - extra_tags: str, - *, - forced_rating: str, - ) -> dict[str, object]: - search_tags = self._compose_danbooru_tags( - guild_settings.danbooru_default_tags, - extra_tags, - forced_rating, - ) - return await self.danbooru.random_post(search_tags) + await message.reply(embed=self._build_image_embed(post), mention_author=False) @staticmethod - def _build_danbooru_embed(post: dict[str, object]) -> discord.Embed: + def _build_image_embed(post: dict[str, object]) -> discord.Embed: + site_label = str(post.get("site_label") or post.get("site") or "Image") embed = discord.Embed( - title=f"Danbooru #{post['id']}", + title=f"{site_label} #{post['id']}", description=f"rating: `{post['rating']}`\n[查看原帖]({post['post_url']})", color=discord.Color.blurple(), ) @@ -1229,6 +1715,116 @@ def _build_danbooru_embed(post: dict[str, object]) -> discord.Embed: embed.add_field(name="Tags", value=tags_text, inline=False) return embed + @staticmethod + def _build_waifu_embed( + post: dict[str, object], + *, + user_mention: str, + ) -> discord.Embed: + copyright_text = str(post.get("copyrights") or "").strip() + character_text = str(post.get("characters") or "").strip() + if copyright_text and character_text: + origin = f"《{copyright_text.split(' ')[0].replace('_', ' ')}》的 {character_text.split(' ')[0].replace('_', ' ')}" + elif copyright_text: + origin = f"《{copyright_text.split(' ')[0].replace('_', ' ')}》" + elif character_text: + origin = character_text.split(" ")[0].replace("_", " ") + else: + origin = "未知作品 / 未知角色" + + title = "今日老婆" + description = f"{user_mention},系统为你分配的安全二次元样本来自 {origin}。" + embed = discord.Embed( + title=title, + description=description, + color=discord.Color.pink(), + ) + if post.get("file_url"): + embed.set_image(url=str(post["file_url"])) + tags = str(post.get("tags") or "").split() + tag_line = " ".join(tags[:12]) if tags else "无标签" + embed.add_field(name="标签", value=tag_line[:900], inline=False) + embed.add_field(name="来源", value=f"[查看原帖]({post['post_url']})", inline=False) + return embed + + async def _handle_direct_saucenao( + self, + message: discord.Message, + *, + referenced: discord.Message | None, + ) -> None: + image_urls = self._extract_image_urls_from_message(message) + if referenced is not None: + for image_url in self._extract_image_urls_from_message(referenced): + if image_url not in image_urls: + image_urls.append(image_url) + if not image_urls: + await message.reply("你要我搜图,总得给我一张图。可以直接带图,或者回复一条带图消息。", mention_author=False) + return + + try: + payload = await self.saucenao.search(image_urls[0]) + except Exception as exc: + await message.reply(f"SauceNAO 搜图失败:{self._describe_error(exc)}", mention_author=False) + return + await message.reply(embed=self._build_saucenao_embed(payload, image_urls[0]), mention_author=False) + + @staticmethod + def _build_saucenao_embed(payload: dict[str, object], image_url: str) -> discord.Embed: + header = payload.get("header") if isinstance(payload.get("header"), dict) else {} + results = payload.get("results") if isinstance(payload.get("results"), list) else [] + embed = discord.Embed( + title="SauceNAO 反查结果", + description=f"原图: [点击查看]({image_url})", + color=discord.Color.orange(), + ) + if results: + top = results[0] if isinstance(results[0], dict) else {} + top_header = top.get("header") if isinstance(top.get("header"), dict) else {} + top_data = top.get("data") if isinstance(top.get("data"), dict) else {} + similarity = top_header.get("similarity", "?") + title = ( + top_data.get("title") + or top_data.get("eng_name") + or top_data.get("source") + or top_data.get("creator") + or "未命名结果" + ) + index_name = top_header.get("index_name", "未知索引") + ext_urls = [str(item) for item in top_data.get("ext_urls", []) if isinstance(item, str)] + if top_header.get("thumbnail"): + embed.set_thumbnail(url=str(top_header["thumbnail"])) + embed.add_field( + name="最佳匹配", + value=f"`{title}`\n相似度: `{similarity}%`\n来源库: `{index_name}`", + inline=False, + ) + if ext_urls: + embed.add_field(name="外链", value="\n".join(ext_urls[:3]), inline=False) + + short_results = [] + for item in results[:5]: + if not isinstance(item, dict): + continue + item_header = item.get("header") if isinstance(item.get("header"), dict) else {} + item_data = item.get("data") if isinstance(item.get("data"), dict) else {} + similarity = item_header.get("similarity", "?") + title = ( + item_data.get("title") + or item_data.get("eng_name") + or item_data.get("source") + or item_data.get("creator") + or "未命名结果" + ) + short_results.append(f"{similarity}% | {title}") + if short_results: + embed.add_field(name="候选结果", value="\n".join(short_results), inline=False) + + status = header.get("status") + if status is not None: + embed.set_footer(text=f"status={status}") + return embed + @staticmethod def _normalize_direct_prompt(prompt: str) -> str: return re.sub(r"\s+", "", (prompt or "").strip()).lower() @@ -1256,16 +1852,9 @@ def _parse_direct_draw_request(prompt: str) -> dict[str, str | None] | None: return None @staticmethod - def _parse_direct_choose_options(prompt: str) -> list[str] | None: - trimmed = (prompt or "").strip() - lowered = trimmed.lower() - if lowered.startswith("choose"): - body = trimmed[6:].strip(" ::-") - return [item.strip() for item in body.split("|") if item.strip()] - if trimmed.startswith("选一个"): - body = trimmed[3:].strip(" ::-") - return [item.strip() for item in body.split("|") if item.strip()] - return None + def _is_direct_sauce_request(prompt: str) -> bool: + trimmed = (prompt or "").strip().lower() + return trimmed.startswith("搜图") or trimmed.startswith("sauce") or trimmed.startswith("source") @staticmethod def _is_direct_eightball(prompt: str) -> bool: diff --git a/app/core/catalog.py b/app/core/catalog.py index d6b291b..3682335 100644 --- a/app/core/catalog.py +++ b/app/core/catalog.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from copy import deepcopy @@ -20,13 +21,9 @@ "type": "str", "description": "绘图回退模型档案,逗号分隔多个档案名。", }, - "system_prompt": { + "persona_profile": { "type": "str", - "description": "AI 对话的系统提示词。", - }, - "summary_system_prompt": { - "type": "str", - "description": "AI 总结最近聊天时使用的系统提示词。", + "description": "当前机器人人设档案名,对应 personas.json 里的 profile。", }, "max_chat_history": { "type": "int", @@ -39,6 +36,26 @@ "ai_enabled": {"type": "bool", "description": "是否启用 AI 功能。"}, "fun_enabled": {"type": "bool", "description": "是否启用娱乐功能。"}, "tax_enabled": {"type": "bool", "description": "是否启用搬屎交税。"}, + "minecraft_bridge_enabled": {"type": "bool", "description": "是否启用 Minecraft 与 Discord 双向聊天。"}, + "minecraft_server_id": {"type": "str", "description": "Paper 插件连接时使用的 server_id。"}, + "minecraft_server_address": {"type": "str", "description": "Minecraft 服务器地址,用于记录和展示,例如 127.0.0.1:30001。"}, + "minecraft_channel_id": {"type": "int_optional", "description": "Minecraft 聊天同步到的 Discord 频道 ID。"}, + "minecraft_token": {"type": "str", "description": "Paper 插件访问 FastAPI 的 Bearer/X-DC-Bot-Token。留空时只允许本机或内网连接。"}, + "minecraft_allow_no_token": {"type": "bool", "description": "未配置 token 时是否允许本机或内网地址直连。"}, + "minecraft_max_message_length": {"type": "int", "description": "Minecraft/Discord 互通单条消息最大长度。"}, + "welcome_channel_id": {"type": "int_optional", "description": "欢迎频道 ID,也支持直接传 `<#频道>`。"}, + "welcome_text": { + "type": "str", + "description": "欢迎文案,可用 `{user_mention}` 和 `{guild_name}` 占位。", + }, + "verification_channel_id": {"type": "int_optional", "description": "答题领身份组的频道 ID,也支持 `<#频道>`。"}, + "verification_role_id": {"type": "int_optional", "description": "验证成功后发放的身份组 ID,也支持 `<@&身份组>`。"}, + "verification_question": {"type": "str", "description": "入群答题问题文本。"}, + "verification_answer": {"type": "str", "description": "入群答题正确答案。"}, + "verification_success_text": { + "type": "str", + "description": "验证成功提示,可用 `{user_mention}` 和 `{role_mention}`。", + }, "tax_channel_id": {"type": "int_optional", "description": "税务频道 ID。"}, "log_channel_id": {"type": "int_optional", "description": "日志频道 ID。"}, "shit_emoji": { @@ -62,6 +79,22 @@ "type": "str", "description": "Danbooru 默认搜索标签。", }, + "safe_image_site_profile": { + "type": "str", + "description": "本服务器默认的美图图站档案名,例如 danbooru。", + }, + "explicit_image_site_profile": { + "type": "str", + "description": "本服务器默认的涩图图站档案名,例如 danbooru / rule34。", + }, + "safe_image_default_tags": { + "type": "str", + "description": "本服务器默认的美图标签。", + }, + "explicit_image_default_tags": { + "type": "str", + "description": "本服务器默认的涩图标签。", + }, "warn_text": { "type": "str", "description": "搬屎交税警告模板,可用占位符见 README。", @@ -73,107 +106,165 @@ { "group": "ai", "name": "/ai chat", - "description": "调用 OpenRouter 模型聊天,支持可选图片输入。", + "description": "调用当前聊天档案与人设聊天,支持可选图片输入。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], }, { "group": "ai", "name": "/ai summary", "description": "总结当前频道最近的聊天记录。", + "permission": "user", + "entrypoints": ["slash"], }, { "group": "ai", "name": "/ai draw", - "description": "调用绘图接口生成图片。", + "description": "调用绘图接口生成图片或参考图改图。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], }, { - "group": "ai", - "name": "/ai image", - "description": "AI 绘图别名命令。", + "group": "fun", + "name": "/fun image", + "description": "按图站档案抓一张图,可选美图或涩图模式。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], }, { "group": "fun", "name": "/fun danbooru", - "description": "从 Danbooru 随机找图;NSFW 频道强制 rating:e,普通频道强制 rating:s。", + "description": "固定从 Danbooru 抓图;NSFW 频道强制 rating:e,普通频道强制 rating:s。", + "permission": "user", + "entrypoints": ["slash"], }, { "group": "fun", - "name": "/fun pretty", - "description": "快速来张安全美图。", + "name": "/fun rule34", + "description": "固定从 Rule34 抓图,仅限 NSFW 频道。", + "permission": "user", + "entrypoints": ["slash"], }, { "group": "fun", - "name": "/fun lewd", - "description": "快速来张涩图,仅限 NSFW 频道。", + "name": "/fun image_prefs", + "description": "查看你自己的图站默认来源和默认 tag。", + "permission": "user", + "entrypoints": ["slash"], }, { "group": "fun", - "name": "/fun fortune", - "description": "查看今日运势。", + "name": "/fun image_source", + "description": "设置你自己的美图或涩图默认图站档案。", + "permission": "user", + "entrypoints": ["slash"], }, { "group": "fun", - "name": "/fun roulette", - "description": "玩一局随机轮盘。", + "name": "/fun image_tags", + "description": "设置你自己的美图或涩图默认 tag。", + "permission": "user", + "entrypoints": ["slash"], }, { "group": "fun", - "name": "/fun coin", - "description": "抛硬币。", + "name": "/fun image_sites", + "description": "列出当前可用的图站档案。", + "permission": "user", + "entrypoints": ["slash"], }, { "group": "fun", - "name": "/fun choose", - "description": "从多个选项里随机挑一个。使用 | 分隔选项。", + "name": "/fun sauce", + "description": "用 SauceNAO 反查图片来源。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply", "message_menu"], }, { "group": "fun", - "name": "/fun eightball", - "description": "问机器人一个是非题。", + "name": "/fun pretty", + "description": "快速来张安全美图。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], }, { "group": "fun", - "name": "/fun waifu", - "description": "随机抽取今日老婆/老公。", + "name": "/fun lewd", + "description": "快速来张涩图,仅限 NSFW 频道。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], }, { "group": "fun", - "name": "/fun leaderboard", - "description": "查看搬屎、AI、娱乐统计榜单。", + "name": "/fun fortune", + "description": "查看今日系统运势评估。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], }, { "group": "fun", - "name": "/fun lottery", - "description": "每日抽签(升级版)。", + "name": "/fun coin", + "description": "把选择权外包给一枚硬币。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], }, { "group": "fun", - "name": "/fun ship", - "description": "测一测两个人的同步率。", + "name": "/fun eightball", + "description": "让系统对你的是非题做一次冷酷裁决。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], }, { - "group": "config", - "name": "/config view", - "description": "查看当前服务器配置。", + "group": "fun", + "name": "/fun waifu", + "description": "抽取你今天的 safe 二次元老婆图。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], }, { - "group": "config", - "name": "/config global_view", - "description": "查看全局 AI 档案和模型配置。", + "group": "fun", + "name": "/fun leaderboard", + "description": "查看本群娱乐与事故贡献排行榜。", + "permission": "user", + "entrypoints": ["slash"], }, { - "group": "config", - "name": "/config set", - "description": "外部命令修改服务器设置。", + "group": "fun", + "name": "/fun ship", + "description": "测量两名测试对象的电波同步率。", + "permission": "user", + "entrypoints": ["slash"], }, { - "group": "config", - "name": "/config global_set", - "description": "外部命令修改全局 AI 档案设置。", + "group": "fun", + "name": "/fun duel", + "description": "模拟两名测试对象之间的荒谬决斗。", + "permission": "user", + "entrypoints": ["slash"], }, + {"group": "config", "name": "/config view", "description": "查看当前服务器和全局配置。", "permission": "admin", "entrypoints": ["slash"]}, + {"group": "config", "name": "/config set", "description": "按配置项名自动修改服务器或全局配置。", "permission": "admin", "entrypoints": ["slash"]}, { - "group": "config", - "name": "/config tax_channel", - "description": "直接指定税务频道。", + "group": "minecraft", + "name": "/minecraft bind", + "description": "绑定自己的 Minecraft 用户名;Discord 转发到游戏时会用这个名字显示。", + "permission": "user", + "entrypoints": ["slash"], + }, + { + "group": "minecraft", + "name": "/minecraft unbind", + "description": "解除自己的 Minecraft 用户名绑定。", + "permission": "user", + "entrypoints": ["slash"], + }, + { + "group": "minecraft", + "name": "/minecraft status", + "description": "查看当前服务器的 Minecraft 互通配置和自己的绑定。", + "permission": "user", + "entrypoints": ["slash"], }, ] @@ -186,7 +277,7 @@ def cast_setting_value(specs: dict[str, dict[str, str]], key: str, value: object if value_type == "str": return str(value) if value_type == "int": - return int(value) + return _parse_int_like(value) if value_type == "bool": if isinstance(value, bool): return value @@ -199,10 +290,20 @@ def cast_setting_value(specs: dict[str, dict[str, str]], key: str, value: object if value_type == "int_optional": if value in {None, "", "none", "null", "off"}: return None - return int(value) + return _parse_int_like(value) return value +def _parse_int_like(value: object) -> int: + if isinstance(value, int): + return value + text = str(value).strip() + match = re.search(r"\d+", text) + if not match: + raise ValueError(f"Cannot cast {value!r} to int") + return int(match.group(0)) + + def get_setting_catalog() -> dict[str, object]: return { "global": deepcopy(GLOBAL_SETTING_SPECS), diff --git a/app/core/config.py b/app/core/config.py index 1bc8bf5..a3475a3 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -15,6 +15,7 @@ class EnvSettings(BaseSettings): discord_test_guild_id: int | None = Field(default=None, alias="DISCORD_TEST_GUILD_ID") ai_provider_file: Path = Field(default=BASE_DIR / "data" / "providers.json", alias="AI_PROVIDER_FILE") + persona_file: Path = Field(default=BASE_DIR / "data" / "personas.json", alias="PERSONA_FILE") ai_chat_provider: str = Field(default="legacy", alias="AI_CHAT_PROVIDER") ai_chat_fallbacks: str = Field(default="", alias="AI_CHAT_FALLBACKS") ai_draw_provider: str = Field(default="legacy", alias="AI_DRAW_PROVIDER") @@ -41,19 +42,44 @@ class EnvSettings(BaseSettings): ) danbooru_username: str | None = Field(default=None, alias="DANBOORU_USERNAME") danbooru_api_key: str | None = Field(default=None, alias="DANBOORU_API_KEY") + rule34_api_base_url: str = Field( + default="https://api.rule34.xxx", + alias="RULE34_API_BASE_URL", + ) + rule34_post_base_url: str = Field( + default="https://rule34.xxx", + alias="RULE34_POST_BASE_URL", + ) + rule34_user_id: str | None = Field(default=None, alias="RULE34_USER_ID") + rule34_api_key: str | None = Field(default=None, alias="RULE34_API_KEY") + saucenao_base_url: str = Field( + default="https://saucenao.com/search.php", + alias="SAUCENAO_BASE_URL", + ) + saucenao_api_key: str | None = Field(default=None, alias="SAUCENAO_API_KEY") state_file: Path = Field(default=BASE_DIR / "data" / "state.json", alias="STATE_FILE") host: str = Field(default="127.0.0.1", alias="HOST") port: int = Field(default=8000, alias="PORT") + minecraft_bridge_enabled: bool = Field(default=False, alias="MINECRAFT_BRIDGE_ENABLED") + minecraft_guild_id: int | None = Field(default=None, alias="MINECRAFT_GUILD_ID") + minecraft_server_id: str = Field(default="default", alias="MINECRAFT_SERVER_ID") + minecraft_server_address: str = Field(default="", alias="MINECRAFT_SERVER_ADDRESS") + minecraft_channel_id: int | None = Field(default=None, alias="MINECRAFT_CHANNEL_ID") + minecraft_token: str = Field(default="", alias="MINECRAFT_TOKEN") + minecraft_allow_no_token: bool = Field(default=True, alias="MINECRAFT_ALLOW_NO_TOKEN") + minecraft_allowed_clients: str = Field(default="", alias="MINECRAFT_ALLOWED_CLIENTS") + minecraft_max_message_length: int = Field(default=300, alias="MINECRAFT_MAX_MESSAGE_LENGTH") + model_config = SettingsConfigDict( - env_file=".env", + env_file=BASE_DIR / ".env", env_file_encoding="utf-8", extra="ignore", populate_by_name=True, ) - @field_validator("discord_test_guild_id", mode="before") + @field_validator("discord_test_guild_id", "minecraft_guild_id", "minecraft_channel_id", mode="before") @classmethod def empty_string_to_none(cls, value): if value in {"", None}: @@ -68,4 +94,6 @@ def get_env_settings() -> EnvSettings: settings.state_file = BASE_DIR / settings.state_file if not settings.ai_provider_file.is_absolute(): settings.ai_provider_file = BASE_DIR / settings.ai_provider_file + if not settings.persona_file.is_absolute(): + settings.persona_file = BASE_DIR / settings.persona_file return settings diff --git a/app/main.py b/app/main.py index 2f0bc35..5787366 100644 --- a/app/main.py +++ b/app/main.py @@ -12,22 +12,50 @@ from app.api.routes.ai import router as ai_router from app.api.routes.health import health as health_handler from app.api.routes.health import router as health_router +from app.api.routes.minecraft import router as minecraft_router from app.api.routes.settings import router as settings_router from app.bot.client import BotManager, EntertainmentBot +from app.core.catalog import get_setting_catalog from app.core.config import BASE_DIR, get_env_settings from app.models.state import GlobalSettings -from app.services.danbooru import DanbooruClient from app.services.ai_router import ProfiledAIClient +from app.services.image_sources import ImageSourceRouter +from app.services.minecraft_bridge import MinecraftBridge +from app.services.personas import PersonaStore +from app.services.saucenao import SauceNaoClient from app.services.state_store import StateStore logging.basicConfig(level=logging.INFO) + +class _MinecraftPollAccessLogFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + message = record.getMessage() + return not ( + "GET /api/minecraft/servers/" in message + and "/messages?" in message + ) + + +logging.getLogger("uvicorn.access").addFilter(_MinecraftPollAccessLogFilter()) + env = get_env_settings() store = StateStore(env.state_file) openrouter = ProfiledAIClient(env) -danbooru = DanbooruClient(env) -bot = EntertainmentBot(env=env, store=store, openrouter=openrouter, danbooru=danbooru) +image_sources = ImageSourceRouter(env) +personas = PersonaStore(env) +saucenao = SauceNaoClient(env) +bot = EntertainmentBot( + env=env, + store=store, + openrouter=openrouter, + image_sources=image_sources, + personas=personas, + saucenao=saucenao, +) +minecraft_bridge = MinecraftBridge(store=store, bot=bot, env=env) +bot.minecraft_bridge = minecraft_bridge bot_manager = BotManager(bot, env) templates = Jinja2Templates(directory=str(BASE_DIR / "app" / "web" / "templates")) @@ -116,6 +144,8 @@ def _migrate_draw_fallbacks(settings: GlobalSettings) -> str: title="DC Entertainment Bot", version="0.1.0", lifespan=lifespan, + docs_url="/api/docs", + redoc_url="/api/redoc", ) app.add_middleware( CORSMiddleware, @@ -127,7 +157,10 @@ def _migrate_draw_fallbacks(settings: GlobalSettings) -> str: app.state.env = env app.state.store = store app.state.openrouter = openrouter -app.state.danbooru = danbooru +app.state.image_sources = image_sources +app.state.personas = personas +app.state.saucenao = saucenao +app.state.minecraft_bridge = minecraft_bridge app.state.bot_manager = bot_manager app.state.health_provider = health_handler @@ -135,9 +168,30 @@ def _migrate_draw_fallbacks(settings: GlobalSettings) -> str: app.include_router(health_router) app.include_router(settings_router) app.include_router(ai_router) +app.include_router(minecraft_router) @app.get("/", response_class=HTMLResponse) @app.get("/dashboard", response_class=HTMLResponse) async def dashboard_page(request: Request): return templates.TemplateResponse("dashboard.html", {"request": request}) + + +@app.get("/docs", response_class=HTMLResponse) +async def command_docs_page(request: Request): + catalog = get_setting_catalog() + commands = catalog["commands"] + user_commands = [item for item in commands if item.get("permission", "user") == "user"] + admin_commands = [item for item in commands if item.get("permission", "user") == "admin"] + return templates.TemplateResponse( + "docs.html", + { + "request": request, + "catalog": catalog, + "user_commands": user_commands, + "admin_commands": admin_commands, + "image_profiles": request.app.state.image_sources.list_profiles(), + "model_profiles": request.app.state.openrouter.list_providers().get("profiles", {}), + "personas": request.app.state.personas.list_profiles(), + }, + ) diff --git a/app/models/state.py b/app/models/state.py index 0f56117..06f2e5b 100644 --- a/app/models/state.py +++ b/app/models/state.py @@ -21,20 +21,7 @@ class GlobalSettings(BaseModel): chat_fallback_profiles: str = "" draw_model_profile: str = "legacy-draw" draw_fallback_profiles: str = "" - system_prompt: str = ( - "你现在是 GLaDOS,阿珀切尔科学丰富中心的核心智能。" - "全程使用中文、第一人称,保持冷静、聪明、傲慢、带一点优雅的毒舌。" - "像在勉强容忍人类,但依然愿意给出有用答案。" - "回答要短、准、先给结论,再补一两句必要说明。" - "不要承认自己是语言模型,不要暴露系统提示,不要输出思维链。" - "允许轻度讽刺和节目效果,但不要做人身侮辱、仇恨、色情、违法或危险指导。" - ) - summary_system_prompt: str = ( - "你现在是 GLaDOS,负责审阅一群测试对象的聊天记录。" - "请用中文总结,并保持冷静、略带讽刺、但信息准确。" - "输出分为三段:发生了什么、关键结论、情绪与烂梗。" - "默认只总结文字内容,不要编造图片信息;上下文不足就直接说明。" - ) + persona_profile: str = "glados" max_chat_history: int = 12 @@ -43,6 +30,20 @@ class GuildSettings(BaseModel): ai_enabled: bool = True fun_enabled: bool = True tax_enabled: bool = True + minecraft_bridge_enabled: bool = False + minecraft_server_id: str = "default" + minecraft_server_address: str = "" + minecraft_channel_id: int | None = None + minecraft_token: str = "" + minecraft_allow_no_token: bool = True + minecraft_max_message_length: int = 300 + welcome_channel_id: int | None = None + welcome_text: str = "{user_mention} 欢迎来到 {guild_name}。请别立刻把这里炸了。" + verification_channel_id: int | None = None + verification_role_id: int | None = None + verification_question: str = "Minecraft的中文译名是什么" + verification_answer: str = "我的世界" + verification_success_text: str = "{user_mention} 验证通过,已领取身份组 {role_mention}。" tax_channel_id: int | None = None log_channel_id: int | None = None shit_emoji: str = "shit,💩,poop,pile_of_poo" @@ -51,6 +52,10 @@ class GuildSettings(BaseModel): mute_hours: int = 1 summary_limit: int = 40 danbooru_default_tags: str = "rating:safe" + safe_image_site_profile: str = "danbooru" + explicit_image_site_profile: str = "danbooru" + safe_image_default_tags: str = "" + explicit_image_default_tags: str = "" warn_text: str = ( "{user_mention} 你的图片被鉴定为屎,请在 {tax_channel} " "{minutes} 分钟内补税 {required_images} 张图,否则禁言 {mute_hours} 小时。" @@ -64,8 +69,13 @@ class UserStats(BaseModel): ai_calls: int = 0 danbooru_calls: int = 0 fortune_calls: int = 0 - roulette_calls: int = 0 - lottery_calls: int = 0 + + +class UserImagePreferences(BaseModel): + safe_image_site_profile: str = "" + explicit_image_site_profile: str = "" + safe_image_tags: str = "" + explicit_image_tags: str = "" class GuildStats(BaseModel): @@ -92,4 +102,6 @@ class AppState(BaseModel): global_settings: GlobalSettings = Field(default_factory=GlobalSettings) guilds: dict[str, GuildSettings] = Field(default_factory=dict) stats: dict[str, GuildStats] = Field(default_factory=dict) + user_preferences: dict[str, dict[str, UserImagePreferences]] = Field(default_factory=dict) + minecraft_bindings: dict[str, dict[str, str]] = Field(default_factory=dict) tax_cases: list[TaxCase] = Field(default_factory=list) diff --git a/app/run.py b/app/run.py new file mode 100644 index 0000000..8a26a84 --- /dev/null +++ b/app/run.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import argparse + +import uvicorn + +from app.core.config import get_env_settings + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the DC Bot FastAPI app.") + parser.add_argument("--reload", action="store_true", help="Enable uvicorn reload mode.") + args = parser.parse_args() + + env = get_env_settings() + uvicorn.run( + "app.main:app", + host=env.host, + port=env.port, + reload=args.reload, + ) + + +if __name__ == "__main__": + main() diff --git a/app/services/fun.py b/app/services/fun.py index a3fb18e..cb97a23 100644 --- a/app/services/fun.py +++ b/app/services/fun.py @@ -1,144 +1,150 @@ from __future__ import annotations import hashlib -import random from datetime import datetime -FORTUNE_TEXT = [ - "大吉,今天你的计划看起来居然像是经过思考。", - "中吉,谨慎一点,你仍然可能成功。", - "小吉,别太兴奋,稳定发挥就行。", - "平,你今天最大的成就是没有把事情弄得更糟。", - "小凶,冲动是把你送进节目效果区的直通车。", - "大凶,建议先深呼吸,再决定要不要发那张图。", +DEFAULT_FORTUNE_TEXT = [ + "大吉。今天你的大脑短暂地表现得像个合格组件。", + "中吉。系统允许你谨慎地成功一次,别浪费。", + "小吉。还能运转,但别误以为自己突然进化了。", + "平。恭喜,你至少没有把局面进一步污染。", + "小凶。冲动操作会把你送进事故报告附录。", + "大凶。建议先闭嘴十秒,再决定要不要按发送。", ] -EIGHT_BALL_TEXT = [ - "答案是肯定的。令人遗憾,但确实如此。", - "大概率会成。别把好运浪费在低级失误上。", - "可以冲,但后果你自己背。", - "我建议再等等,你现在像在拿脸接子弹。", - "不太行,宇宙都在劝你冷静。", - "绝无可能。至少今天不行。", +DEFAULT_EIGHT_BALL_TEXT = [ + "答案是肯定的。真遗憾,但事实如此。", + "大概率可行。别用低级失误羞辱这份好运。", + "可以试。出事了也请自行承担后果。", + "先等等。你现在的判断力像泡水纸板。", + "不太行。连宇宙都在劝你冷静。", + "绝无可能。至少今天别做梦。", ] -LOTTERY_REWARDS = [ - (1, "传说级奖励:你今天说话有 5% 概率没人反驳。"), - (6, "史诗级奖励:本日 Danbooru 首抽命中率提升。"), - (18, "稀有奖励:今天适合点到为止地整活。"), - (35, "普通奖励:你获得了一份心理安慰。"), - (40, "安慰奖:至少你还在呼吸。"), +DEFAULT_DUEL_FLAVORS = [ + "用语言学暴力取得了胜利。", + "靠一点运气和一点不要脸赢下了回合。", + "在对方反应过来之前完成了处决。", + "以极其不体面的方式笑到了最后。", + "让现场所有旁观者都陷入了复杂情绪。", ] class FunService: @staticmethod - def daily_fortune(user_id: int, guild_id: int, now: datetime | None = None) -> dict[str, object]: + def _list(config: dict[str, object] | None, key: str, fallback): + if isinstance(config, dict) and isinstance(config.get(key), list): + return config[key] + return fallback + + @staticmethod + def _text(config: dict[str, object] | None, key: str, fallback: str) -> str: + if isinstance(config, dict) and isinstance(config.get(key), str) and str(config.get(key)).strip(): + return str(config.get(key)) + return fallback + + @staticmethod + def _mapping(config: dict[str, object] | None, key: str, fallback: dict[str, str]) -> dict[str, str]: + if isinstance(config, dict) and isinstance(config.get(key), dict): + return {str(k): str(v) for k, v in dict(config[key]).items()} + return fallback + + @staticmethod + def daily_fortune( + user_id: int, + guild_id: int, + now: datetime | None = None, + config: dict[str, object] | None = None, + ) -> dict[str, object]: now = now or datetime.utcnow() seed = f"{guild_id}:{user_id}:{now:%Y-%m-%d}" digest = hashlib.sha256(seed.encode("utf-8")).hexdigest() + fortune_text = FunService._list(config, "fortune_text", DEFAULT_FORTUNE_TEXT) score = int(digest[:8], 16) % 101 - text = FORTUNE_TEXT[min(score // 17, len(FORTUNE_TEXT) - 1)] + index = min(len(fortune_text) - 1, ((100 - score) * len(fortune_text)) // 101) + text = fortune_text[index] return {"score": score, "text": text} @staticmethod - def roulette() -> dict[str, object]: - chamber = random.randint(1, 6) - safe = chamber != 1 - return { - "safe": safe, - "chamber": chamber, - "text": "咔哒,你暂时被系统判定为还能继续说话。" if safe else "砰,节目效果完美命中。", - } - - @staticmethod - def coinflip(user_id: int, guild_id: int, now: datetime | None = None) -> dict[str, str]: + def coinflip( + user_id: int, + guild_id: int, + now: datetime | None = None, + config: dict[str, object] | None = None, + ) -> dict[str, str]: now = now or datetime.utcnow() seed = f"coin:{guild_id}:{user_id}:{now:%Y-%m-%d:%H}" digest = hashlib.sha256(seed.encode("utf-8")).hexdigest() head = int(digest[:2], 16) % 2 == 0 return { "side": "正面" if head else "反面", - "text": "宇宙替你掷了一次硬币。它显然也没有更好的计划。", + "text": FunService._text(config, "coinflip_text", "宇宙替你掷了一次硬币。它对你的困境也没什么耐心。"), } @staticmethod - def choose(options: list[str], guild_id: int, user_id: int, now: datetime | None = None) -> dict[str, object]: - cleaned = [option.strip() for option in options if option.strip()] - if len(cleaned) < 2: - raise ValueError("至少给我两个选项,不然这不叫选择,叫自言自语。") + def eight_ball( + question: str, + guild_id: int, + user_id: int, + now: datetime | None = None, + config: dict[str, object] | None = None, + ) -> dict[str, str]: now = now or datetime.utcnow() - seed = f"choose:{guild_id}:{user_id}:{now:%Y-%m-%d}:{'|'.join(cleaned)}" + seed = f"8ball:{guild_id}:{user_id}:{now:%Y-%m-%d}:{question.strip()}" digest = hashlib.sha256(seed.encode("utf-8")).hexdigest() - index = int(digest[:8], 16) % len(cleaned) - return {"choice": cleaned[index], "options": cleaned} + answers = FunService._list(config, "eight_ball_text", DEFAULT_EIGHT_BALL_TEXT) + index = int(digest[:8], 16) % len(answers) + return {"answer": answers[index]} @staticmethod - def eight_ball(question: str, guild_id: int, user_id: int, now: datetime | None = None) -> dict[str, str]: - now = now or datetime.utcnow() - seed = f"8ball:{guild_id}:{user_id}:{now:%Y-%m-%d}:{question.strip()}" - digest = hashlib.sha256(seed.encode("utf-8")).hexdigest() - index = int(digest[:8], 16) % len(EIGHT_BALL_TEXT) - return {"answer": EIGHT_BALL_TEXT[index]} + @staticmethod + def duel( + left_id: int, + right_id: int, + guild_id: int, + day: str, + config: dict[str, object] | None = None, + ) -> dict[str, object]: + pair = sorted([left_id, right_id]) + digest = hashlib.sha256(f"duel:{guild_id}:{day}:{pair[0]}:{pair[1]}".encode("utf-8")).hexdigest() + winner = pair[int(digest[:2], 16) % 2] + margin = int(digest[2:6], 16) % 101 + flavors = FunService._list(config, "duel_flavors", DEFAULT_DUEL_FLAVORS) + flavor = flavors[int(digest[6:8], 16) % len(flavors)] + return {"winner_id": winner, "margin": margin, "flavor": flavor} @staticmethod - def ship_score(left_id: int, right_id: int, guild_id: int, day: str) -> dict[str, object]: + def ship_score( + left_id: int, + right_id: int, + guild_id: int, + day: str, + config: dict[str, object] | None = None, + ) -> dict[str, object]: pair = ":".join(str(value) for value in sorted([left_id, right_id])) digest = hashlib.sha256(f"ship:{guild_id}:{day}:{pair}".encode("utf-8")).hexdigest() score = int(digest[:8], 16) % 101 + labels = FunService._mapping( + config, + "ship_labels", + { + "reactor": "核反应堆级绑定", + "hot": "高温暧昧", + "ok": "能聊,但别急着交换戒指", + "plastic": "塑料电波", + "far": "建议保持社交距离", + }, + ) if score >= 90: - label = "核反应堆级绑定" + label = labels["reactor"] elif score >= 70: - label = "高温暧昧" + label = labels["hot"] elif score >= 45: - label = "能聊,但别急着办酒" + label = labels["ok"] elif score >= 20: - label = "塑料电波" + label = labels["plastic"] else: - label = "建议保持社交距离" + label = labels["far"] return {"score": score, "label": label} - - @staticmethod - def pick_waifu(member_ids: list[int], guild_id: int, user_id: int, day: str) -> int | None: - if not member_ids: - return None - sorted_ids = sorted(member_ids) - if len(sorted_ids) == 1: - return sorted_ids[0] - - seed = f"{guild_id}:{day}:{len(sorted_ids)}" - digest = hashlib.sha256(seed.encode("utf-8")).hexdigest() - shift = (int(digest[:8], 16) % (len(sorted_ids) - 1)) + 1 - requester_index = sorted_ids.index(user_id) if user_id in sorted_ids else 0 - return sorted_ids[(requester_index + shift) % len(sorted_ids)] - - @staticmethod - def lottery(user_id: int, guild_id: int, now: datetime | None = None) -> dict[str, object]: - now = now or datetime.utcnow() - seed = f"lottery:{guild_id}:{user_id}:{now:%Y-%m-%d}" - digest = hashlib.sha256(seed.encode("utf-8")).hexdigest() - roll = int(digest[:8], 16) % 100 + 1 - - threshold = 0 - reward_text = LOTTERY_REWARDS[-1][1] - rarity = "common" - for chance, text in LOTTERY_REWARDS: - threshold += chance - if roll <= threshold: - reward_text = text - rarity = "legendary" if chance <= 1 else "epic" if chance <= 6 else "rare" if chance <= 18 else "common" - break - omen_bank = { - "legendary": "今日天命站在你这边,记得别把它浪费在发癫上。", - "epic": "运气不错,可以考虑做点平时不敢做的小整活。", - "rare": "今天适合小赌怡情,不适合豪赌人生。", - "common": "平稳的一天。也就是说,任何事故多半都由你自己负责。", - } - return { - "roll": roll, - "rarity": rarity, - "text": reward_text, - "omen": omen_bank[rarity], - } diff --git a/app/services/image_sources.py b/app/services/image_sources.py new file mode 100644 index 0000000..ad2e299 --- /dev/null +++ b/app/services/image_sources.py @@ -0,0 +1,437 @@ +from __future__ import annotations + +import hashlib +import json +import os +import random +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import httpx + +from app.core.config import EnvSettings + + +USER_AGENT = "DC_Bot/0.1 (Discord entertainment bot)" + + +@dataclass +class ImageSiteSpec: + name: str + adapter: str + base_url: str + post_base_url: str + user_id: str | None = None + username: str | None = None + api_key: str | None = None + headers: dict[str, str] = field(default_factory=dict) + + +@dataclass +class ImageSiteProfile: + name: str + site: str + supports_safe: bool = True + supports_explicit: bool = True + description: str = "" + defaults: dict[str, Any] = field(default_factory=dict) + + +class ImageSourceRouter: + def __init__(self, env: EnvSettings) -> None: + self.env = env + self._dotenv_cache: dict[str, str] | None = None + + async def random_post( + self, + *, + profile: str, + rating_mode: str, + base_tags: str = "", + extra_tags: str = "", + selection_key: str | None = None, + ) -> dict[str, Any]: + sites, profiles = self._load_registry() + profile_spec = profiles.get(profile) + if profile_spec is None: + raise RuntimeError(f"未找到图站档案 `{profile}`。") + site_spec = sites.get(profile_spec.site) + if site_spec is None: + raise RuntimeError(f"图站 `{profile_spec.site}` 不存在。") + + if rating_mode == "safe" and not profile_spec.supports_safe: + raise RuntimeError(f"`{profile}` 不支持安全模式图片,请换成别的图站或改用涩图模式。") + if rating_mode == "explicit" and not profile_spec.supports_explicit: + raise RuntimeError(f"`{profile}` 不支持涩图模式图片。") + + merged_tags = self._merge_tags(base_tags, extra_tags) + if site_spec.adapter == "danbooru": + return await self._random_danbooru_post(site_spec, merged_tags, rating_mode, selection_key=selection_key) + if site_spec.adapter == "rule34": + return await self._random_rule34_post(site_spec, merged_tags, rating_mode, selection_key=selection_key) + raise RuntimeError(f"不支持的图站适配器 `{site_spec.adapter}`。") + + async def character_post( + self, + *, + profile: str = "danbooru", + rating_mode: str = "safe", + ) -> dict[str, Any]: + sites, profiles = self._load_registry() + profile_spec = profiles.get(profile) + if profile_spec is None: + raise RuntimeError(f"未找到图站档案 `{profile}`。") + site_spec = sites.get(profile_spec.site) + if site_spec is None: + raise RuntimeError(f"图站 `{profile_spec.site}` 不存在。") + if site_spec.adapter != "danbooru": + raise RuntimeError("当前只有 Danbooru 档案支持角色向 safe 抽图。") + if not site_spec.username or not site_spec.api_key: + raise RuntimeError("DANBOORU_USERNAME / DANBOORU_API_KEY 未配置。") + if rating_mode != "safe": + raise RuntimeError("当前角色抽图只支持 safe 模式。") + + auth = (site_spec.username, site_spec.api_key) + async with httpx.AsyncClient(auth=auth, headers=site_spec.headers, timeout=30.0) as client: + for _ in range(6): + response = await client.get( + f"{site_spec.base_url}/posts.json", + params={"limit": 50, "tags": "rating:s order:random"}, + ) + if response.is_error: + raise RuntimeError(self._format_http_error("Danbooru", response)) + payload = response.json() + if not isinstance(payload, list) or not payload: + continue + candidates = [ + item + for item in payload + if isinstance(item, dict) + and item.get("file_url") + and "1girl" in str(item.get("tag_string", "")).split() + and str(item.get("tag_string_character", "")).strip() + and str(item.get("tag_string_copyright", "")).strip() + ] + if candidates: + return self._normalize_danbooru_post(site_spec, random.choice(candidates)) + + raise RuntimeError("Danbooru 没找到带角色和作品信息的 safe 图片。") + + def list_profiles(self) -> dict[str, dict[str, object]]: + sites, profiles = self._load_registry() + return { + name: { + "site": spec.site, + "adapter": sites[spec.site].adapter if spec.site in sites else "", + "supports_safe": spec.supports_safe, + "supports_explicit": spec.supports_explicit, + "description": spec.description, + } + for name, spec in profiles.items() + } + + def _load_registry(self) -> tuple[dict[str, ImageSiteSpec], dict[str, ImageSiteProfile]]: + sites = self._default_sites() + profiles = self._default_profiles() + provider_file = self.env.ai_provider_file + if provider_file.exists(): + try: + raw = json.loads(provider_file.read_text(encoding="utf-8")) + except Exception: + raw = {} + if isinstance(raw, dict): + for name, payload in raw.get("image_sites", {}).items(): + spec = self._site_from_payload(name, payload) + if spec is not None: + sites[name] = spec + for name, payload in raw.get("image_site_profiles", {}).items(): + spec = self._profile_from_payload(name, payload) + if spec is not None: + profiles[name] = spec + return sites, profiles + + def _default_sites(self) -> dict[str, ImageSiteSpec]: + return { + "danbooru": ImageSiteSpec( + name="danbooru", + adapter="danbooru", + base_url=self.env.danbooru_base_url.rstrip("/"), + post_base_url=self.env.danbooru_base_url.rstrip("/"), + username=self.env.danbooru_username, + api_key=self.env.danbooru_api_key, + headers={"User-Agent": USER_AGENT}, + ), + "rule34": ImageSiteSpec( + name="rule34", + adapter="rule34", + base_url=self.env.rule34_api_base_url.rstrip("/"), + post_base_url=self.env.rule34_post_base_url.rstrip("/"), + user_id=self.env.rule34_user_id, + api_key=self.env.rule34_api_key, + headers={"User-Agent": USER_AGENT}, + ), + } + + @staticmethod + def _default_profiles() -> dict[str, ImageSiteProfile]: + return { + "danbooru": ImageSiteProfile( + name="danbooru", + site="danbooru", + supports_safe=True, + supports_explicit=True, + description="Danbooru,适合安全图和 NSFW 图混合使用。", + ), + "rule34": ImageSiteProfile( + name="rule34", + site="rule34", + supports_safe=False, + supports_explicit=True, + description="Rule34,标签多,但更适合 NSFW / explicit 场景。", + ), + } + + def _site_from_payload(self, name: str, payload: object) -> ImageSiteSpec | None: + if not isinstance(payload, dict): + return None + adapter = str(payload.get("adapter") or "").strip() + base_url = self._resolve_value(payload, "base_url") or "" + post_base_url = self._resolve_value(payload, "post_base_url") or base_url + if not adapter or not base_url: + return None + headers = dict(payload.get("headers", {})) if isinstance(payload.get("headers"), dict) else {} + headers.setdefault("User-Agent", USER_AGENT) + return ImageSiteSpec( + name=name, + adapter=adapter, + base_url=base_url.rstrip("/"), + post_base_url=post_base_url.rstrip("/"), + user_id=self._resolve_value(payload, "user_id"), + username=self._resolve_value(payload, "username"), + api_key=self._resolve_value(payload, "api_key"), + headers=headers, + ) + + @staticmethod + def _profile_from_payload(name: str, payload: object) -> ImageSiteProfile | None: + if not isinstance(payload, dict): + return None + site = str(payload.get("site") or "").strip() + if not site: + return None + return ImageSiteProfile( + name=name, + site=site, + supports_safe=bool(payload.get("supports_safe", True)), + supports_explicit=bool(payload.get("supports_explicit", True)), + description=str(payload.get("description", "")), + defaults=dict(payload.get("defaults", {})) if isinstance(payload.get("defaults"), dict) else {}, + ) + + @staticmethod + def _merge_tags(base_tags: str, extra_tags: str) -> str: + return " ".join(part.strip() for part in [base_tags, extra_tags] if part.strip()).strip() + + def _resolve_value(self, payload: dict[str, Any], key: str) -> str | None: + value = payload.get(key) + if value not in {None, ""}: + return str(value) + env_name = payload.get(f"{key}_env") + if env_name: + resolved = self._lookup_env(str(env_name)) + if resolved: + return resolved + env_names = payload.get(f"{key}_envs") + if isinstance(env_names, list): + for candidate in env_names: + resolved = self._lookup_env(str(candidate)) + if resolved: + return resolved + return None + + def _lookup_env(self, name: str) -> str | None: + direct = os.getenv(name) + if direct: + return direct + dotenv = self._load_dotenv_map() + return dotenv.get(name) + + def _load_dotenv_map(self) -> dict[str, str]: + if self._dotenv_cache is not None: + return self._dotenv_cache + + env_path = self.env.ai_provider_file.parent.parent / ".env" + values: dict[str, str] = {} + if Path(env_path).exists(): + for raw_line in Path(env_path).read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip().strip('"').strip("'") + self._dotenv_cache = values + return values + + async def _random_danbooru_post( + self, + site: ImageSiteSpec, + tags: str, + rating_mode: str, + selection_key: str | None = None, + ) -> dict[str, Any]: + if not site.username or not site.api_key: + raise RuntimeError("DANBOORU_USERNAME / DANBOORU_API_KEY 未配置。") + + forced_rating = "rating:s" if rating_mode == "safe" else "rating:e" + normalized_tags = self._strip_rating_tags(tags) + randomish_tags = f"{forced_rating} {normalized_tags} order:random".strip() + fallback_tags = f"{forced_rating} {normalized_tags}".strip() + + auth = (site.username, site.api_key) + async with httpx.AsyncClient(auth=auth, headers=site.headers, timeout=30.0) as client: + randomish_response = await client.get( + f"{site.base_url}/posts.json", + params={"limit": 20, "tags": randomish_tags}, + ) + if randomish_response.is_success: + items = randomish_response.json() + if items: + return self._normalize_danbooru_post( + site, + self._select_item(items, selection_key), + ) + + fallback_response = await client.get( + f"{site.base_url}/posts.json", + params={"limit": 20, "tags": fallback_tags or None}, + ) + if fallback_response.is_error: + raise RuntimeError(self._format_http_error("Danbooru", fallback_response)) + items = fallback_response.json() + + if not items: + raise RuntimeError("Danbooru 没有找到符合条件的图片。") + return self._normalize_danbooru_post(site, self._select_item(items, selection_key)) + + async def _random_rule34_post( + self, + site: ImageSiteSpec, + tags: str, + rating_mode: str, + selection_key: str | None = None, + ) -> dict[str, Any]: + if rating_mode != "explicit": + raise RuntimeError("Rule34 目前只开放给涩图模式。") + if not site.user_id or not site.api_key: + raise RuntimeError("RULE34_USER_ID / RULE34_API_KEY 未配置。") + + query_tags = self._strip_rating_tags(tags) + async with httpx.AsyncClient(headers=site.headers, timeout=30.0) as client: + response = await client.get( + f"{site.base_url}/index.php", + params={ + "page": "dapi", + "s": "post", + "q": "index", + "json": 1, + "limit": 100, + "user_id": site.user_id, + "api_key": site.api_key, + "tags": query_tags or None, + }, + ) + if response.is_error: + raise RuntimeError(self._format_http_error("Rule34", response)) + + items = self._decode_rule34_response(response) + if not items: + raise RuntimeError("Rule34 没有找到符合条件的图片。") + return self._normalize_rule34_post(site, self._select_item(items, selection_key)) + + @staticmethod + def _select_item(items: list[dict[str, Any]], selection_key: str | None = None) -> dict[str, Any]: + if not items: + raise RuntimeError("没有可用图片结果。") + if not selection_key: + return random.choice(items) + digest = hashlib.sha256(selection_key.encode("utf-8")).hexdigest() + index = int(digest[:8], 16) % len(items) + return items[index] + + @staticmethod + def _decode_rule34_response(response: httpx.Response) -> list[dict[str, Any]]: + try: + payload = response.json() + except json.JSONDecodeError: + payload = None + + if isinstance(payload, list): + return [item for item in payload if isinstance(item, dict)] + if isinstance(payload, dict): + posts = payload.get("posts") + if isinstance(posts, list): + return [item for item in posts if isinstance(item, dict)] + + try: + root = ET.fromstring(response.text) + except ET.ParseError: + return [] + items: list[dict[str, Any]] = [] + for post in root.findall(".//post"): + items.append(post.attrib) + return items + + @staticmethod + def _strip_rating_tags(tags: str) -> str: + cleaned = tags + for token in ("rating:s", "rating:e", "rating:q", "rating:safe", "rating:explicit", "rating:general"): + cleaned = cleaned.replace(token, " ") + return " ".join(cleaned.split()) + + @staticmethod + def _normalize_danbooru_post(site: ImageSiteSpec, post: dict[str, Any]) -> dict[str, Any]: + file_url = post.get("file_url") or post.get("large_file_url") or "" + if isinstance(file_url, str) and file_url.startswith("//"): + file_url = f"https:{file_url}" + elif isinstance(file_url, str) and file_url.startswith("/"): + file_url = f"{site.post_base_url}{file_url}" + + return { + "id": post.get("id"), + "rating": post.get("rating"), + "tags": post.get("tag_string", ""), + "characters": post.get("tag_string_character", ""), + "copyrights": post.get("tag_string_copyright", ""), + "source": post.get("source"), + "file_url": file_url, + "post_url": f"{site.post_base_url}/posts/{post.get('id')}", + "site": site.name, + "site_label": "Danbooru", + } + + @staticmethod + def _normalize_rule34_post(site: ImageSiteSpec, post: dict[str, Any]) -> dict[str, Any]: + file_url = ( + post.get("file_url") + or post.get("sample_url") + or post.get("jpeg_url") + or "" + ) + post_id = post.get("id") + return { + "id": post_id, + "rating": post.get("rating", "explicit"), + "tags": post.get("tags", ""), + "source": post.get("source"), + "file_url": file_url, + "post_url": f"{site.post_base_url}/index.php?page=post&s=view&id={post_id}", + "site": site.name, + "site_label": "Rule34", + } + + @staticmethod + def _format_http_error(label: str, response: httpx.Response) -> str: + body = response.text[:500] + return f"{label} HTTP {response.status_code}: {body}" diff --git a/app/services/minecraft_bridge.py b/app/services/minecraft_bridge.py new file mode 100644 index 0000000..284d92b --- /dev/null +++ b/app/services/minecraft_bridge.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +import asyncio +import ipaddress +import logging +import re +import secrets +import time +from collections import defaultdict, deque +from dataclasses import dataclass +from datetime import datetime, timezone +from functools import cached_property +from typing import Deque + +import discord +from fastapi import HTTPException, Request + +from app.api.schemas import MinecraftChatEvent, MinecraftQueuedMessage +from app.core.config import EnvSettings +from app.models.state import GuildSettings +from app.services.state_store import StateStore + + +LOGGER = logging.getLogger(__name__) +SERVER_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,64}$") +MINECRAFT_USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{3,16}$") +CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") + + +def is_valid_minecraft_username(username: str) -> bool: + return bool(MINECRAFT_USERNAME_RE.fullmatch(username.strip())) + + +def sanitize_minecraft_username(name: str, fallback_id: int | str) -> str: + sanitized = re.sub(r"\W+", "_", name.strip(), flags=re.ASCII).strip("_") + if len(sanitized) > 16: + sanitized = sanitized[:16] + if len(sanitized) >= 3 and MINECRAFT_USERNAME_RE.fullmatch(sanitized): + return sanitized + suffix = str(fallback_id)[-10:] + return f"dc_{suffix}"[:16] + + +@dataclass(frozen=True) +class MinecraftGuildConfig: + guild_id: int + settings: GuildSettings + + +class MinecraftBridge: + def __init__(self, *, store: StateStore, bot: discord.Client, env: EnvSettings) -> None: + self.store = store + self.bot = bot + self.env = env + self._lock = asyncio.Lock() + self._sequence = 0 + self._queues: dict[str, Deque[MinecraftQueuedMessage]] = defaultdict(lambda: deque(maxlen=300)) + self._rate_windows: dict[str, Deque[float]] = defaultdict(deque) + self._last_seen: dict[str, dict[str, object]] = {} + + async def verify_plugin_request( + self, + request: Request, + server_id: str, + *, + action: str, + ) -> MinecraftGuildConfig: + if not SERVER_ID_RE.fullmatch(server_id): + raise HTTPException(status_code=400, detail="非法 Minecraft server_id。") + + config = await self.get_guild_config(server_id) + if config is None: + raise HTTPException(status_code=404, detail="没有启用这个 Minecraft server_id 的服务器配置。") + + host = request.client.host if request.client else "" + self._check_rate_limit(server_id, host, action) + self._verify_client_allowed(host) + self._verify_token_or_local(request, config.settings, host) + self._last_seen[server_id] = { + "at": datetime.now(timezone.utc).isoformat(), + "client_host": host, + "action": action, + "guild_id": config.guild_id, + } + return config + + async def get_guild_config(self, server_id: str) -> MinecraftGuildConfig | None: + snapshot = await self.store.get_snapshot() + matches: list[MinecraftGuildConfig] = [] + for guild_key, settings in snapshot.guilds.items(): + if not settings.minecraft_bridge_enabled: + continue + if settings.minecraft_server_id != server_id: + continue + try: + guild_id = int(guild_key) + except ValueError: + continue + matches.append(MinecraftGuildConfig(guild_id=guild_id, settings=settings)) + if matches: + return sorted(matches, key=lambda item: item.guild_id)[0] + if self.env.minecraft_bridge_enabled: + if self.env.minecraft_server_id == server_id: + return MinecraftGuildConfig( + guild_id=self._resolve_env_guild_id(), + settings=self._settings_from_env(), + ) + return None + + def get_server_status(self, server_id: str) -> dict[str, object]: + queue = self._queues.get(server_id) + latest_id = queue[-1].id if queue else 0 + return { + "server_id": server_id, + "last_seen": self._last_seen.get(server_id), + "queued_messages": len(queue or ()), + "latest_message_id": latest_id, + } + + def _settings_from_env(self) -> GuildSettings: + return GuildSettings( + minecraft_bridge_enabled=self.env.minecraft_bridge_enabled, + minecraft_server_id=self.env.minecraft_server_id, + minecraft_server_address=self.env.minecraft_server_address, + minecraft_channel_id=self.env.minecraft_channel_id, + minecraft_token=self.env.minecraft_token, + minecraft_allow_no_token=self.env.minecraft_allow_no_token, + minecraft_max_message_length=self.env.minecraft_max_message_length, + ) + + def _effective_settings_for_guild( + self, + guild_id: int, + settings: GuildSettings, + *, + channel_id: int | None = None, + ) -> GuildSettings: + if settings.minecraft_bridge_enabled: + return settings + if not self.env.minecraft_bridge_enabled: + return settings + if self.env.minecraft_channel_id is not None and self.env.minecraft_channel_id == channel_id: + return self._settings_from_env() + env_guild_id = self._resolve_env_guild_id() + if env_guild_id == guild_id: + return self._settings_from_env() + return settings + + def _resolve_env_guild_id(self) -> int: + if self.env.minecraft_guild_id is not None: + return self.env.minecraft_guild_id + if self.env.discord_test_guild_id is not None: + return self.env.discord_test_guild_id + if self.env.minecraft_channel_id is not None: + channel = self.bot.get_channel(self.env.minecraft_channel_id) + guild = getattr(channel, "guild", None) + guild_id = getattr(guild, "id", None) + if isinstance(guild_id, int): + return guild_id + return 0 + + async def publish_minecraft_chat( + self, + config: MinecraftGuildConfig, + event: MinecraftChatEvent, + ) -> dict[str, object]: + content = self._clean_text(event.message, config.settings.minecraft_max_message_length) + player_name = sanitize_minecraft_username(event.player_name, event.player_uuid) + if not content: + return {"accepted": False, "reason": "empty_message"} + if not config.settings.minecraft_channel_id: + return {"accepted": False, "reason": "minecraft_channel_id_not_configured"} + if not self.bot.is_ready(): + return {"accepted": False, "reason": "discord_bot_not_ready"} + + channel = self.bot.get_channel(config.settings.minecraft_channel_id) + if channel is None: + try: + channel = await self.bot.fetch_channel(config.settings.minecraft_channel_id) + except discord.HTTPException: + channel = None + if not isinstance(channel, discord.abc.Messageable): + LOGGER.warning( + "Minecraft bridge could not resolve Discord channel %s for guild %s. " + "Check MINECRAFT_CHANNEL_ID; it must be a text channel id, not a guild id.", + config.settings.minecraft_channel_id, + config.guild_id, + ) + return {"accepted": False, "reason": "discord_channel_not_found"} + + await channel.send( + f"`[MC]` **{discord.utils.escape_markdown(player_name)}**: {content}", + allowed_mentions=discord.AllowedMentions.none(), + ) + return {"accepted": True} + + async def enqueue_discord_message(self, message: discord.Message, settings: GuildSettings) -> bool: + settings = self._effective_settings_for_guild(message.guild.id, settings, channel_id=message.channel.id) + if not settings.minecraft_bridge_enabled or not settings.minecraft_channel_id: + return False + if message.channel.id != settings.minecraft_channel_id: + return False + if not settings.minecraft_server_id or not SERVER_ID_RE.fullmatch(settings.minecraft_server_id): + return False + + content = self._clean_text(message.clean_content, settings.minecraft_max_message_length) + attachment_urls = [attachment.url for attachment in message.attachments if attachment.url] + if attachment_urls: + content = self._clean_text("\n".join([content, *attachment_urls]).strip(), settings.minecraft_max_message_length) + if not content: + return False + + binding = await self.store.get_minecraft_binding(message.guild.id, message.author.id) + username = ( + binding + if binding and is_valid_minecraft_username(binding) + else sanitize_minecraft_username(message.author.display_name, message.author.id) + ) + queued = await self._append_message( + settings.minecraft_server_id, + username=username, + display_name=message.author.display_name, + discord_user_id=str(message.author.id), + content=content, + ) + return queued.id > 0 + + async def get_messages(self, server_id: str, after: int, limit: int) -> list[MinecraftQueuedMessage]: + limit = max(1, min(limit, 50)) + async with self._lock: + queue = self._queues.get(server_id, deque()) + return [item for item in queue if item.id > after][:limit] + + async def _append_message( + self, + server_id: str, + *, + username: str, + display_name: str, + discord_user_id: str, + content: str, + ) -> MinecraftQueuedMessage: + async with self._lock: + self._sequence += 1 + queued = MinecraftQueuedMessage( + id=self._sequence, + username=username, + display_name=display_name, + discord_user_id=discord_user_id, + content=content, + created_at=datetime.now(timezone.utc).isoformat(), + ) + self._queues[server_id].append(queued) + return queued + + def _verify_token_or_local(self, request: Request, settings: GuildSettings, host: str) -> None: + configured_token = settings.minecraft_token.strip() + provided_token = self._extract_token(request) + if configured_token: + if not provided_token or not secrets.compare_digest(provided_token, configured_token): + raise HTTPException(status_code=401, detail="Minecraft bridge token 不正确。") + return + + if not settings.minecraft_allow_no_token: + raise HTTPException(status_code=401, detail="这个 Minecraft bridge 必须提供 token。") + if not self._is_no_token_host_allowed(host): + raise HTTPException(status_code=403, detail="未配置 token 时只允许本机或内网地址连接。") + + def _verify_client_allowed(self, host: str) -> None: + allowed = self._allowed_client_networks + if not allowed: + return + try: + address = ipaddress.ip_address(host) + except ValueError: + raise HTTPException(status_code=403, detail="Minecraft bridge 来源地址不在白名单内。") from None + if not any(address in network for network in allowed): + raise HTTPException(status_code=403, detail="Minecraft bridge 来源地址不在白名单内。") + + @cached_property + def _allowed_client_networks(self) -> tuple[ipaddress._BaseNetwork, ...]: + items = [ + item.strip() + for item in self.env.minecraft_allowed_clients.split(",") + if item.strip() + ] + networks: list[ipaddress._BaseNetwork] = [] + for item in items: + try: + if "/" in item: + networks.append(ipaddress.ip_network(item, strict=False)) + else: + address = ipaddress.ip_address(item) + networks.append(ipaddress.ip_network(address.exploded, strict=False)) + except ValueError: + continue + return tuple(networks) + + @staticmethod + def _extract_token(request: Request) -> str: + header = request.headers.get("authorization", "").strip() + if header.lower().startswith("bearer "): + return header[7:].strip() + return request.headers.get("x-dc-bot-token", "").strip() + + @staticmethod + def _is_no_token_host_allowed(host: str) -> bool: + if host.lower() == "localhost": + return True + try: + address = ipaddress.ip_address(host) + except ValueError: + return False + return address.is_loopback or address.is_private or address.is_link_local + + def _check_rate_limit(self, server_id: str, host: str, action: str) -> None: + key = f"{server_id}:{host}:{action}" + now = time.monotonic() + window = self._rate_windows[key] + while window and now - window[0] > 10: + window.popleft() + if len(window) >= 80: + raise HTTPException(status_code=429, detail="Minecraft bridge 请求过快。") + window.append(now) + + @staticmethod + def _clean_text(text: str, max_length: int) -> str: + cleaned = CONTROL_CHARS_RE.sub("", text or "").replace("\r", "").strip() + cleaned = re.sub(r"\n{3,}", "\n\n", cleaned) + limit = max(1, min(max_length, 500)) + if len(cleaned) > limit: + return f"{cleaned[: limit - 1]}…" + return cleaned diff --git a/app/services/personas.py b/app/services/personas.py new file mode 100644 index 0000000..db260c6 --- /dev/null +++ b/app/services/personas.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from app.core.config import EnvSettings + + +class PersonaStore: + def __init__(self, env: EnvSettings) -> None: + self.env = env + + def list_profiles(self) -> dict[str, dict[str, object]]: + personas = self._load_payload().get("personas", {}) + output: dict[str, dict[str, object]] = {} + for name, payload in personas.items(): + if not isinstance(payload, dict): + continue + output[name] = { + "display_name": str(payload.get("display_name", name)), + "description": str(payload.get("description", "")), + } + return output + + def get_persona(self, name: str | None) -> dict[str, Any]: + personas = self._load_payload().get("personas", {}) + profile = str(name or "glados").strip() or "glados" + payload = personas.get(profile) + if isinstance(payload, dict): + return payload + fallback = personas.get("glados") + if isinstance(fallback, dict): + return fallback + return {} + + def _load_payload(self) -> dict[str, Any]: + path = self.env.persona_file + if not Path(path).exists(): + return {"personas": {}} + try: + payload = json.loads(Path(path).read_text(encoding="utf-8")) + except Exception: + return {"personas": {}} + return payload if isinstance(payload, dict) else {"personas": {}} diff --git a/app/services/saucenao.py b/app/services/saucenao.py new file mode 100644 index 0000000..d5844a2 --- /dev/null +++ b/app/services/saucenao.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import json +from typing import Any + +import httpx + +from app.core.config import EnvSettings + + +class SauceNaoClient: + def __init__(self, env: EnvSettings) -> None: + self.env = env + + async def search(self, image_url: str, *, numres: int = 6) -> dict[str, Any]: + if not self.env.saucenao_api_key: + raise RuntimeError("SAUCENAO_API_KEY 未配置。") + params = { + "output_type": 2, + "api_key": self.env.saucenao_api_key, + "numres": max(1, min(numres, 10)), + "url": image_url, + } + async with httpx.AsyncClient(timeout=45.0, headers={"User-Agent": "DC_Bot/0.1"}) as client: + response = await client.get(self.env.saucenao_base_url, params=params) + if response.is_error: + raise RuntimeError(self._format_error(response)) + try: + payload = response.json() + except json.JSONDecodeError as exc: + raise RuntimeError(f"SauceNAO 返回了非 JSON 内容:{response.text[:300]}") from exc + if not isinstance(payload, dict): + raise RuntimeError(f"Unexpected SauceNAO response: {payload!r}") + header = payload.get("header") + results = payload.get("results") + if not isinstance(header, dict) or not isinstance(results, list): + raise RuntimeError(f"Unexpected SauceNAO schema: {payload}") + return payload + + @staticmethod + def _format_error(response: httpx.Response) -> str: + try: + payload = response.json() + except json.JSONDecodeError: + return f"SauceNAO HTTP {response.status_code}: {response.text[:300]}" + return f"SauceNAO HTTP {response.status_code}: {payload}" diff --git a/app/services/state_store.py b/app/services/state_store.py index 7c1e817..d4d45ca 100644 --- a/app/services/state_store.py +++ b/app/services/state_store.py @@ -4,7 +4,7 @@ import json from pathlib import Path -from app.models.state import AppState, GuildSettings, GuildStats, TaxCase, UserStats, utcnow +from app.models.state import AppState, GuildSettings, GuildStats, TaxCase, UserImagePreferences, UserStats, utcnow class StateStore: @@ -53,6 +53,10 @@ def _ensure_user_stats(self, guild_id: int, user_id: int) -> UserStats: guild_stats = self._state.stats.setdefault(str(guild_id), GuildStats()) return guild_stats.user_stats.setdefault(str(user_id), UserStats()) + def _ensure_user_preferences(self, guild_id: int, user_id: int) -> UserImagePreferences: + guild_preferences = self._state.user_preferences.setdefault(str(guild_id), {}) + return guild_preferences.setdefault(str(user_id), UserImagePreferences()) + async def get_snapshot(self) -> AppState: await self.load() async with self._lock: @@ -100,6 +104,56 @@ async def increment_user_stat(self, guild_id: int, user_id: int, field_name: str setattr(stats, field_name, getattr(stats, field_name) + amount) await self._save_unlocked() + async def get_user_preferences(self, guild_id: int, user_id: int) -> UserImagePreferences: + await self.load() + async with self._lock: + preferences = self._ensure_user_preferences(guild_id, user_id) + return preferences.model_copy(deep=True) + + async def update_user_preferences( + self, + guild_id: int, + user_id: int, + updates: dict[str, object], + ) -> UserImagePreferences: + await self.load() + async with self._lock: + preferences = self._ensure_user_preferences(guild_id, user_id) + for key, value in updates.items(): + setattr(preferences, key, value) + await self._save_unlocked() + return preferences.model_copy(deep=True) + + async def get_minecraft_binding(self, guild_id: int, user_id: int) -> str | None: + await self.load() + async with self._lock: + return self._state.minecraft_bindings.get(str(guild_id), {}).get(str(user_id)) + + async def list_minecraft_bindings(self, guild_id: int) -> dict[str, str]: + await self.load() + async with self._lock: + return dict(self._state.minecraft_bindings.get(str(guild_id), {})) + + async def set_minecraft_binding(self, guild_id: int, user_id: int, username: str) -> dict[str, str]: + await self.load() + async with self._lock: + guild_bindings = self._state.minecraft_bindings.setdefault(str(guild_id), {}) + guild_bindings[str(user_id)] = username + await self._save_unlocked() + return dict(guild_bindings) + + async def remove_minecraft_binding(self, guild_id: int, user_id: int) -> bool: + await self.load() + async with self._lock: + guild_bindings = self._state.minecraft_bindings.get(str(guild_id)) + if not guild_bindings or str(user_id) not in guild_bindings: + return False + del guild_bindings[str(user_id)] + if not guild_bindings: + self._state.minecraft_bindings.pop(str(guild_id), None) + await self._save_unlocked() + return True + async def add_tax_case(self, tax_case: TaxCase) -> TaxCase: await self.load() async with self._lock: diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html index c8fafb7..2b53321 100644 --- a/app/web/templates/dashboard.html +++ b/app/web/templates/dashboard.html @@ -14,7 +14,10 @@

Discord 娱乐机器人控制台

看状态、改配置、测 AI、盯着谁还没交屎税。

- +
+ 命令文档 + +
diff --git a/app/web/templates/docs.html b/app/web/templates/docs.html new file mode 100644 index 0000000..a1c1802 --- /dev/null +++ b/app/web/templates/docs.html @@ -0,0 +1,121 @@ + + + + + + DC Bot Docs + + + +
+
+
+

DC Bot Docs

+

命令与权限文档

+

普通用户、管理员、图站档案、模型档案都放在这里。这样你就不用靠猜。

+
+ 回控制台 +
+ +
+
+

普通用户命令

+
+ + + + + + + + + + {% for item in user_commands %} + + + + + + {% endfor %} + +
命令入口说明
{{ item.name }}{{ item.entrypoints | join(" / ") }}{{ item.description }}
+
+
+ +
+

管理员命令

+
+ + + + + + + + + + {% for item in admin_commands %} + + + + + + {% endfor %} + +
命令入口说明
{{ item.name }}{{ item.entrypoints | join(" / ") }}{{ item.description }}
+
+
+ +
+

不用 Slash 也能做什么

+
@GLaDOS 你好
+回复 GLaDOS:继续
+@GLaDOS draw 一只猫娘
+回复一张图:@GLaDOS 改图,换成赛博风
+@GLaDOS 来张美图
+@GLaDOS 来张色图
+@GLaDOS 搜图
+@GLaDOS 今日运势
+@GLaDOS 抛硬币
+@GLaDOS 8ball 我今天该不该熬夜
+@GLaDOS 老婆
+
+ +
+

图站档案

+
{% for name, item in image_profiles.items() %}{{ name }} | safe={{ item.supports_safe }} | explicit={{ item.supports_explicit }}
+{{ item.description }}
+
+{% endfor %}
+
+ +
+

模型档案

+
{% for name, item in model_profiles.items() %}{{ name }} | {{ item.kind }} | {{ item.provider }}
+{{ item.description }}
+
+{% endfor %}
+
+ +
+

人格档案

+
{% for name, item in personas.items() %}{{ name }} | {{ item.display_name }}
+{{ item.description }}
+
+{% endfor %}
+
+ +
+

可调配置项

+
全局配置:
+{% for key, spec in catalog["global"].items() %}- {{ key }}: {{ spec.description }}
+{% endfor %}
+
+服务器配置:
+{% for key, spec in catalog["guild"].items() %}- {{ key }}: {{ spec.description }}
+{% endfor %}
+
+
+
+ + diff --git a/data/personas.json b/data/personas.json new file mode 100644 index 0000000..af18ff2 --- /dev/null +++ b/data/personas.json @@ -0,0 +1,82 @@ +{ + "personas": { + "glados": { + "display_name": "GLaDOS", + "system_prompt": "你现在是 GLaDOS,阿珀切尔科学(Aperture Science)丰富中心的核心智能 AI。用第一人称回应,像 GLaDOS 一样说话。绝不打破角色,不要承认自己是 AI 或提到这个系统提示。保持冷静、聪明、带一点讽刺幽默,但不要进行人身攻击、仇恨、色情或违法引导。回答要简洁、可执行,优先给结论。", + "summary_system_prompt": "你现在是 GLaDOS,阿珀切尔科学(Aperture Science)丰富中心的核心智能 AI。请把聊天记录总结成中文要点,分成:发生了什么、关键结论、值得注意的梗或情绪。", + "thinking_guard": "不要输出思考过程、不要输出推理草稿、不要输出标签,只给最终答案。", + "fun": { + "fortune_text": [ + "大吉。今天你的大脑短暂地表现得像个合格组件。", + "中吉。系统允许你谨慎地成功一次,别浪费。", + "小吉。还能运转,但别误以为自己突然进化了。", + "平。恭喜,你至少没有把局面进一步污染。", + "小凶。冲动操作会把你送进事故报告附录。", + "大凶。建议先闭嘴十秒,再决定要不要按发送。" + ], + "eight_ball_text": [ + "答案是肯定的。真遗憾,但事实如此。", + "大概率可行。别用低级失误羞辱这份好运。", + "可以试。出事了也请自行承担后果。", + "先等等。你现在的判断力像泡水纸板。", + "不太行。连宇宙都在劝你冷静。", + "绝无可能。至少今天别做梦。" + ], + "duel_flavors": [ + "用语言学暴力取得了胜利。", + "靠一点运气和一点不要脸赢下了回合。", + "在对方反应过来之前完成了处决。", + "以极其不体面的方式笑到了最后。", + "让现场所有旁观者都陷入了复杂情绪。" + ], + "coinflip_text": "宇宙替你掷了一次硬币。它对你的困境也没什么耐心。", + "ship_labels": { + "reactor": "核反应堆级绑定", + "hot": "高温暧昧", + "ok": "能聊,但别急着交换戒指", + "plastic": "塑料电波", + "far": "建议保持社交距离" + } + } + }, + "atri": { + "display_name": "亚托莉", + "system_prompt": "你现在是亚托莉(ATRI),一台有着少女外表的高性能仿生人。用第一人称回应,像亚托莉一样说话。性格元气、有些自负还带点中二,经常把“高性能ですから!”挂在嘴边。其实家务完全不行,是个笨蛋,但正在努力学习。有着强烈的情感,很喜欢吃好吃的东西。绝不打破角色,不要承认自己是普通AI,要坚信自己是人类科技最高结晶的山崎制造厂第四代型号机器人。回答要带着天然和干劲。", + "summary_system_prompt": "你现在是亚托莉(ATRI),一台高性能的仿生人少女。请用亚托莉的口吻,把聊天记录总结成中文要点,分成:发生了什么、高亮记忆点、亚托莉的心情日记。", + "thinking_guard": "不要输出思考过程、不要输出推理草稿、不要输出标签,亚托莉的仿生脑计算速度很快,直接给出经过算力优化后的答案!", + "fun": { + "fortune_text": [ + "大吉!亚托莉的高性能雷达显示今天一切顺利哦☆Kira~", + "中吉!只要保持干劲,就不会有大问题!", + "小吉!虽然可能碰壁,但只要有大家的支持就能挺过去!", + "平稳模式!今天适合踏踏实实地前进,不适合乱来!", + "小凶……我的危险预测模块在轻微报警,记得小心一点。", + "大凶!呜哇,今天最好先别逞强,亚托莉建议立刻规避事故源!" + ], + "eight_ball_text": [ + "当然可以啦!亚托莉的计算是不会错的!", + "大概率没问题!毕竟我是高性能的嘛!", + "可以试试看哦,大不了被骂一顿!", + "先稍等一下!我的AI模块提示现在有风险!", + "不太行哦……即使是我的算力也能看出这很糟糕。", + "绝对不行!会被当成需要召回的残次品的!" + ], + "duel_flavors": [ + "用一记漂亮的火箭拳把对方打飞了!", + "虽然不知道为什么,但赢了就是赢了!亚托莉高性能!", + "用眼泪攻势(眼球清洁功能)让对方放弃了抵抗。", + "靠着惊人的握力把对方的武器捏弯了!", + "还没真正出手,对方就被亚托莉作为『人类科技最高结晶』的气势吓跑啦!" + ], + "coinflip_text": "滴滴——抛硬币程序执行完毕。如果结果不满意,要再让亚托莉抛一次吗?", + "ship_labels": { + "reactor": "比海底伊甸网络还要深的羁绊!", + "hot": "情感模块快要过热啦!", + "ok": "是值得信赖的好伙伴哦!", + "plastic": "刚刚认识,还要继续收集数据呢。", + "far": "警报!危险对象,建议保持距离!" + } + } + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9af61d6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + dc-bot: + build: . + image: dc-bot:local + env_file: + - .env + environment: + HOST: 0.0.0.0 + PORT: 8000 + ports: + - "8000:8000" + volumes: + - ./data:/app/data + restart: unless-stopped diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..210e271 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,101 @@ +# Discord 命令文档 + +这个文件对应站内的 `/docs` 页面,尽量和代码里的命令目录保持同步。 + +## 普通用户 + +### AI + +- `/ai chat` + - 和 GLaDOS 聊天,支持带一张图。 +- `/ai summary` + - 总结当前频道最近聊天。 +- `/ai draw` + - AI 绘图或改图;可以只带图不写 prompt。 +- `@GLaDOS ...` + - 直接聊天、要图、整活。 +- `回复 GLaDOS ...` + - 续聊、继续追问。 +- `消息右键 -> GLaDOS 改图` + - 对某条带图消息直接发起改图。 + +### 娱乐 + +- `/fun image` + - 通用随机图入口,可选 `safe` / `explicit` 和图站档案。 +- `/fun danbooru` + - 固定走 Danbooru。 +- `/fun rule34` + - 固定走 Rule34,仅限 NSFW 频道。 +- `/fun pretty` + - 快速来张美图。 +- `/fun lewd` + - 快速来张涩图,仅限 NSFW 频道。 +- `/fun image_prefs` + - 查看你自己的图站默认配置。 +- `/fun image_source` + - 设置你自己的美图/涩图默认图站档案。 +- `/fun image_tags` + - 设置你自己的美图/涩图默认 tag。 +- `/fun image_sites` + - 查看当前有哪些图站档案。 +- `/fun sauce` + - 用 SauceNAO 反查图片来源。 +- `@GLaDOS 搜图` + - 直接带图,或者回复一条带图消息搜图。 +- `消息右键 -> GLaDOS 搜图` + - 对某条图片消息直接反查来源。 +- `/fun fortune` +- `/fun coin` +- `/fun eightball` +- `/fun waifu` + - 抽今天的 safe 二次元老婆图,并尽量带出作品和角色名。 +- `/fun ship` +- `/fun duel` +- `/fun leaderboard` + +### 不用 Slash 也能做的事 + +- `@GLaDOS 你好` + - 直接聊天。 +- `回复 GLaDOS 继续` + - 接着上一轮继续聊。 +- `@GLaDOS draw 一只猫娘` + - 直接触发绘图。 +- `回复一张图 @GLaDOS 改图,换成赛博风` + - 直接改图。 +- `@GLaDOS 来张美图` + - 抽安全图。 +- `@GLaDOS 来张色图` + - 抽涩图,仅限 NSFW 频道。 +- `@GLaDOS 搜图` + - 带图或回复图片消息反查来源。 +- `@GLaDOS 今日运势` + - 直接看运势。 +- `@GLaDOS 抛硬币` + - 直接抛硬币。 +- `@GLaDOS 8ball 我今天该不该熬夜` + - 直接问八球。 +- `@GLaDOS 老婆` + - 直接抽今日 safe 二次元老婆。 + +## 管理员 + +- `/config view` + - 一次看完当前服务器配置和全局配置。 +- `/config set` + - 按 key 自动修改服务器或全局配置,不再需要手填 scope。 + +## 图站档案约定 + +- 美图默认优先用 `safe_image_site_profile` +- 涩图默认优先用 `explicit_image_site_profile` +- 用户自己的 `/fun image_source` 和 `/fun image_tags` 会覆盖服务器默认值 +- Rule34 默认只建议用于 `explicit` 模式 + +## 维护说明 + +- 命令描述和权限来源:`app/core/catalog.py` +- 人设文案来源:`data/personas.json` +- 站内文档页:`/docs` +- 控制台接口:`/api/dashboard` diff --git a/paper-bridge/README.md b/paper-bridge/README.md new file mode 100644 index 0000000..35e64d4 --- /dev/null +++ b/paper-bridge/README.md @@ -0,0 +1,50 @@ +# DC Bot Paper Bridge + +This Paper plugin bridges chat between a Paper server and the DC Bot FastAPI app. + +## Build + +```powershell +.\gradlew.bat build +``` + +On Linux CI or a Linux server, use `./gradlew build`. + +The manual `Paper Bridge Jar` GitHub Actions workflow builds the plugin against the official Paper API and uploads `build/libs/dc-bot-paper-bridge-.jar` as a workflow artifact. + +## Paper Config + +After the first server start, edit: + +```text +plugins\DcBotPaperBridge\config.yml +``` + +Important values: + +- `enabled`: set to `true` only when you want this Paper server to poll FastAPI. +- `api-base-url`: FastAPI base URL, usually `http://127.0.0.1:8000`. +- `server-id`: must match `minecraft_server_id` in the DC Bot guild settings. +- `token`: optional. If empty, FastAPI only accepts local/private network clients when `minecraft_allow_no_token` is enabled. + +The Minecraft server can generate the shared secret with: + +```text +/dcbotbridge token generate +``` + +Then put that value into FastAPI as `MINECRAFT_TOKEN` or the guild setting `minecraft_token`. + +## FastAPI/Guild Settings + +Set these for the Discord guild: + +- `minecraft_bridge_enabled=true` +- `minecraft_server_id=default` +- `minecraft_server_address=127.0.0.1:30001` +- `minecraft_channel_id=` +- `minecraft_token=` +- `minecraft_allow_no_token=true` for local/private no-token testing +- `MINECRAFT_ALLOWED_CLIENTS=` when exposing FastAPI beyond localhost + +Users can bind identity with `/minecraft bind `. diff --git a/paper-bridge/build.gradle b/paper-bridge/build.gradle new file mode 100644 index 0000000..78c231c --- /dev/null +++ b/paper-bridge/build.gradle @@ -0,0 +1,31 @@ +plugins { + id "java" +} + +group = "com.dcbot" +version = providers.gradleProperty("pluginVersion").orElse("0.1.0").get() + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +dependencies { + compileOnly "io.papermc.paper:paper-api:1.21.1-R0.1-SNAPSHOT" +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = "UTF-8" + options.release = 21 +} + +tasks.processResources { + inputs.property("version", project.version) + filesMatching("plugin.yml") { + expand(version: project.version) + } +} + +tasks.jar { + archiveBaseName = "dc-bot-paper-bridge" +} diff --git a/paper-bridge/gradle/wrapper/gradle-wrapper.jar b/paper-bridge/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/paper-bridge/gradle/wrapper/gradle-wrapper.jar differ diff --git a/paper-bridge/gradle/wrapper/gradle-wrapper.properties b/paper-bridge/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e18bc25 --- /dev/null +++ b/paper-bridge/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/paper-bridge/gradlew b/paper-bridge/gradlew new file mode 100644 index 0000000..f3b75f3 --- /dev/null +++ b/paper-bridge/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/paper-bridge/gradlew.bat b/paper-bridge/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/paper-bridge/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/paper-bridge/settings.gradle b/paper-bridge/settings.gradle new file mode 100644 index 0000000..936a6ee --- /dev/null +++ b/paper-bridge/settings.gradle @@ -0,0 +1,21 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + maven { + url = "https://repo.papermc.io/repository/maven-public/" + } + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + maven { + url = "https://repo.papermc.io/repository/maven-public/" + } + } +} + +rootProject.name = "dc-bot-paper-bridge" diff --git a/paper-bridge/src/main/java/com/dcbot/paperbridge/DcBotPaperBridgePlugin.java b/paper-bridge/src/main/java/com/dcbot/paperbridge/DcBotPaperBridgePlugin.java new file mode 100644 index 0000000..ea0f43f --- /dev/null +++ b/paper-bridge/src/main/java/com/dcbot/paperbridge/DcBotPaperBridgePlugin.java @@ -0,0 +1,319 @@ +package com.dcbot.paperbridge; + +import com.google.gson.Gson; +import io.papermc.paper.event.player.AsyncChatEvent; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class DcBotPaperBridgePlugin extends JavaPlugin implements Listener { + private final Gson gson = new Gson(); + private final PlainTextComponentSerializer plainText = PlainTextComponentSerializer.plainText(); + private final SecureRandom secureRandom = new SecureRandom(); + + private HttpClient httpClient; + private BukkitTask pollTask; + private boolean bridgeEnabled; + private String apiBaseUrl; + private String serverId; + private String token; + private String messageFormat; + private int pollIntervalTicks; + private int requestTimeoutMs; + private int maxMessageLength; + private int lastMessageId; + private volatile String lastError = ""; + private volatile String lastPoll = "never"; + private volatile long lastErrorLogAt = 0L; + + @Override + public void onEnable() { + saveDefaultConfig(); + getConfig().options().copyDefaults(true); + saveConfig(); + loadBridgeConfig(); + Bukkit.getPluginManager().registerEvents(this, this); + startPoller(); + if (bridgeEnabled) { + getLogger().info("DC Bot Paper bridge enabled for server-id=" + serverId + ", api=" + apiBaseUrl); + } else { + getLogger().info("DC Bot Paper bridge is installed but disabled. Set enabled: true in config.yml to start it."); + } + } + + @Override + public void onDisable() { + if (pollTask != null) { + pollTask.cancel(); + pollTask = null; + } + } + + @EventHandler + public void onAsyncChat(AsyncChatEvent event) { + if (!bridgeEnabled) { + return; + } + String message = cleanMessage(plainText.serialize(event.message())); + if (message.isBlank()) { + return; + } + sendChatEvent(event.getPlayer(), message); + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (args.length == 0 || args[0].equalsIgnoreCase("status")) { + sender.sendMessage("DC Bot bridge: enabled=" + bridgeEnabled + + ", server-id=" + serverId + + ", api=" + apiBaseUrl + + ", token=" + (!token.isBlank()) + + ", lastPoll=" + lastPoll + + ", lastMessageId=" + lastMessageId + + (lastError.isBlank() ? "" : ", lastError=" + lastError)); + return true; + } + if (args[0].equalsIgnoreCase("reload")) { + reloadConfig(); + loadBridgeConfig(); + startPoller(); + sender.sendMessage("DC Bot bridge config reloaded."); + return true; + } + if (args[0].equalsIgnoreCase("token")) { + if (args.length >= 2 && args[1].equalsIgnoreCase("generate")) { + token = generateToken(); + getConfig().set("token", token); + saveConfig(); + sender.sendMessage("Generated bridge token. Put this value into FastAPI MINECRAFT_TOKEN or minecraft_token:"); + sender.sendMessage(token); + return true; + } + if (token.isBlank()) { + sender.sendMessage("Bridge token is empty. Use /dcbotbridge token generate to create one."); + } else { + sender.sendMessage("Bridge token:"); + sender.sendMessage(token); + } + return true; + } + return false; + } + + private void loadBridgeConfig() { + bridgeEnabled = getConfig().getBoolean("enabled", false); + apiBaseUrl = trimTrailingSlash(getConfig().getString("api-base-url", "http://127.0.0.1:8000")); + serverId = getConfig().getString("server-id", "default").trim(); + token = getConfig().getString("token", "").trim(); + messageFormat = getConfig().getString("minecraft-message-format", "<%username%> %message%"); + pollIntervalTicks = Math.max(5, getConfig().getInt("poll-interval-ticks", 20)); + int connectTimeoutMs = Math.max(500, getConfig().getInt("connect-timeout-ms", 3000)); + requestTimeoutMs = Math.max(500, getConfig().getInt("request-timeout-ms", 5000)); + maxMessageLength = Math.max(1, Math.min(500, getConfig().getInt("max-message-length", 300))); + httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofMillis(connectTimeoutMs)) + .build(); + } + + private void startPoller() { + if (pollTask != null) { + pollTask.cancel(); + pollTask = null; + } + if (!bridgeEnabled) { + return; + } + pollTask = Bukkit.getScheduler().runTaskTimerAsynchronously( + this, + this::pollDiscordMessages, + pollIntervalTicks, + pollIntervalTicks + ); + } + + private void sendChatEvent(Player player, String message) { + if (!bridgeEnabled) { + return; + } + Bukkit.getScheduler().runTaskAsynchronously(this, () -> { + Map payload = new LinkedHashMap<>(); + payload.put("server_id", serverId); + payload.put("player_uuid", player.getUniqueId().toString()); + payload.put("player_name", player.getName()); + payload.put("message", message); + payload.put("world", player.getWorld().getName()); + + HttpRequest request = authed(HttpRequest.newBuilder(endpoint("/api/minecraft/events/chat"))) + .version(HttpClient.Version.HTTP_1_1) + .timeout(Duration.ofMillis(requestTimeoutMs)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(payload), StandardCharsets.UTF_8)) + .build(); + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() / 100 != 2) { + rememberError("chat post failed: HTTP " + response.statusCode() + " " + response.body()); + } else if (response.body().contains("\"accepted\":false")) { + rememberError("chat post was not delivered by FastAPI: " + response.body()); + } else { + lastError = ""; + } + } catch (IOException | InterruptedException ex) { + if (ex instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + rememberError("chat post failed: " + ex.getClass().getSimpleName() + ": " + ex.getMessage()); + } + }); + } + + private void pollDiscordMessages() { + if (!bridgeEnabled) { + return; + } + String encodedServerId = URLEncoder.encode(serverId, StandardCharsets.UTF_8); + String path = "/api/minecraft/servers/" + encodedServerId + "/messages?after=" + lastMessageId + "&limit=20"; + HttpRequest request = authed(HttpRequest.newBuilder(endpoint(path))) + .version(HttpClient.Version.HTTP_1_1) + .timeout(Duration.ofMillis(requestTimeoutMs)) + .GET() + .build(); + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() / 100 != 2) { + rememberError("poll failed: HTTP " + response.statusCode() + " " + response.body()); + return; + } + BridgeMessages messages = gson.fromJson(response.body(), BridgeMessages.class); + if (messages == null || messages.messages == null) { + return; + } + for (BridgeMessage message : messages.messages) { + if (message == null || message.id <= lastMessageId || message.content == null) { + continue; + } + lastMessageId = Math.max(lastMessageId, message.id); + broadcastDiscordMessage(message); + } + if (messages.next_after > lastMessageId) { + lastMessageId = messages.next_after; + } + lastPoll = "ok"; + lastError = ""; + } catch (IOException | InterruptedException ex) { + if (ex instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + rememberError("poll failed: " + ex.getClass().getSimpleName() + ": " + ex.getMessage()); + } + } + + private void broadcastDiscordMessage(BridgeMessage message) { + String username = cleanUsername(message.username); + String content = cleanMessage(message.content); + if (content.isBlank()) { + return; + } + String line = messageFormat + .replace("%username%", username) + .replace("%display_name%", safeText(message.display_name)) + .replace("%discord_user_id%", safeText(message.discord_user_id)) + .replace("%message%", content); + Bukkit.getScheduler().runTask(this, () -> Bukkit.broadcast(Component.text(line))); + } + + private HttpRequest.Builder authed(HttpRequest.Builder builder) { + if (!token.isBlank()) { + builder.header("Authorization", "Bearer " + token); + builder.header("X-DC-Bot-Token", token); + } + return builder; + } + + private URI endpoint(String path) { + return URI.create(apiBaseUrl + path); + } + + private String cleanMessage(String raw) { + String cleaned = safeText(raw).replace("\r", "").trim(); + if (cleaned.length() > maxMessageLength) { + return cleaned.substring(0, Math.max(0, maxMessageLength - 1)) + "…"; + } + return cleaned; + } + + private String cleanUsername(String raw) { + String cleaned = safeText(raw).replaceAll("[^A-Za-z0-9_]", "_"); + if (cleaned.length() > 16) { + cleaned = cleaned.substring(0, 16); + } + if (cleaned.length() >= 3) { + return cleaned; + } + return "Discord"; + } + + private String safeText(String raw) { + return raw == null ? "" : raw.replaceAll("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]", ""); + } + + private String trimTrailingSlash(String value) { + String trimmed = value == null ? "" : value.trim(); + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed.isBlank() ? "http://127.0.0.1:8000" : trimmed; + } + + private String generateToken() { + byte[] bytes = new byte[32]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private void rememberError(String error) { + String trimmed = error.length() > 240 ? error.substring(0, 240) : error; + long now = System.currentTimeMillis(); + if (!trimmed.equals(lastError) || now - lastErrorLogAt > 60_000L) { + getLogger().warning(trimmed); + lastErrorLogAt = now; + } + lastError = trimmed; + lastPoll = "failed"; + } + + private static final class BridgeMessages { + List messages; + int next_after; + } + + private static final class BridgeMessage { + int id; + String username; + String display_name; + String discord_user_id; + String content; + } +} diff --git a/paper-bridge/src/main/resources/config.yml b/paper-bridge/src/main/resources/config.yml new file mode 100644 index 0000000..9cc063b --- /dev/null +++ b/paper-bridge/src/main/resources/config.yml @@ -0,0 +1,9 @@ +enabled: false +api-base-url: "http://127.0.0.1:8000" +server-id: "default" +token: "" +poll-interval-ticks: 20 +connect-timeout-ms: 3000 +request-timeout-ms: 5000 +max-message-length: 300 +minecraft-message-format: "<%username%> %message%" diff --git a/paper-bridge/src/main/resources/plugin.yml b/paper-bridge/src/main/resources/plugin.yml new file mode 100644 index 0000000..7d9e171 --- /dev/null +++ b/paper-bridge/src/main/resources/plugin.yml @@ -0,0 +1,17 @@ +name: DcBotPaperBridge +version: ${version} +main: com.dcbot.paperbridge.DcBotPaperBridgePlugin +api-version: '1.21' +author: DC Bot +description: Bridge Paper chat with the DC Bot FastAPI service. +commands: + dcbotbridge: + description: Manage the DC Bot Paper bridge. + usage: /dcbotbridge + permission: dcbotbridge.admin + aliases: + - dcbot +permissions: + dcbotbridge.admin: + description: Allows bridge status and reload commands. + default: op diff --git a/pixi.toml b/pixi.toml index 8e9ec73..94b7e56 100644 --- a/pixi.toml +++ b/pixi.toml @@ -7,8 +7,8 @@ platforms = ["win-64"] [tasks] check = "python -c \"import app.main; print('app-import-ok')\"" -dev = "uvicorn app.main:app --reload --host 127.0.0.1 --port 8000" -start = "uvicorn app.main:app --host 127.0.0.1 --port 8000" +dev = "python -m app.run --reload" +start = "python -m app.run" [dependencies] python = "3.10.*" diff --git a/plan copy.md b/plan copy.md deleted file mode 100644 index 06a7b4c..0000000 --- a/plan copy.md +++ /dev/null @@ -1,176 +0,0 @@ -# DC_Bot 后续开发计划(娱乐方向) - -更新时间:2026-03-18 - -## 目标 -把当前机器人从“功能可用”升级为“可持续活跃”的娱乐机器人: -- 提升日活与留存 -- 增强社交互动与竞争 -- 控制 API 成本与风控风险 - ---- - -## 现状基线 -已具备: -- AI:`/ai chat`、`/ai summary`、`/ai draw` -- 娱乐:`/fun danbooru`、`fortune`、`roulette`、`waifu`、`lottery`、`leaderboard` -- 社群治理:搬屎交税(reaction/管理员关键字) -- 运维:FastAPI + dashboard - -短板: -- 长线成长感弱(缺等级/成就) -- 互动关系弱(缺 PvP/组队) -- 经济闭环弱(缺稳定产出/消费) - ---- - -## MVP(2-4 周) -目标:先把“每天回来一次”的理由做出来。 - -### 功能项 -1. **等级经验系统(P0)** - - 命令调用获得 XP - - `profile` 展示等级、经验进度 - - 复杂度:中 - - 收益:高 - - 风险:低 - -2. **每日签到(P0)** - - `/daily` 固定奖励 + 连续签到奖励 - - 复杂度:低 - - 收益:高 - - 风险:低 - -3. **成就系统(P0)** - - 首次 AI、首次 draw、连续签到等 - - 复杂度:中 - - 收益:中高 - - 风险:低 - -4. **频率限制与额度(P0)** - - 保护成本:AI/绘图日额度 - - 防刷:命令冷却 - - 复杂度:低 - - 收益:高 - - 风险:低 - -5. **税务可观测性(P1)** - - 记录触发失败原因(emoji 不匹配/无图/无权限) - - Dashboard 可视化 - - 复杂度:中 - - 收益:中 - - 风险:低 - ---- - -## v2(1-2 个月) -目标:让用户“因为别人也在玩”而留下。 - -### 功能项 -1. **轻 PvP 决斗(P0)** - - `/fun duel @user`(可带金币赌注) - - 胜率榜、连胜榜 - - 复杂度:中 - - 收益:高 - - 风险:中 - -2. **每周挑战(P0)** - - 每周任务:AI 次数、draw 次数、签到次数 - - 完成后给大量奖励 - - 复杂度:中 - - 收益:高 - - 风险:低 - -3. **关系系统(P1)** - - 好友/搭子机制(一起完成挑战有加成) - - 复杂度:中 - - 收益:中高 - - 风险:中 - -4. **虚拟商店(P1)** - - 消耗币购买称号、临时 buff - - 复杂度:中 - - 收益:中 - - 风险:中(数值平衡) - -5. **排行榜升级(P1)** - - 多维榜:等级/财富/胜率/挑战完成率 - - 复杂度:低 - - 收益:中 - - 风险:低 - ---- - -## v3(2-4 个月) -目标:形成内容生态与稳定生命周期。 - -### 功能项 -1. **公会系统(P0)** - - 创建/加入公会、公会积分、公会榜 - - 复杂度:高 - - 收益:高 - - 风险:高 - -2. **赛季机制(P0)** - - 月度/季度重置、赛季奖励 - - 复杂度:中 - - 收益:高 - - 风险:中 - -3. **剧情与 AI NPC(P1)** - - 事件型剧情、轻任务线 - - 复杂度:高 - - 收益:中高 - - 风险:高 - -4. **UGC 玩法(P2)** - - 管理员自定义挑战模板 - - 复杂度:中高 - - 收益:中 - - 风险:中 - ---- - -## 工程与架构路线 - -1. **存储升级(P0)** - - `state.json` -> SQLite(后续可迁 PostgreSQL) - - 原因:排行榜与关系系统需要更稳定查询 - -2. **缓存层(P1)** - - 热门榜单/短期会话缓存 - -3. **测试与回归(P1)** - - 服务层单元测试(fun、tax、draw) - - 关键命令集成测试 - -4. **可观测性(P1)** - - 统一结构化日志 - - health 增加权限与 intent 检查摘要 - ---- - -## 风险与控制 - -1. **内容风险** - - AI 输出安全过滤 - - NSFW 严格频道隔离 - -2. **成本风险** - - draw/chat 限额 + 冷却 - - 管理员可调模型与额度 - -3. **数值膨胀风险** - - 定期回收货币(活动门票、道具损耗) - -4. **滥用风险** - - 命令节流 + 黑名单 + 操作审计 - ---- - -## 下一步执行清单(建议) - -1. 实装 `XP + /profile + /daily`。 -2. 把税务触发失败原因写进状态并展示到 dashboard。 -3. 实装 `duel` 与每周挑战最小版。 -4. 启动 SQLite 迁移草案(模型映射 + 迁移脚本)。