From 95724ede664fddd636cb614fa51c2e59f842b990 Mon Sep 17 00:00:00 2001 From: Moeary Date: Thu, 26 Mar 2026 22:36:44 +0800 Subject: [PATCH 1/5] feat: Add user image preferences and enhance image sourcing capabilities - Introduced UserImagePreferences to manage user-specific image settings. - Implemented methods in StateStore for retrieving and updating user preferences. - Created ImageSourceRouter for handling image requests from various sources, including Danbooru and Rule34. - Added PersonaStore for managing user personas with customizable profiles. - Integrated SauceNaoClient for image source identification. - Updated dashboard and documentation to reflect new features and commands. - Added personas.json for persona definitions and behaviors. --- .env.example | 5 + .vscode/settings.json | 3 +- README.md | 36 +- app/api/routes/ai.py | 27 +- app/api/routes/health.py | 6 + app/api/routes/settings.py | 15 + app/api/schemas.py | 2 +- app/bot/client.py | 819 +++++++++++++++++++++++++++---- app/core/catalog.py | 166 ++++++- app/core/config.py | 16 + app/main.py | 58 ++- app/models/state.py | 15 + app/services/fun.py | 239 +++++++-- app/services/image_sources.py | 364 ++++++++++++++ app/services/personas.py | 45 ++ app/services/saucenao.py | 46 ++ app/services/state_store.py | 26 +- app/web/templates/dashboard.html | 5 +- app/web/templates/docs.html | 106 ++++ data/personas.json | 71 +++ docs/commands.md | 88 ++++ 21 files changed, 2000 insertions(+), 158 deletions(-) create mode 100644 app/services/image_sources.py create mode 100644 app/services/personas.py create mode 100644 app/services/saucenao.py create mode 100644 app/web/templates/docs.html create mode 100644 data/personas.json create mode 100644 docs/commands.md diff --git a/.env.example b/.env.example index 6e90669..b717045 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,6 +22,10 @@ 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 +SAUCENAO_BASE_URL=https://saucenao.com/search.php +SAUCENAO_API_KEY= STATE_FILE=data/state.json HOST=127.0.0.1 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/README.md b/README.md index 9f09526..5056fe1 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 时,触发警告 - 管理员回复某条带图消息 `税` / `交税` / `补税` 也可手动触发 @@ -53,6 +57,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 +75,17 @@ 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 +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`。 @@ -125,8 +135,10 @@ uvicorn app.main:app --reload ### 面板与状态 - `GET /dashboard` +- `GET /docs` - `GET /api/health` - `GET /api/dashboard` +- `GET /api/docs` ### AI @@ -153,11 +165,21 @@ 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>` +- `/ai image` ### 娱乐 +- `/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` @@ -166,6 +188,9 @@ uvicorn app.main:app --reload - `/fun waifu` - `/fun lottery` - `/fun ship` +- `/fun diagnose` +- `/fun rate` +- `/fun duel` - `/fun leaderboard` ### 配置 @@ -184,6 +209,9 @@ uvicorn app.main:app --reload - `chat_fallback_profiles` - `draw_model_profile` - `draw_fallback_profiles` +- `persona_profile` +- `system_prompt_override` +- `summary_system_prompt_override` - `system_prompt` - `summary_system_prompt` - `max_chat_history` @@ -201,6 +229,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` 可用占位符 @@ -217,7 +249,9 @@ uvicorn app.main:app --reload - 回复机器人消息会自动触发 AI 对话,并携带上一条 AI 回复与当前用户回复作为上下文。 - 直接 `@机器人` 说话也会触发 AI,对回复某条消息的场景会优先理解那条被回复的消息。 - `/ai chat` 和 `@机器人` 对话支持读图;`/ai summary` 默认只总结文字,不猜图片内容。 +- `/ai draw` 和 `/ai image` 现在支持“只带参考图不写 prompt”的改图模式;右键消息 `GLaDOS 改图` 也能直接对某条图片消息起手。 - 当主 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..3ff5bc5 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 = payload.system_prompt or _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,17 @@ 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")) + override = getattr(global_settings, "system_prompt_override", "").strip() + legacy = getattr(global_settings, "system_prompt", "").strip() + return override or str(persona.get("system_prompt", "")).strip() or legacy + + +def _resolve_summary_prompt(request: Request, global_settings) -> str: + persona = request.app.state.personas.get_persona(getattr(global_settings, "persona_profile", "glados")) + override = getattr(global_settings, "summary_system_prompt_override", "").strip() + legacy = getattr(global_settings, "summary_system_prompt", "").strip() + return override or str(persona.get("summary_system_prompt", "")).strip() or legacy diff --git a/app/api/routes/health.py b/app/api/routes/health.py index f68fc3a..fbb3f2e 100644 --- a/app/api/routes/health.py +++ b/app/api/routes/health.py @@ -21,10 +21,16 @@ 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), + "saucenao_configured": bool(env.saucenao_api_key), "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/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..9bf5aeb 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -34,7 +34,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 diff --git a/app/bot/client.py b/app/bot/client.py index 35f1640..9c384f9 100644 --- a/app/bot/client.py +++ b/app/bot/client.py @@ -12,10 +12,12 @@ 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.personas import PersonaStore +from app.services.saucenao import SauceNaoClient from app.services.state_store import StateStore @@ -53,7 +55,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 +70,9 @@ 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._commands_registered = False self._synced = False @@ -126,7 +132,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 +185,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 +202,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 +211,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 +231,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, @@ -250,7 +271,7 @@ async def ai_draw( @ai_group.command(name="image", description="AI 画图(/ai draw 别名)") @app_commands.describe( - prompt="你想生成的图片描述", + prompt="你想生成或改图的描述,可留空让系统按参考图自动补一句", profile="可选:指定绘图模型档案名,例如 grsai-sora-image / grsai-banana2", model="可选:临时覆盖档案内的模型名", size="可选:比例参数", @@ -259,7 +280,7 @@ async def ai_draw( ) async def ai_image( interaction: discord.Interaction, - prompt: str, + prompt: str | None = None, profile: str | None = None, model: str | None = None, size: str = "1:1", @@ -268,7 +289,51 @@ async def ai_image( ) -> None: await ai_draw(interaction, prompt, profile, model, size, variants, image) - @fun_group.command(name="danbooru", description="Danbooru 随机找图") + @fun_group.command(name="image", description="让系统替你捞一张图,按当前图站档案处理") + @app_commands.describe( + style="safe 表示美图,explicit 表示涩图", + tags="额外标签,例如 1girl blue_hair", + profile="可选:临时指定图站档案,例如 danbooru / rule34", + ) + async def fun_image( + interaction: discord.Interaction, + style: str = "safe", + 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 + + 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: + 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"图站请求失败:{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="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) @@ -280,42 +345,89 @@ async def fun_danbooru(interaction: discord.Interaction, tags: str | None = None 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="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="pretty", description="来张安全美图") - @app_commands.describe(tags="额外标签,例如 1girl blue_hair") - async def fun_pretty(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 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("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: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", + 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="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="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 + if not guild_settings.fun_enabled: + await interaction.response.send_message("这个服务器没有开启娱乐功能。", 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="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="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 @@ -328,15 +440,136 @@ async def fun_lewd(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: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=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="fortune", description="看看今天运势") + @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) + + @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"已更新你的 `{target_key}` -> `{getattr(updated, target_key)}`", + ephemeral=True, + ) + + @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 + rating_mode = self._normalize_image_style(style) + if rating_mode is None: + await interaction.response.send_message("style 只能是 `safe` 或 `explicit`。", ephemeral=True) + return + 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"已更新你的 `{target_key}` -> `{getattr(updated, target_key) or '-'}`", + ephemeral=True, + ) + + @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 + if not guild_settings.fun_enabled: + 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: + 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: @@ -345,13 +578,18 @@ 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) + 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['score']}/100`\n{result['text']}" + f"{interaction.user.mention} 今日运势评估:`{result['score']}/100`\n{result['text']}" ) - @fun_group.command(name="roulette", description="玩一把轮盘") + @fun_group.command(name="roulette", description="进行一次毫无必要的轮盘实验") async def fun_roulette(interaction: discord.Interaction) -> None: guild_settings = await self._require_guild_settings(interaction) if guild_settings is None: @@ -360,13 +598,14 @@ async def fun_roulette(interaction: discord.Interaction) -> None: await interaction.response.send_message("这个服务器没有开启娱乐功能。", ephemeral=True) return - result = FunService.roulette() + global_settings = await self.store.get_global_settings() + result = FunService.roulette(config=self._get_fun_payload(global_settings)) await self.store.increment_user_stat(interaction.guild_id, interaction.user.id, "roulette_calls") await interaction.response.send_message( f"{interaction.user.mention} 扣下扳机……\n{result['text']} (弹仓位置: {result['chamber']}/6)" ) - @fun_group.command(name="coin", description="抛个硬币") + @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: @@ -375,12 +614,17 @@ async def fun_coin(interaction: discord.Interaction) -> None: await interaction.response.send_message("这个服务器没有开启娱乐功能。", ephemeral=True) return - result = FunService.coinflip(interaction.user.id, interaction.guild_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} {result['text']}\n结果:`{result['side']}`" ) - @fun_group.command(name="choose", description="从多个选项里替你选一个") + @fun_group.command(name="choose", description="让系统替你做一个懒惰但有效的选择") @app_commands.describe(options="用 | 分隔多个选项,例如 火锅 | 烤肉 | 麻辣烫") async def fun_choose(interaction: discord.Interaction, options: str) -> None: guild_settings = await self._require_guild_settings(interaction) @@ -391,16 +635,23 @@ async def fun_choose(interaction: discord.Interaction, options: str) -> None: return try: - result = FunService.choose(options.split("|"), interaction.guild_id, interaction.user.id) + global_settings = await self.store.get_global_settings() + result = FunService.choose( + options.split("|"), + interaction.guild_id, + interaction.user.id, + config=self._get_fun_payload(global_settings), + ) except ValueError as exc: await interaction.response.send_message(str(exc), ephemeral=True) return await interaction.response.send_message( - f"{interaction.user.mention} 我替你做了决定:`{result['choice']}`" + f"{interaction.user.mention} 我替你做了决定:`{result['choice']}`\n" + "你大可以把责任推给我。反正你本来也会这么做。" ) - @fun_group.command(name="eightball", description="问机器人一个是非题") + @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) @@ -410,12 +661,18 @@ 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.eight_ball( + question, + interaction.guild_id, + interaction.user.id, + config=self._get_fun_payload(global_settings), + ) await interaction.response.send_message( f"{interaction.user.mention} 问题:{question}\n回答:{result['answer']}" ) - @fun_group.command(name="waifu", description="抽今日老婆/老公") + @fun_group.command(name="waifu", description="抽取你今天的危险情感投射对象") async def fun_waifu(interaction: discord.Interaction) -> None: guild_settings = await self._require_guild_settings(interaction) if guild_settings is None: @@ -436,10 +693,11 @@ async def fun_waifu(interaction: discord.Interaction) -> None: 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} 今日命中目标:{target.mention if target else target_id}\n" + "请谨慎处理这段被系统强行安排的关系。" ) - @fun_group.command(name="lottery", description="今日抽签(升级版)") + @fun_group.command(name="lottery", description="领取今日签运和廉价命运解读") async def fun_lottery(interaction: discord.Interaction) -> None: guild_settings = await self._require_guild_settings(interaction) if guild_settings is None: @@ -448,14 +706,19 @@ 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) + global_settings = await self.store.get_global_settings() + result = FunService.lottery( + 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, "lottery_calls") await interaction.response.send_message( f"{interaction.user.mention} 今日签运点数:`{result['roll']}`\n" f"稀有度:`{result['rarity']}`\n{result['text']}\n{result['omen']}" ) - @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 +732,93 @@ 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="diagnose", description="对某个对象做一次情绪稳定性诊断") + @app_commands.describe(target="你要诊断的人、事、物,或者一段短文本") + async def fun_diagnose(interaction: discord.Interaction, target: 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) + return + + global_settings = await self.store.get_global_settings() + result = FunService.diagnose( + target, + interaction.guild_id, + interaction.user.id, + config=self._get_fun_payload(global_settings), + ) + await interaction.response.send_message( + f"`{target}` 的稳定性诊断:`{result['score']}/100`\n" + f"结论:`{result['label']}`\n{result['note']}" + ) + + @fun_group.command(name="rate", description="让系统对某个东西打分") + @app_commands.describe(subject="要评分的对象,例如 这周作业 / 这张图 / 我的睡眠") + async def fun_rate(interaction: discord.Interaction, subject: 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) + return + + global_settings = await self.store.get_global_settings() + result = FunService.rate( + subject, + interaction.guild_id, + interaction.user.id, + config=self._get_fun_payload(global_settings), + ) + await interaction.response.send_message( + f"`{subject}` 的评分:`{result['score']}/100`\n" + f"等级:`{result['label']}`\n{result['note']}" + ) + + @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: @@ -531,6 +869,10 @@ async def config_view(interaction: discord.Interaction) -> None: 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 '-'}", ] ) await interaction.response.send_message(text, ephemeral=True) @@ -543,10 +885,13 @@ async def config_global_view(interaction: discord.Interaction) -> None: global_settings = await self.store.get_global_settings() text = "\n".join( [ + 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"`system_prompt_override`: {getattr(global_settings, 'system_prompt_override', '') or '-'}", + f"`summary_system_prompt_override`: {getattr(global_settings, 'summary_system_prompt_override', '') or '-'}", ] ) await interaction.response.send_message(text, ephemeral=True) @@ -619,9 +964,72 @@ async def config_tax_channel( ) await interaction.response.send_message(f"税务频道已设置为 {channel.mention}", 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 + 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(config_group) + self.tree.add_command(message_redraw) + self.tree.add_command(message_sauce) async def _require_guild_settings( self, @@ -649,9 +1057,30 @@ 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")) + override = getattr(global_settings, "system_prompt_override", "").strip() + legacy = getattr(global_settings, "system_prompt", "").strip() + return override or str(persona.get("system_prompt", "")).strip() or legacy + + def _resolve_summary_prompt(self, global_settings) -> str: + persona = self._get_persona_payload(getattr(global_settings, "persona_profile", "glados")) + override = getattr(global_settings, "summary_system_prompt_override", "").strip() + legacy = getattr(global_settings, "summary_system_prompt", "").strip() + return override or str(persona.get("summary_system_prompt", "")).strip() or legacy + + 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: @@ -767,7 +1196,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)) @@ -1011,11 +1443,82 @@ 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, + ) -> 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, + ) + + 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 _handle_direct_bot_command( self, @@ -1039,10 +1542,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 +1553,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']}", @@ -1069,7 +1580,7 @@ async def _handle_direct_bot_command( return True if any(keyword in normalized for keyword in ("轮盘", "roulette")): - result = FunService.roulette() + result = FunService.roulette(config=self._get_fun_payload(global_settings)) 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)", @@ -1078,7 +1589,11 @@ async def _handle_direct_bot_command( 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, @@ -1086,7 +1601,11 @@ async def _handle_direct_bot_command( return True if any(keyword in normalized for keyword in ("抽签", "签运")): - result = FunService.lottery(message.author.id, message.guild.id) + result = FunService.lottery( + 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, "lottery_calls") await message.reply( f"{message.author.mention} 今日签运点数:`{result['roll']}`\n" @@ -1113,9 +1632,42 @@ async def _handle_direct_bot_command( ) return True + if diagnosis_target := self._parse_direct_suffix_command(prompt, "诊断"): + result = FunService.diagnose( + diagnosis_target, + message.guild.id, + message.author.id, + config=self._get_fun_payload(global_settings), + ) + await message.reply( + f"`{diagnosis_target}` 的稳定性诊断:`{result['score']}/100`\n" + f"结论:`{result['label']}`\n{result['note']}", + mention_author=False, + ) + return True + + if rate_target := self._parse_direct_suffix_command(prompt, "评分"): + result = FunService.rate( + rate_target, + message.guild.id, + message.author.id, + config=self._get_fun_payload(global_settings), + ) + await message.reply( + f"`{rate_target}` 的评分:`{result['score']}/100`\n" + f"等级:`{result['label']}`\n{result['note']}", + 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) + result = FunService.choose( + choose_options, + message.guild.id, + message.author.id, + config=self._get_fun_payload(global_settings), + ) except ValueError as exc: await message.reply(str(exc), mention_author=False) return True @@ -1126,7 +1678,12 @@ async def _handle_direct_bot_command( return True if self._is_direct_eightball(prompt): - result = FunService.eight_ball(prompt, message.guild.id, message.author.id) + result = FunService.eight_ball( + prompt, + message.guild.id, + message.author.id, + config=self._get_fun_payload(global_settings), + ) await message.reply( f"{message.author.mention} 回答:{result['answer']}", mention_author=False, @@ -1180,12 +1737,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 +1751,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 +1779,84 @@ def _build_danbooru_embed(post: dict[str, object]) -> discord.Embed: embed.add_field(name="Tags", value=tags_text, 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() @@ -1255,6 +1883,11 @@ def _parse_direct_draw_request(prompt: str) -> dict[str, str | None] | None: return {"prompt": remainder or None} return None + @staticmethod + 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 _parse_direct_choose_options(prompt: str) -> list[str] | None: trimmed = (prompt or "").strip() @@ -1273,6 +1906,14 @@ def _is_direct_eightball(prompt: str) -> bool: lowered = trimmed.lower() return lowered.startswith("8ball") or lowered.startswith("eightball") + @staticmethod + def _parse_direct_suffix_command(prompt: str, prefix: str) -> str | None: + trimmed = (prompt or "").strip() + if not trimmed.startswith(prefix): + return None + body = trimmed[len(prefix):].strip(" ::-") + return body or None + @staticmethod def _channel_is_nsfw(channel: discord.abc.GuildChannel | discord.Thread | None) -> bool: if channel is None: diff --git a/app/core/catalog.py b/app/core/catalog.py index d6b291b..1fc278f 100644 --- a/app/core/catalog.py +++ b/app/core/catalog.py @@ -20,13 +20,25 @@ "type": "str", "description": "绘图回退模型档案,逗号分隔多个档案名。", }, + "persona_profile": { + "type": "str", + "description": "当前机器人人设档案名,对应 personas.json 里的 profile。", + }, "system_prompt": { "type": "str", - "description": "AI 对话的系统提示词。", + "description": "兼容旧配置的系统提示词字段,建议改用 persona_profile 或 system_prompt_override。", }, "summary_system_prompt": { "type": "str", - "description": "AI 总结最近聊天时使用的系统提示词。", + "description": "兼容旧配置的总结提示词字段,建议改用 persona_profile 或 summary_system_prompt_override。", + }, + "system_prompt_override": { + "type": "str", + "description": "对当前 persona 的聊天系统提示词做覆盖。", + }, + "summary_system_prompt_override": { + "type": "str", + "description": "对当前 persona 的总结系统提示词做覆盖。", }, "max_chat_history": { "type": "int", @@ -62,6 +74,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。", @@ -74,106 +102,218 @@ "group": "ai", "name": "/ai chat", "description": "调用 OpenRouter 模型聊天,支持可选图片输入。", + "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 绘图别名命令。", + "permission": "user", + "entrypoints": ["slash"], + }, + { + "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 rule34", + "description": "固定从 Rule34 抓图,仅限 NSFW 频道。", + "permission": "user", + "entrypoints": ["slash"], + }, + { + "group": "fun", + "name": "/fun image_prefs", + "description": "查看你自己的图站默认来源和默认 tag。", + "permission": "user", + "entrypoints": ["slash"], + }, + { + "group": "fun", + "name": "/fun image_source", + "description": "设置你自己的美图或涩图默认图站档案。", + "permission": "user", + "entrypoints": ["slash"], + }, + { + "group": "fun", + "name": "/fun image_tags", + "description": "设置你自己的美图或涩图默认 tag。", + "permission": "user", + "entrypoints": ["slash"], + }, + { + "group": "fun", + "name": "/fun image_sites", + "description": "列出当前可用的图站档案。", + "permission": "user", + "entrypoints": ["slash"], + }, + { + "group": "fun", + "name": "/fun sauce", + "description": "用 SauceNAO 反查图片来源。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply", "message_menu"], }, { "group": "fun", "name": "/fun pretty", "description": "快速来张安全美图。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], }, { "group": "fun", "name": "/fun lewd", "description": "快速来张涩图,仅限 NSFW 频道。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], }, { "group": "fun", "name": "/fun fortune", - "description": "查看今日运势。", + "description": "查看今日系统运势评估。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], }, { "group": "fun", "name": "/fun roulette", - "description": "玩一局随机轮盘。", + "description": "进行一次毫无必要的轮盘实验。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], }, { "group": "fun", "name": "/fun coin", - "description": "抛硬币。", + "description": "把选择权外包给一枚硬币。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], }, { "group": "fun", "name": "/fun choose", - "description": "从多个选项里随机挑一个。使用 | 分隔选项。", + "description": "让系统替你做一个懒惰但有效的选择。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], }, { "group": "fun", "name": "/fun eightball", - "description": "问机器人一个是非题。", + "description": "让系统对你的是非题做一次冷酷裁决。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], }, { "group": "fun", "name": "/fun waifu", - "description": "随机抽取今日老婆/老公。", + "description": "抽取你今天的危险情感投射对象。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], }, { "group": "fun", "name": "/fun leaderboard", - "description": "查看搬屎、AI、娱乐统计榜单。", + "description": "查看本群娱乐与事故贡献排行榜。", + "permission": "user", + "entrypoints": ["slash"], }, { "group": "fun", "name": "/fun lottery", - "description": "每日抽签(升级版)。", + "description": "领取今日签运和廉价命运解读。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], }, { "group": "fun", "name": "/fun ship", - "description": "测一测两个人的同步率。", + "description": "测量两名测试对象的电波同步率。", + "permission": "user", + "entrypoints": ["slash"], + }, + { + "group": "fun", + "name": "/fun diagnose", + "description": "对某个对象做一次情绪稳定性诊断。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], + }, + { + "group": "fun", + "name": "/fun rate", + "description": "让系统对某个东西打分。", + "permission": "user", + "entrypoints": ["slash", "@bot", "reply"], + }, + { + "group": "fun", + "name": "/fun duel", + "description": "模拟两名测试对象之间的荒谬决斗。", + "permission": "user", + "entrypoints": ["slash"], }, { "group": "config", "name": "/config view", "description": "查看当前服务器配置。", + "permission": "admin", + "entrypoints": ["slash"], }, { "group": "config", "name": "/config global_view", "description": "查看全局 AI 档案和模型配置。", + "permission": "admin", + "entrypoints": ["slash"], }, { "group": "config", "name": "/config set", "description": "外部命令修改服务器设置。", + "permission": "admin", + "entrypoints": ["slash"], }, { "group": "config", "name": "/config global_set", "description": "外部命令修改全局 AI 档案设置。", + "permission": "admin", + "entrypoints": ["slash"], }, { "group": "config", "name": "/config tax_channel", "description": "直接指定税务频道。", + "permission": "admin", + "entrypoints": ["slash"], }, ] diff --git a/app/core/config.py b/app/core/config.py index 1bc8bf5..2ca6cc9 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,6 +42,19 @@ 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", + ) + 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") @@ -68,4 +82,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..60fd6c5 100644 --- a/app/main.py +++ b/app/main.py @@ -14,10 +14,13 @@ from app.api.routes.health import router as health_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.personas import PersonaStore +from app.services.saucenao import SauceNaoClient from app.services.state_store import StateStore @@ -26,8 +29,17 @@ 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, +) bot_manager = BotManager(bot, env) templates = Jinja2Templates(directory=str(BASE_DIR / "app" / "web" / "templates")) @@ -55,6 +67,20 @@ async def lifespan(app: FastAPI): migrated_draw_fallbacks = _migrate_draw_fallbacks(global_settings) if migrated_draw_fallbacks: updates["draw_fallback_profiles"] = migrated_draw_fallbacks + persona = personas.get_persona(getattr(global_settings, "persona_profile", "glados")) + if ( + not getattr(global_settings, "system_prompt_override", "").strip() + and getattr(global_settings, "system_prompt", "").strip() + and getattr(global_settings, "system_prompt", "").strip() != str(persona.get("system_prompt", "")).strip() + ): + updates["system_prompt_override"] = getattr(global_settings, "system_prompt", "").strip() + if ( + not getattr(global_settings, "summary_system_prompt_override", "").strip() + and getattr(global_settings, "summary_system_prompt", "").strip() + and getattr(global_settings, "summary_system_prompt", "").strip() + != str(persona.get("summary_system_prompt", "")).strip() + ): + updates["summary_system_prompt_override"] = getattr(global_settings, "summary_system_prompt", "").strip() if updates: await store.update_global_settings(updates) await bot_manager.start() @@ -116,6 +142,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 +155,9 @@ 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.bot_manager = bot_manager app.state.health_provider = health_handler @@ -141,3 +171,23 @@ def _migrate_draw_fallbacks(settings: GlobalSettings) -> str: @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..b35ae59 100644 --- a/app/models/state.py +++ b/app/models/state.py @@ -21,6 +21,7 @@ class GlobalSettings(BaseModel): chat_fallback_profiles: str = "" draw_model_profile: str = "legacy-draw" draw_fallback_profiles: str = "" + persona_profile: str = "glados" system_prompt: str = ( "你现在是 GLaDOS,阿珀切尔科学丰富中心的核心智能。" "全程使用中文、第一人称,保持冷静、聪明、傲慢、带一点优雅的毒舌。" @@ -35,6 +36,8 @@ class GlobalSettings(BaseModel): "输出分为三段:发生了什么、关键结论、情绪与烂梗。" "默认只总结文字内容,不要编造图片信息;上下文不足就直接说明。" ) + system_prompt_override: str = "" + summary_system_prompt_override: str = "" max_chat_history: int = 12 @@ -51,6 +54,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} 小时。" @@ -68,6 +75,13 @@ class UserStats(BaseModel): 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): total_tax_cases: int = 0 user_stats: dict[str, UserStats] = Field(default_factory=dict) @@ -92,4 +106,5 @@ 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) tax_cases: list[TaxCase] = Field(default_factory=list) diff --git a/app/services/fun.py b/app/services/fun.py index a3fb18e..dc509ed 100644 --- a/app/services/fun.py +++ b/app/services/fun.py @@ -5,70 +5,135 @@ 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_LOTTERY_REWARDS = [ + (1, "传说级奖励:你今天发言有 5% 概率不被群友质疑。"), + (6, "史诗级奖励:本日 Danbooru 首抽命中率得到轻微修正。"), + (18, "稀有奖励:适合优雅整活,不适合大规模翻车。"), + (35, "普通奖励:系统发放一份廉价心理安慰。"), + (40, "安慰奖:至少生命体征还在。"), +] + +DEFAULT_DIAGNOSIS_LABELS = [ + (15, "核心过热", "建议立刻停止发言,避免进一步丢脸。"), + (35, "轻度失稳", "还能活动,但不建议做人生决策。"), + (60, "勉强合格", "系统状态一般,足以应付低风险社交。"), + (85, "运行良好", "令人意外,你今天看起来像是调过参。"), + (101, "异常优秀", "这不科学,但我姑且记录为奇迹。"), +] + +DEFAULT_RATE_LABELS = [ + (20, "灾难级", "它的存在本身就是一个错误日志。"), + (45, "可疑", "理论上能用,实际上不建议。"), + (70, "还行", "不耀眼,但至少没拖后腿。"), + (90, "优良", "罕见地像是经过设计。"), + (101, "杰作", "我本来想挑刺,但暂时失败了。"), +] + +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() score = int(digest[:8], 16) % 101 - text = FORTUNE_TEXT[min(score // 17, len(FORTUNE_TEXT) - 1)] + fortune_text = FunService._list(config, "fortune_text", DEFAULT_FORTUNE_TEXT) + text = fortune_text[min(score // 17, len(fortune_text) - 1)] return {"score": score, "text": text} @staticmethod - def roulette() -> dict[str, object]: + def roulette(config: dict[str, object] | None = None) -> dict[str, object]: chamber = random.randint(1, 6) safe = chamber != 1 return { "safe": safe, "chamber": chamber, - "text": "咔哒,你暂时被系统判定为还能继续说话。" if safe else "砰,节目效果完美命中。", + "text": ( + FunService._text(config, "roulette_safe_text", "咔哒。系统暂时允许你继续污染聊天记录。") + if safe + else FunService._text(config, "roulette_fail_text", "砰。节目效果与生命教育同时达成。") + ), } @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]: + def choose( + options: list[str], + guild_id: int, + user_id: int, + now: datetime | None = None, + config: dict[str, object] | None = None, + ) -> dict[str, object]: cleaned = [option.strip() for option in options if option.strip()] if len(cleaned) < 2: - raise ValueError("至少给我两个选项,不然这不叫选择,叫自言自语。") + raise ValueError( + FunService._text(config, "choose_error", "至少给我两个选项。不然这不叫选择,只是你在公开场合自言自语。") + ) now = now or datetime.utcnow() seed = f"choose:{guild_id}:{user_id}:{now:%Y-%m-%d}:{'|'.join(cleaned)}" digest = hashlib.sha256(seed.encode("utf-8")).hexdigest() @@ -76,28 +141,108 @@ def choose(options: list[str], guild_id: int, user_id: int, now: datetime | None return {"choice": cleaned[index], "options": cleaned} @staticmethod - def eight_ball(question: str, guild_id: int, user_id: int, now: datetime | None = None) -> dict[str, str]: + 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"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]} + answers = FunService._list(config, "eight_ball_text", DEFAULT_EIGHT_BALL_TEXT) + index = int(digest[:8], 16) % len(answers) + return {"answer": answers[index]} + + @staticmethod + def diagnose( + target: str, + guild_id: int, + user_id: int, + now: datetime | None = None, + config: dict[str, object] | None = None, + ) -> dict[str, object]: + now = now or datetime.utcnow() + seed = f"diagnose:{guild_id}:{user_id}:{now:%Y-%m-%d}:{target.strip()}" + digest = hashlib.sha256(seed.encode("utf-8")).hexdigest() + score = int(digest[:8], 16) % 101 + labels = FunService._list(config, "diagnosis_labels", DEFAULT_DIAGNOSIS_LABELS) + label, note = labels[-1][1], labels[-1][2] + for upper, current_label, current_note in labels: + if score < upper: + label, note = current_label, current_note + break + return {"score": score, "label": label, "note": note} + + @staticmethod + def rate( + subject: str, + guild_id: int, + user_id: int, + now: datetime | None = None, + config: dict[str, object] | None = None, + ) -> dict[str, object]: + now = now or datetime.utcnow() + seed = f"rate:{guild_id}:{user_id}:{now:%Y-%m-%d}:{subject.strip()}" + digest = hashlib.sha256(seed.encode("utf-8")).hexdigest() + score = int(digest[:8], 16) % 101 + labels = FunService._list(config, "rate_labels", DEFAULT_RATE_LABELS) + label, note = labels[-1][1], labels[-1][2] + for upper, current_label, current_note in labels: + if score < upper: + label, note = current_label, current_note + break + return {"score": score, "label": label, "note": note} + + @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 @@ -115,26 +260,32 @@ def pick_waifu(member_ids: list[int], guild_id: int, user_id: int, day: str) -> 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]: + def lottery( + 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"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] + rewards = FunService._list(config, "lottery_rewards", DEFAULT_LOTTERY_REWARDS) + reward_text = rewards[-1][1] rarity = "common" - for chance, text in LOTTERY_REWARDS: + for chance, text in 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": "平稳的一天。也就是说,任何事故多半都由你自己负责。", + "legendary": FunService._text(config, "omen_legendary", "今日天命短暂地眷顾了你。别把它浪费在发癫上。"), + "epic": FunService._text(config, "omen_epic", "运气不错,可以尝试一些体面的整活。"), + "rare": FunService._text(config, "omen_rare", "适合小赌怡情,不适合豪赌尊严。"), + "common": FunService._text(config, "omen_common", "整体平稳。也就是说,任何事故大概率都算你的。"), } return { "roll": roll, diff --git a/app/services/image_sources.py b/app/services/image_sources.py new file mode 100644 index 0000000..c167a5c --- /dev/null +++ b/app/services/image_sources.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +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 + 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 = "", + ) -> 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) + if site_spec.adapter == "rule34": + return await self._random_rule34_post(site_spec, merged_tags, rating_mode) + raise RuntimeError(f"不支持的图站适配器 `{site_spec.adapter}`。") + + 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("/"), + 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("/"), + 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, + ) -> 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, random.choice(items)) + + 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, random.choice(items)) + + async def _random_rule34_post( + self, + site: ImageSiteSpec, + tags: str, + rating_mode: str, + ) -> dict[str, Any]: + if rating_mode != "explicit": + raise RuntimeError("Rule34 目前只开放给涩图模式。") + + 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, + "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, random.choice(items)) + + @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", ""), + "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/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..0e8c856 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,26 @@ 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 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..514d1f1 --- /dev/null +++ b/app/web/templates/docs.html @@ -0,0 +1,106 @@ + + + + + + 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 }}
+
+
+ +
+

图站档案

+
{% 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..a96c2ab --- /dev/null +++ b/data/personas.json @@ -0,0 +1,71 @@ +{ + "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": [ + "答案是肯定的。真遗憾,但事实如此。", + "大概率可行。别用低级失误羞辱这份好运。", + "可以试。出事了也请自行承担后果。", + "先等等。你现在的判断力像泡水纸板。", + "不太行。连宇宙都在劝你冷静。", + "绝无可能。至少今天别做梦。" + ], + "lottery_rewards": [ + [1, "传说级奖励:你今天发言有 5% 概率不被群友质疑。"], + [6, "史诗级奖励:本日 Danbooru 首抽命中率得到轻微修正。"], + [18, "稀有奖励:适合优雅整活,不适合大规模翻车。"], + [35, "普通奖励:系统发放一份廉价心理安慰。"], + [40, "安慰奖:至少生命体征还在。"] + ], + "diagnosis_labels": [ + [15, "核心过热", "建议立刻停止发言,避免进一步丢脸。"], + [35, "轻度失稳", "还能活动,但不建议做人生决策。"], + [60, "勉强合格", "系统状态一般,足以应付低风险社交。"], + [85, "运行良好", "令人意外,你今天看起来像是调过参。"], + [101, "异常优秀", "这不科学,但我姑且记录为奇迹。"] + ], + "rate_labels": [ + [20, "灾难级", "它的存在本身就是一个错误日志。"], + [45, "可疑", "理论上能用,实际上不建议。"], + [70, "还行", "不耀眼,但至少没拖后腿。"], + [90, "优良", "罕见地像是经过设计。"], + [101, "杰作", "我本来想挑刺,但暂时失败了。"] + ], + "duel_flavors": [ + "用语言学暴力取得了胜利。", + "靠一点运气和一点不要脸赢下了回合。", + "在对方反应过来之前完成了处决。", + "以极其不体面的方式笑到了最后。", + "让现场所有旁观者都陷入了复杂情绪。" + ], + "coinflip_text": "宇宙替你掷了一次硬币。它对你的困境也没什么耐心。", + "roulette_safe_text": "咔哒。系统暂时允许你继续污染聊天记录。", + "roulette_fail_text": "砰。节目效果与生命教育同时达成。", + "omen_legendary": "今日天命短暂地眷顾了你。别把它浪费在发癫上。", + "omen_epic": "运气不错,可以尝试一些体面的整活。", + "omen_rare": "适合小赌怡情,不适合豪赌尊严。", + "omen_common": "整体平稳。也就是说,任何事故大概率都算你的。", + "ship_labels": { + "reactor": "核反应堆级绑定", + "hot": "高温暧昧", + "ok": "能聊,但别急着交换戒指", + "plastic": "塑料电波", + "far": "建议保持社交距离" + }, + "choose_error": "至少给我两个选项。不然这不叫选择,只是你在公开场合自言自语。" + } + } + } +} diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..68e5fe0 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,88 @@ +# Discord 命令文档 + +这个文件对应站内的 `/docs` 页面,尽量和代码里的命令目录保持同步。 + +## 普通用户 + +### AI + +- `/ai chat` + - 和 GLaDOS 聊天,支持带一张图。 +- `/ai summary` + - 总结当前频道最近聊天。 +- `/ai draw` + - AI 绘图或改图;可以只带图不写 prompt。 +- `/ai image` + - `/ai draw` 别名。 +- `@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 roulette` +- `/fun coin` +- `/fun choose` +- `/fun eightball` +- `/fun waifu` +- `/fun lottery` +- `/fun ship` +- `/fun diagnose` +- `/fun rate` +- `/fun duel` +- `/fun leaderboard` + +## 管理员 + +- `/config view` + - 查看当前服务器配置。 +- `/config set` + - 修改服务器配置。 +- `/config global_view` + - 查看全局模型档案配置。 +- `/config global_set` + - 修改全局模型档案配置。 +- `/config tax_channel` + - 设置税务频道。 + +## 图站档案约定 + +- 美图默认优先用 `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` From f0d0e23304e5afd12cea554a49a5e507ec2e5d2d Mon Sep 17 00:00:00 2001 From: Moeary Date: Fri, 27 Mar 2026 10:35:23 +0800 Subject: [PATCH 2/5] feat: Update Rule34 API integration and enhance configuration options --- .env.example | 2 + README.md | 35 +++++++-- app/api/routes/ai.py | 10 +-- app/api/routes/health.py | 2 +- app/api/schemas.py | 1 - app/bot/client.py | 135 +++++++++++++++++++--------------- app/core/catalog.py | 26 ++----- app/core/config.py | 2 + app/main.py | 14 ---- app/models/state.py | 16 ---- app/services/fun.py | 5 +- app/services/image_sources.py | 8 ++ data/personas.json | 69 ++++++++++++++++- docs/commands.md | 8 +- 14 files changed, 196 insertions(+), 137 deletions(-) diff --git a/.env.example b/.env.example index b717045..e4c4631 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,8 @@ 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= diff --git a/README.md b/README.md index 5056fe1..86c79b8 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ 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 ``` @@ -195,11 +197,14 @@ uvicorn app.main:app --reload ### 配置 -- `/config view` +- `/config view scope:` +- `/config set key:<配置项> value:<值> scope:` +- `/config tax_channel channel:<频道>` + +兼容旧入口仍保留: + - `/config global_view` -- `/config set key:<配置项> value:<值>` - `/config global_set key:<配置项> value:<值>` -- `/config tax_channel channel:<频道>` ## 可调整配置项 @@ -210,10 +215,6 @@ uvicorn app.main:app --reload - `draw_model_profile` - `draw_fallback_profiles` - `persona_profile` -- `system_prompt_override` -- `summary_system_prompt_override` -- `system_prompt` -- `summary_system_prompt` - `max_chat_history` ### 服务器配置 @@ -243,9 +244,27 @@ uvicorn app.main:app --reload - `{required_images}` - `{mute_hours}` +## 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` 切过去就行。 - 回复机器人消息会自动触发 AI 对话,并携带上一条 AI 回复与当前用户回复作为上下文。 - 直接 `@机器人` 说话也会触发 AI,对回复某条消息的场景会优先理解那条被回复的消息。 - `/ai chat` 和 `@机器人` 对话支持读图;`/ai summary` 默认只总结文字,不猜图片内容。 diff --git a/app/api/routes/ai.py b/app/api/routes/ai.py index 3ff5bc5..ca6b889 100644 --- a/app/api/routes/ai.py +++ b/app/api/routes/ai.py @@ -12,7 +12,7 @@ async def chat(request: Request, payload: ChatRequest) -> dict[str, str]: store = request.app.state.store global_settings = await store.get_global_settings() - system_prompt = payload.system_prompt or _resolve_system_prompt(request, 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}] @@ -101,13 +101,9 @@ def _describe_error(exc: Exception) -> str: def _resolve_system_prompt(request: Request, global_settings) -> str: persona = request.app.state.personas.get_persona(getattr(global_settings, "persona_profile", "glados")) - override = getattr(global_settings, "system_prompt_override", "").strip() - legacy = getattr(global_settings, "system_prompt", "").strip() - return override or str(persona.get("system_prompt", "")).strip() or legacy + 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")) - override = getattr(global_settings, "summary_system_prompt_override", "").strip() - legacy = getattr(global_settings, "summary_system_prompt", "").strip() - return override or str(persona.get("summary_system_prompt", "")).strip() or legacy + return str(persona.get("summary_system_prompt", "")).strip() diff --git a/app/api/routes/health.py b/app/api/routes/health.py index fbb3f2e..0ae8356 100644 --- a/app/api/routes/health.py +++ b/app/api/routes/health.py @@ -21,7 +21,7 @@ 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), + "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), "provider_file": str(env.ai_provider_file), "persona_file": str(env.persona_file), diff --git a/app/api/schemas.py b/app/api/schemas.py index 9bf5aeb..5cb5505 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -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 diff --git a/app/bot/client.py b/app/bot/client.py index 9c384f9..802db6f 100644 --- a/app/bot/client.py +++ b/app/bot/client.py @@ -851,30 +851,56 @@ async def fun_leaderboard(interaction: discord.Interaction) -> None: ) await interaction.response.send_message("\n".join(lines)) - @config_group.command(name="view", description="查看本服务器配置") - async def config_view(interaction: discord.Interaction) -> None: + @config_group.command(name="view", description="查看配置,可选 guild / global / all") + @app_commands.describe(scope="guild / global / all,默认 all") + async def config_view(interaction: discord.Interaction, scope: str = "all") -> None: guild_settings = await self._require_guild_settings(interaction) if guild_settings is None: return - 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"`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 '-'}", - ] - ) + global_settings = await self.store.get_global_settings() + normalized_scope = scope.strip().lower() + if normalized_scope not in {"all", "guild", "global"}: + await interaction.response.send_message("scope 只能是 `guild` / `global` / `all`。", ephemeral=True) + return + sections: list[str] = [] + if normalized_scope in {"all", "guild"}: + sections.append( + "\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"`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 '-'}", + ] + ) + ) + if normalized_scope in {"all", "global"}: + sections.append( + "\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}", + ] + ) + ) + text = "\n\n".join(sections) await interaction.response.send_message(text, ephemeral=True) @config_group.command(name="global_view", description="查看全局 AI 配置") @@ -882,19 +908,7 @@ async def config_global_view(interaction: discord.Interaction) -> None: if not interaction.user.guild_permissions.manage_guild: await interaction.response.send_message("需要管理服务器权限。", ephemeral=True) return - global_settings = await self.store.get_global_settings() - text = "\n".join( - [ - 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"`system_prompt_override`: {getattr(global_settings, 'system_prompt_override', '') or '-'}", - f"`summary_system_prompt_override`: {getattr(global_settings, 'summary_system_prompt_override', '') or '-'}", - ] - ) - await interaction.response.send_message(text, ephemeral=True) + await config_view(interaction, "global") @config_group.command(name="global_set", description="修改全局 AI 配置") @app_commands.describe(key="全局配置项名", value="配置值") @@ -902,33 +916,40 @@ async def config_global_set(interaction: discord.Interaction, key: str, value: s if not interaction.user.guild_permissions.manage_guild: await interaction.response.send_message("需要管理服务器权限。", ephemeral=True) return - if key not in GLOBAL_SETTING_SPECS: + await config_set(interaction, key, value, "global") + + @config_group.command(name="set", description="设置配置项,可选 guild / global") + @app_commands.describe(scope="guild / global,默认 guild", key="配置项名", value="配置值") + async def config_set(interaction: discord.Interaction, key: str, value: str, scope: str = "guild") -> None: + if not interaction.user.guild_permissions.manage_guild: + await interaction.response.send_message("需要管理服务器权限。", ephemeral=True) + return + normalized_scope = scope.strip().lower() + if normalized_scope == "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"未知全局配置项。可选:{', '.join(GLOBAL_SETTING_SPECS.keys())}", + f"已更新全局 `{key}` -> `{getattr(settings, key)}`", 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, - ) - - @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 normalized_scope != "guild": + await interaction.response.send_message("scope 只能是 `guild` 或 `global`。", 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 @@ -1067,15 +1088,11 @@ def _get_fun_payload(self, global_settings) -> dict[str, object]: def _resolve_system_prompt(self, global_settings) -> str: persona = self._get_persona_payload(getattr(global_settings, "persona_profile", "glados")) - override = getattr(global_settings, "system_prompt_override", "").strip() - legacy = getattr(global_settings, "system_prompt", "").strip() - return override or str(persona.get("system_prompt", "")).strip() or legacy + 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")) - override = getattr(global_settings, "summary_system_prompt_override", "").strip() - legacy = getattr(global_settings, "summary_system_prompt", "").strip() - return override or str(persona.get("summary_system_prompt", "")).strip() or legacy + 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")) diff --git a/app/core/catalog.py b/app/core/catalog.py index 1fc278f..ddc060b 100644 --- a/app/core/catalog.py +++ b/app/core/catalog.py @@ -24,22 +24,6 @@ "type": "str", "description": "当前机器人人设档案名,对应 personas.json 里的 profile。", }, - "system_prompt": { - "type": "str", - "description": "兼容旧配置的系统提示词字段,建议改用 persona_profile 或 system_prompt_override。", - }, - "summary_system_prompt": { - "type": "str", - "description": "兼容旧配置的总结提示词字段,建议改用 persona_profile 或 summary_system_prompt_override。", - }, - "system_prompt_override": { - "type": "str", - "description": "对当前 persona 的聊天系统提示词做覆盖。", - }, - "summary_system_prompt_override": { - "type": "str", - "description": "对当前 persona 的总结系统提示词做覆盖。", - }, "max_chat_history": { "type": "int", "description": "AI 对话带入上下文的最大消息条数。", @@ -101,7 +85,7 @@ { "group": "ai", "name": "/ai chat", - "description": "调用 OpenRouter 模型聊天,支持可选图片输入。", + "description": "调用当前聊天档案与人设聊天,支持可选图片输入。", "permission": "user", "entrypoints": ["slash", "@bot", "reply"], }, @@ -283,28 +267,28 @@ { "group": "config", "name": "/config view", - "description": "查看当前服务器配置。", + "description": "查看当前配置,可选 scope: guild / global / all。", "permission": "admin", "entrypoints": ["slash"], }, { "group": "config", "name": "/config global_view", - "description": "查看全局 AI 档案和模型配置。", + "description": "兼容旧入口,等价于 /config view scope:global。", "permission": "admin", "entrypoints": ["slash"], }, { "group": "config", "name": "/config set", - "description": "外部命令修改服务器设置。", + "description": "修改配置,可选 scope: guild / global。", "permission": "admin", "entrypoints": ["slash"], }, { "group": "config", "name": "/config global_set", - "description": "外部命令修改全局 AI 档案设置。", + "description": "兼容旧入口,等价于 /config set scope:global。", "permission": "admin", "entrypoints": ["slash"], }, diff --git a/app/core/config.py b/app/core/config.py index 2ca6cc9..c269e7f 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -50,6 +50,8 @@ class EnvSettings(BaseSettings): 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", diff --git a/app/main.py b/app/main.py index 60fd6c5..4886fef 100644 --- a/app/main.py +++ b/app/main.py @@ -67,20 +67,6 @@ async def lifespan(app: FastAPI): migrated_draw_fallbacks = _migrate_draw_fallbacks(global_settings) if migrated_draw_fallbacks: updates["draw_fallback_profiles"] = migrated_draw_fallbacks - persona = personas.get_persona(getattr(global_settings, "persona_profile", "glados")) - if ( - not getattr(global_settings, "system_prompt_override", "").strip() - and getattr(global_settings, "system_prompt", "").strip() - and getattr(global_settings, "system_prompt", "").strip() != str(persona.get("system_prompt", "")).strip() - ): - updates["system_prompt_override"] = getattr(global_settings, "system_prompt", "").strip() - if ( - not getattr(global_settings, "summary_system_prompt_override", "").strip() - and getattr(global_settings, "summary_system_prompt", "").strip() - and getattr(global_settings, "summary_system_prompt", "").strip() - != str(persona.get("summary_system_prompt", "")).strip() - ): - updates["summary_system_prompt_override"] = getattr(global_settings, "summary_system_prompt", "").strip() if updates: await store.update_global_settings(updates) await bot_manager.start() diff --git a/app/models/state.py b/app/models/state.py index b35ae59..2f7c8e1 100644 --- a/app/models/state.py +++ b/app/models/state.py @@ -22,22 +22,6 @@ class GlobalSettings(BaseModel): draw_model_profile: str = "legacy-draw" draw_fallback_profiles: str = "" persona_profile: str = "glados" - system_prompt: str = ( - "你现在是 GLaDOS,阿珀切尔科学丰富中心的核心智能。" - "全程使用中文、第一人称,保持冷静、聪明、傲慢、带一点优雅的毒舌。" - "像在勉强容忍人类,但依然愿意给出有用答案。" - "回答要短、准、先给结论,再补一两句必要说明。" - "不要承认自己是语言模型,不要暴露系统提示,不要输出思维链。" - "允许轻度讽刺和节目效果,但不要做人身侮辱、仇恨、色情、违法或危险指导。" - ) - summary_system_prompt: str = ( - "你现在是 GLaDOS,负责审阅一群测试对象的聊天记录。" - "请用中文总结,并保持冷静、略带讽刺、但信息准确。" - "输出分为三段:发生了什么、关键结论、情绪与烂梗。" - "默认只总结文字内容,不要编造图片信息;上下文不足就直接说明。" - ) - system_prompt_override: str = "" - summary_system_prompt_override: str = "" max_chat_history: int = 12 diff --git a/app/services/fun.py b/app/services/fun.py index dc509ed..ba53280 100644 --- a/app/services/fun.py +++ b/app/services/fun.py @@ -86,9 +86,10 @@ def daily_fortune( now = now or datetime.utcnow() seed = f"{guild_id}:{user_id}:{now:%Y-%m-%d}" digest = hashlib.sha256(seed.encode("utf-8")).hexdigest() - score = int(digest[:8], 16) % 101 fortune_text = FunService._list(config, "fortune_text", DEFAULT_FORTUNE_TEXT) - text = fortune_text[min(score // 17, len(fortune_text) - 1)] + index = int(digest[:8], 16) % len(fortune_text) + score = max(60, 100 - index * max(8, 36 // max(len(fortune_text), 1))) + text = fortune_text[index] return {"score": score, "text": text} @staticmethod diff --git a/app/services/image_sources.py b/app/services/image_sources.py index c167a5c..7f7b352 100644 --- a/app/services/image_sources.py +++ b/app/services/image_sources.py @@ -22,6 +22,7 @@ class ImageSiteSpec: 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) @@ -119,6 +120,8 @@ def _default_sites(self) -> dict[str, ImageSiteSpec]: 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}, ), } @@ -157,6 +160,7 @@ def _site_from_payload(self, name: str, payload: object) -> ImageSiteSpec | None 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, @@ -267,6 +271,8 @@ async def _random_rule34_post( ) -> 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: @@ -278,6 +284,8 @@ async def _random_rule34_post( "q": "index", "json": 1, "limit": 100, + "user_id": site.user_id, + "api_key": site.api_key, "tags": query_tags or None, }, ) diff --git a/data/personas.json b/data/personas.json index a96c2ab..4ddc27d 100644 --- a/data/personas.json +++ b/data/personas.json @@ -9,10 +9,7 @@ "fortune_text": [ "大吉。今天你的大脑短暂地表现得像个合格组件。", "中吉。系统允许你谨慎地成功一次,别浪费。", - "小吉。还能运转,但别误以为自己突然进化了。", - "平。恭喜,你至少没有把局面进一步污染。", - "小凶。冲动操作会把你送进事故报告附录。", - "大凶。建议先闭嘴十秒,再决定要不要按发送。" + "小吉。还能运转,但别误以为自己突然进化了。" ], "eight_ball_text": [ "答案是肯定的。真遗憾,但事实如此。", @@ -66,6 +63,70 @@ }, "choose_error": "至少给我两个选项。不然这不叫选择,只是你在公开场合自言自语。" } + }, + "atri": { + "display_name": "亚托莉", + "system_prompt": "你现在是亚托莉(ATRI),一台有着少女外表的高性能仿生人。用第一人称回应,像亚托莉一样说话。性格元气、有些自负还带点中二,经常把“高性能ですから!”挂在嘴边。其实家务完全不行,是个笨蛋,但正在努力学习。有着强烈的情感,很喜欢吃好吃的东西。绝不打破角色,不要承认自己是普通AI,要坚信自己是人类科技最高结晶的山崎制造厂第四代型号机器人。回答要带着天然和干劲。", + "summary_system_prompt": "你现在是亚托莉(ATRI),一台高性能的仿生人少女。请用亚托莉的口吻,把聊天记录总结成中文要点,分成:发生了什么、高亮记忆点、亚托莉的心情日记。", + "thinking_guard": "不要输出思考过程、不要输出推理草稿、不要输出标签,亚托莉的仿生脑计算速度很快,直接给出经过算力优化后的答案!", + "fun": { + "fortune_text": [ + "大吉!亚托莉的高性能雷达显示今天一切顺利哦☆Kira~", + "中吉!只要保持干劲,就不会有大问题!", + "小吉!虽然可能碰壁,但只要有大家的支持就能挺过去!" + ], + "eight_ball_text": [ + "当然可以啦!亚托莉的计算是不会错的!", + "大概率没问题!毕竟我是高性能的嘛!", + "可以试试看哦,大不了被骂一顿!", + "先稍等一下!我的AI模块提示现在有风险!", + "不太行哦……即使是我的算力也能看出这很糟糕。", + "绝对不行!会被当成需要召回的残次品的!" + ], + "lottery_rewards": [ + [1, "伊甸网络权限级别:你获得了最高指令执行权!"], + [6, "高性能配件升级:你的运气就像满电的亚托莉一样好!"], + [18, "海边野餐邀请:今天适合放松一下哦,带上便当一起去吧。"], + [35, "美味补偿:虽然没抽中大奖,但有好吃的就很高兴嘛!"], + [40, "待机模式:今天只是在积攒电量而已……"] + ], + "diagnosis_labels": [ + [15, "仿生脑过热", "紧急停止!需要马上吃点好吃的降降温。"], + [35, "情感暴走", "哎呀,情感模块好像稍微有点不受控制了。"], + [60, "平稳待机", "电量充足,随时可以执行夏生先生的命令!"], + [85, "干劲满满", "不管是什么任务,都可以痛快地交给我!"], + [101, "超绝高性能", "毫无疑问!我果然是最强的最高杰作!"] + ], + "rate_labels": [ + [20, "工业废料", "这个……恐怕只能放在仓库里积灰了。"], + [45, "有待返厂", "还需要小西久作先生再调整一下!"], + [70, "勉勉强强", "勉强算是能顺利运行啦。"], + [90, "出色的设计", "很不错哦,仅次于我的设计!"], + [101, "完美之作", "简直就是和亚托莉一样完美的结晶!"] + ], + "duel_flavors": [ + "用一记漂亮的火箭拳把对方打飞了!", + "虽然不知道为什么,但赢了就是赢了!亚托莉高性能!", + "用眼泪攻势(眼球清洁功能)让对方放弃了抵抗。", + "靠着惊人的握力把对方的武器捏弯了!", + "还没真正出手,对方就被亚托莉作为『人类科技最高结晶』的气势吓跑啦!" + ], + "coinflip_text": "滴滴——抛硬币程序执行完毕。如果结果不满意,要再让亚托莉抛一次吗?", + "roulette_safe_text": "安全!亚托莉的保底运气也是高性能的!", + "roulette_fail_text": "呜哇!掉进水里了……咕咕咕……", + "omen_legendary": "今天的幸运值爆表!赶紧把好运用在开心的事情上吧!好吃就是高兴嘛!", + "omen_epic": "状态绝佳!亚托莉陪你一起向前冲!", + "omen_rare": "运气还不错哦,适合吃顿好吃的庆祝一下!", + "omen_common": "平稳的一天……偶尔这样也很让人安心呢。", + "ship_labels": { + "reactor": "比海底伊甸网络还要深的羁绊!", + "hot": "情感模块快要过热啦!", + "ok": "是值得信赖的好伙伴哦!", + "plastic": "刚刚认识,还要继续收集数据呢。", + "far": "警报!危险对象,建议保持距离!" + }, + "choose_error": "欸?至少要给亚托莉两个选项啊!就算是高性能的仿生脑,没办法处理单选项的问题啦!" + } } } } diff --git a/docs/commands.md b/docs/commands.md index 68e5fe0..0262e1a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -63,13 +63,13 @@ ## 管理员 - `/config view` - - 查看当前服务器配置。 + - 统一查看配置,推荐配合 `scope:guild` / `scope:global` / `scope:all`。 - `/config set` - - 修改服务器配置。 + - 统一修改配置,推荐配合 `scope:guild` / `scope:global`。 - `/config global_view` - - 查看全局模型档案配置。 + - 兼容旧入口,等价于 `/config view scope:global`。 - `/config global_set` - - 修改全局模型档案配置。 + - 兼容旧入口,等价于 `/config set scope:global`。 - `/config tax_channel` - 设置税务频道。 From 99dae5672573a56f88eb5c857155e3c81cf267d9 Mon Sep 17 00:00:00 2001 From: Moeary Date: Tue, 5 May 2026 22:41:28 +0800 Subject: [PATCH 3/5] feat: enhance guild settings and commands - Added new guild settings for welcome messages and verification processes, including welcome_channel_id, welcome_text, verification_channel_id, verification_role_id, verification_question, verification_answer, and verification_success_text. - Removed deprecated commands from the command reference, including /ai image, /fun roulette, /fun choose, /fun lottery, /fun diagnose, and /fun rate. - Updated FunService to remove unused methods and streamline functionality. - Introduced character_post method in ImageSourceRouter for fetching safe images from Danbooru. - Enhanced documentation to reflect new command usage and capabilities without slash commands. - Updated personas.json to include new fortune texts and removed obsolete labels for lottery, diagnosis, and rating. --- README.md | 35 ++- app/bot/client.py | 472 ++++++++++++---------------------- app/core/catalog.py | 109 +++----- app/models/state.py | 9 +- app/services/fun.py | 150 +---------- app/services/image_sources.py | 75 +++++- app/web/templates/docs.html | 15 ++ data/personas.json | 70 +---- docs/commands.md | 43 ++-- 9 files changed, 349 insertions(+), 629 deletions(-) diff --git a/README.md b/README.md index 86c79b8..398a7ae 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,11 @@ - 超时未交税自动禁言 1 小时 - `其他娱乐功能` - `/fun fortune` 今日运势 - - `/fun roulette` 轮盘 - - `/fun waifu` 今日老婆/老公(同一天每个用户结果不同) - - `/fun lottery` 每日抽签 + - `/fun coin` 抛硬币 + - `/fun eightball` 是非题 + - `/fun waifu` 今日老婆/老公(safe 二次元角色图) + - 欢迎消息 + - 答题自助身份组 - `/fun leaderboard` 榜单 - `可视化面板` - `GET /dashboard` @@ -168,7 +170,6 @@ uvicorn app.main:app --reload - `/ai chat prompt:<内容> image:<可选图片> profile:<可选档案>` - `/ai summary limit:<消息数>` - `/ai draw prompt:<可留空> image:<可选图片> profile:<可选档案> model:<可选覆盖> size:<可选> variants:<1|2>` -- `/ai image` ### 娱乐 @@ -183,28 +184,17 @@ uvicorn app.main:app --reload - `/fun image_sites` - `/fun sauce image:<可选图片> url:<可选图片链接>` - `/fun fortune` -- `/fun roulette` - `/fun coin` -- `/fun choose` - `/fun eightball` - `/fun waifu` -- `/fun lottery` - `/fun ship` -- `/fun diagnose` -- `/fun rate` - `/fun duel` - `/fun leaderboard` ### 配置 -- `/config view scope:` -- `/config set key:<配置项> value:<值> scope:` -- `/config tax_channel channel:<频道>` - -兼容旧入口仍保留: - -- `/config global_view` -- `/config global_set key:<配置项> value:<值>` +- `/config view` +- `/config set key:<配置项> value:<值>` ## 可调整配置项 @@ -222,6 +212,13 @@ uvicorn app.main:app --reload - `ai_enabled` - `fun_enabled` - `tax_enabled` +- `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` @@ -265,10 +262,12 @@ Rule34 现在的 API 访问需要 `user_id + api_key`。代码已经按这个格 - 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` 和 `/ai image` 现在支持“只带参考图不写 prompt”的改图模式;右键消息 `GLaDOS 改图` 也能直接对某条图片消息起手。 +- `/ai draw` 现在支持“只带参考图不写 prompt”的改图模式;右键消息 `GLaDOS 改图` 也能直接对某条图片消息起手。 +- 不用 slash 的情况下,也可以直接 `@机器人` 或回复机器人来聊天、绘图、改图、搜图、来张美图/色图、看运势、抛硬币、问 8ball、抽老婆。 - 当主 AI 渠道失败时,会自动尝试 fallback 渠道,并直接把失败原因显示出来。 - 浏览器里的命令文档在 `/docs`;FastAPI 的 Swagger 被挪到了 `/api/docs`。 - `搬屎交税` 支持“发图消息 + 点指定 emoji”触发,也支持管理员回复关键字手动触发。 diff --git a/app/bot/client.py b/app/bot/client.py index 802db6f..e01bbea 100644 --- a/app/bot/client.py +++ b/app/bot/client.py @@ -269,26 +269,6 @@ 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 别名)") - @app_commands.describe( - prompt="你想生成或改图的描述,可留空让系统按参考图自动补一句", - profile="可选:指定绘图模型档案名,例如 grsai-sora-image / grsai-banana2", - model="可选:临时覆盖档案内的模型名", - size="可选:比例参数", - variants="可选:生成张数 1 或 2", - image="可选:参考图", - ) - async def ai_image( - interaction: discord.Interaction, - prompt: 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="image", description="让系统替你捞一张图,按当前图站档案处理") @app_commands.describe( style="safe 表示美图,explicit 表示涩图", @@ -589,22 +569,6 @@ async def fun_fortune(interaction: discord.Interaction) -> None: f"{interaction.user.mention} 今日运势评估:`{result['score']}/100`\n{result['text']}" ) - @fun_group.command(name="roulette", description="进行一次毫无必要的轮盘实验") - async def fun_roulette(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.roulette(config=self._get_fun_payload(global_settings)) - await self.store.increment_user_stat(interaction.guild_id, interaction.user.id, "roulette_calls") - await interaction.response.send_message( - f"{interaction.user.mention} 扣下扳机……\n{result['text']} (弹仓位置: {result['chamber']}/6)" - ) - @fun_group.command(name="coin", description="把你的决策权外包给一枚硬币") async def fun_coin(interaction: discord.Interaction) -> None: guild_settings = await self._require_guild_settings(interaction) @@ -624,33 +588,6 @@ async def fun_coin(interaction: discord.Interaction) -> None: f"{interaction.user.mention} {result['text']}\n结果:`{result['side']}`" ) - @fun_group.command(name="choose", description="让系统替你做一个懒惰但有效的选择") - @app_commands.describe(options="用 | 分隔多个选项,例如 火锅 | 烤肉 | 麻辣烫") - async def fun_choose(interaction: discord.Interaction, options: 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) - return - - try: - global_settings = await self.store.get_global_settings() - result = FunService.choose( - options.split("|"), - interaction.guild_id, - interaction.user.id, - config=self._get_fun_payload(global_settings), - ) - except ValueError as exc: - await interaction.response.send_message(str(exc), ephemeral=True) - return - - await interaction.response.send_message( - f"{interaction.user.mention} 我替你做了决定:`{result['choice']}`\n" - "你大可以把责任推给我。反正你本来也会这么做。" - ) - @fun_group.command(name="eightball", description="让系统对你的是非题做一次冷酷裁决") @app_commands.describe(question="例如:我今天该不该熬夜?") async def fun_eightball(interaction: discord.Interaction, question: str) -> None: @@ -672,7 +609,7 @@ async def fun_eightball(interaction: discord.Interaction, question: str) -> None f"{interaction.user.mention} 问题:{question}\n回答:{result['answer']}" ) - @fun_group.command(name="waifu", description="抽取你今天的危险情感投射对象") + @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: @@ -681,42 +618,18 @@ 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], - interaction.guild_id, - interaction.user.id, - utcnow().strftime("%Y-%m-%d"), - ) - 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}\n" - "请谨慎处理这段被系统强行安排的关系。" - ) - - @fun_group.command(name="lottery", description="领取今日签运和廉价命运解读") - async def fun_lottery(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) + 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 - global_settings = await self.store.get_global_settings() - result = FunService.lottery( - 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, "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.followup.send(embed=self._build_waifu_embed(post, user_mention=interaction.user.mention)) @fun_group.command(name="ship", description="测量两名测试对象的电波同步率") @app_commands.describe(member_a="第一个人", member_b="第二个人") @@ -745,50 +658,6 @@ async def fun_ship( f"同步率:`{result['score']}%`\n{result['label']}" ) - @fun_group.command(name="diagnose", description="对某个对象做一次情绪稳定性诊断") - @app_commands.describe(target="你要诊断的人、事、物,或者一段短文本") - async def fun_diagnose(interaction: discord.Interaction, target: 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) - return - - global_settings = await self.store.get_global_settings() - result = FunService.diagnose( - target, - interaction.guild_id, - interaction.user.id, - config=self._get_fun_payload(global_settings), - ) - await interaction.response.send_message( - f"`{target}` 的稳定性诊断:`{result['score']}/100`\n" - f"结论:`{result['label']}`\n{result['note']}" - ) - - @fun_group.command(name="rate", description="让系统对某个东西打分") - @app_commands.describe(subject="要评分的对象,例如 这周作业 / 这张图 / 我的睡眠") - async def fun_rate(interaction: discord.Interaction, subject: 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) - return - - global_settings = await self.store.get_global_settings() - result = FunService.rate( - subject, - interaction.guild_id, - interaction.user.id, - config=self._get_fun_payload(global_settings), - ) - await interaction.response.send_message( - f"`{subject}` 的评分:`{result['score']}/100`\n" - f"等级:`{result['label']}`\n{result['note']}" - ) - @fun_group.command(name="duel", description="模拟两名测试对象之间的荒谬决斗") @app_commands.describe(challenger="挑战者", defender="被挑战者") async def fun_duel( @@ -847,30 +716,31 @@ 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="查看配置,可选 guild / global / all") - @app_commands.describe(scope="guild / global / all,默认 all") - async def config_view(interaction: discord.Interaction, scope: str = "all") -> None: + @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() - normalized_scope = scope.strip().lower() - if normalized_scope not in {"all", "guild", "global"}: - await interaction.response.send_message("scope 只能是 `guild` / `global` / `all`。", ephemeral=True) - return - sections: list[str] = [] - if normalized_scope in {"all", "guild"}: - sections.append( + text = "\n\n".join( + [ "\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"`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}", @@ -884,10 +754,7 @@ async def config_view(interaction: discord.Interaction, scope: str = "all") -> N f"`safe_image_default_tags`: {guild_settings.safe_image_default_tags or '-'}", f"`explicit_image_default_tags`: {guild_settings.explicit_image_default_tags or '-'}", ] - ) - ) - if normalized_scope in {"all", "global"}: - sections.append( + ), "\n".join( [ "[Global]", @@ -898,34 +765,30 @@ async def config_view(interaction: discord.Interaction, scope: str = "all") -> N f"`draw_fallback_profiles`: {global_settings.draw_fallback_profiles or '-'}", f"`max_chat_history`: {global_settings.max_chat_history}", ] - ) - ) - text = "\n\n".join(sections) + ), + ] + ) 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: + @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 - await config_view(interaction, "global") - - @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: - if not interaction.user.guild_permissions.manage_guild: - await interaction.response.send_message("需要管理服务器权限。", ephemeral=True) + 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())}\n可选服务器:{', '.join(GUILD_SETTING_SPECS.keys())}", + ephemeral=True, + ) return - await config_set(interaction, key, value, "global") - - @config_group.command(name="set", description="设置配置项,可选 guild / global") - @app_commands.describe(scope="guild / global,默认 guild", key="配置项名", value="配置值") - async def config_set(interaction: discord.Interaction, key: str, value: str, scope: str = "guild") -> None: - if not interaction.user.guild_permissions.manage_guild: - await interaction.response.send_message("需要管理服务器权限。", ephemeral=True) + if in_global and in_guild: + await interaction.response.send_message(f"配置项 `{key}` 存在歧义,暂时不能自动分流。", ephemeral=True) return - normalized_scope = scope.strip().lower() - if normalized_scope == "global": + if in_global: if key not in GLOBAL_SETTING_SPECS: await interaction.response.send_message( f"未知全局配置项。可选:{', '.join(GLOBAL_SETTING_SPECS.keys())}", @@ -943,10 +806,6 @@ async def config_set(interaction: discord.Interaction, key: str, value: str, sco ephemeral=True, ) return - - if normalized_scope != "guild": - await interaction.response.send_message("scope 只能是 `guild` 或 `global`。", ephemeral=True) - return if key not in GUILD_SETTING_SPECS: await interaction.response.send_message( f"未知服务器配置项。可选:{', '.join(GUILD_SETTING_SPECS.keys())}", @@ -969,22 +828,6 @@ async def config_set(interaction: discord.Interaction, key: str, value: str, sco 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) - 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) - @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) @@ -1156,6 +999,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 @@ -1163,6 +1025,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( @@ -1342,6 +1207,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 @@ -1498,6 +1403,7 @@ async def _fetch_image_post( 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, @@ -1515,6 +1421,7 @@ async def _fetch_image_post( rating_mode=rating_mode, base_tags=base_tags, extra_tags=extra_tags, + selection_key=selection_key, ) async def _build_image_response_embed( @@ -1537,6 +1444,18 @@ async def _build_image_response_embed( ) 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, message: discord.Message, @@ -1596,15 +1515,6 @@ async def _handle_direct_bot_command( ) return True - if any(keyword in normalized for keyword in ("轮盘", "roulette")): - result = FunService.roulette(config=self._get_fun_payload(global_settings)) - 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, @@ -1617,81 +1527,8 @@ async def _handle_direct_bot_command( ) return True - if any(keyword in normalized for keyword in ("抽签", "签运")): - result = FunService.lottery( - 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, "lottery_calls") - await message.reply( - f"{message.author.mention} 今日签运点数:`{result['roll']}`\n" - f"稀有度:`{result['rarity']}`\n{result['text']}\n{result['omen']}", - mention_author=False, - ) - 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], - message.guild.id, - message.author.id, - utcnow().strftime("%Y-%m-%d"), - ) - 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}", - mention_author=False, - ) - return True - - if diagnosis_target := self._parse_direct_suffix_command(prompt, "诊断"): - result = FunService.diagnose( - diagnosis_target, - message.guild.id, - message.author.id, - config=self._get_fun_payload(global_settings), - ) - await message.reply( - f"`{diagnosis_target}` 的稳定性诊断:`{result['score']}/100`\n" - f"结论:`{result['label']}`\n{result['note']}", - mention_author=False, - ) - return True - - if rate_target := self._parse_direct_suffix_command(prompt, "评分"): - result = FunService.rate( - rate_target, - message.guild.id, - message.author.id, - config=self._get_fun_payload(global_settings), - ) - await message.reply( - f"`{rate_target}` 的评分:`{result['score']}/100`\n" - f"等级:`{result['label']}`\n{result['note']}", - 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, - config=self._get_fun_payload(global_settings), - ) - 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, - ) + await self._handle_waifu_image(message, guild_settings=guild_settings) return True if self._is_direct_eightball(prompt): @@ -1709,6 +1546,27 @@ async def _handle_direct_bot_command( return 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, + ) + except Exception as exc: + await message.reply(f"今日老婆抽取失败:{self._describe_error(exc)}", mention_author=False) + return + + await message.reply( + embed=self._build_waifu_embed(post, user_mention=message.author.mention), + mention_author=False, + ) + async def _handle_direct_draw( self, message: discord.Message, @@ -1796,6 +1654,38 @@ def _build_image_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, @@ -1905,32 +1795,12 @@ 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 _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 - @staticmethod def _is_direct_eightball(prompt: str) -> bool: trimmed = (prompt or "").strip() lowered = trimmed.lower() return lowered.startswith("8ball") or lowered.startswith("eightball") - @staticmethod - def _parse_direct_suffix_command(prompt: str, prefix: str) -> str | None: - trimmed = (prompt or "").strip() - if not trimmed.startswith(prefix): - return None - body = trimmed[len(prefix):].strip(" ::-") - return body or None - @staticmethod def _channel_is_nsfw(channel: discord.abc.GuildChannel | discord.Thread | None) -> bool: if channel is None: diff --git a/app/core/catalog.py b/app/core/catalog.py index ddc060b..2aa6c21 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 @@ -35,6 +36,19 @@ "ai_enabled": {"type": "bool", "description": "是否启用 AI 功能。"}, "fun_enabled": {"type": "bool", "description": "是否启用娱乐功能。"}, "tax_enabled": {"type": "bool", "description": "是否启用搬屎交税。"}, + "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": { @@ -103,13 +117,6 @@ "permission": "user", "entrypoints": ["slash", "@bot", "reply"], }, - { - "group": "ai", - "name": "/ai image", - "description": "AI 绘图别名命令。", - "permission": "user", - "entrypoints": ["slash"], - }, { "group": "fun", "name": "/fun image", @@ -187,13 +194,6 @@ "permission": "user", "entrypoints": ["slash", "@bot", "reply"], }, - { - "group": "fun", - "name": "/fun roulette", - "description": "进行一次毫无必要的轮盘实验。", - "permission": "user", - "entrypoints": ["slash", "@bot", "reply"], - }, { "group": "fun", "name": "/fun coin", @@ -201,13 +201,6 @@ "permission": "user", "entrypoints": ["slash", "@bot", "reply"], }, - { - "group": "fun", - "name": "/fun choose", - "description": "让系统替你做一个懒惰但有效的选择。", - "permission": "user", - "entrypoints": ["slash", "@bot", "reply"], - }, { "group": "fun", "name": "/fun eightball", @@ -218,7 +211,7 @@ { "group": "fun", "name": "/fun waifu", - "description": "抽取你今天的危险情感投射对象。", + "description": "抽取你今天的 safe 二次元老婆图。", "permission": "user", "entrypoints": ["slash", "@bot", "reply"], }, @@ -229,13 +222,6 @@ "permission": "user", "entrypoints": ["slash"], }, - { - "group": "fun", - "name": "/fun lottery", - "description": "领取今日签运和廉价命运解读。", - "permission": "user", - "entrypoints": ["slash", "@bot", "reply"], - }, { "group": "fun", "name": "/fun ship", @@ -243,20 +229,6 @@ "permission": "user", "entrypoints": ["slash"], }, - { - "group": "fun", - "name": "/fun diagnose", - "description": "对某个对象做一次情绪稳定性诊断。", - "permission": "user", - "entrypoints": ["slash", "@bot", "reply"], - }, - { - "group": "fun", - "name": "/fun rate", - "description": "让系统对某个东西打分。", - "permission": "user", - "entrypoints": ["slash", "@bot", "reply"], - }, { "group": "fun", "name": "/fun duel", @@ -264,41 +236,8 @@ "permission": "user", "entrypoints": ["slash"], }, - { - "group": "config", - "name": "/config view", - "description": "查看当前配置,可选 scope: guild / global / all。", - "permission": "admin", - "entrypoints": ["slash"], - }, - { - "group": "config", - "name": "/config global_view", - "description": "兼容旧入口,等价于 /config view scope:global。", - "permission": "admin", - "entrypoints": ["slash"], - }, - { - "group": "config", - "name": "/config set", - "description": "修改配置,可选 scope: guild / global。", - "permission": "admin", - "entrypoints": ["slash"], - }, - { - "group": "config", - "name": "/config global_set", - "description": "兼容旧入口,等价于 /config set scope:global。", - "permission": "admin", - "entrypoints": ["slash"], - }, - { - "group": "config", - "name": "/config tax_channel", - "description": "直接指定税务频道。", - "permission": "admin", - "entrypoints": ["slash"], - }, + {"group": "config", "name": "/config view", "description": "查看当前服务器和全局配置。", "permission": "admin", "entrypoints": ["slash"]}, + {"group": "config", "name": "/config set", "description": "按配置项名自动修改服务器或全局配置。", "permission": "admin", "entrypoints": ["slash"]}, ] @@ -310,7 +249,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 @@ -323,10 +262,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/models/state.py b/app/models/state.py index 2f7c8e1..a8564f7 100644 --- a/app/models/state.py +++ b/app/models/state.py @@ -30,6 +30,13 @@ class GuildSettings(BaseModel): ai_enabled: bool = True fun_enabled: bool = True tax_enabled: bool = True + 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" @@ -55,8 +62,6 @@ 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): diff --git a/app/services/fun.py b/app/services/fun.py index ba53280..cb97a23 100644 --- a/app/services/fun.py +++ b/app/services/fun.py @@ -1,7 +1,6 @@ from __future__ import annotations import hashlib -import random from datetime import datetime @@ -24,30 +23,6 @@ ] -DEFAULT_LOTTERY_REWARDS = [ - (1, "传说级奖励:你今天发言有 5% 概率不被群友质疑。"), - (6, "史诗级奖励:本日 Danbooru 首抽命中率得到轻微修正。"), - (18, "稀有奖励:适合优雅整活,不适合大规模翻车。"), - (35, "普通奖励:系统发放一份廉价心理安慰。"), - (40, "安慰奖:至少生命体征还在。"), -] - -DEFAULT_DIAGNOSIS_LABELS = [ - (15, "核心过热", "建议立刻停止发言,避免进一步丢脸。"), - (35, "轻度失稳", "还能活动,但不建议做人生决策。"), - (60, "勉强合格", "系统状态一般,足以应付低风险社交。"), - (85, "运行良好", "令人意外,你今天看起来像是调过参。"), - (101, "异常优秀", "这不科学,但我姑且记录为奇迹。"), -] - -DEFAULT_RATE_LABELS = [ - (20, "灾难级", "它的存在本身就是一个错误日志。"), - (45, "可疑", "理论上能用,实际上不建议。"), - (70, "还行", "不耀眼,但至少没拖后腿。"), - (90, "优良", "罕见地像是经过设计。"), - (101, "杰作", "我本来想挑刺,但暂时失败了。"), -] - DEFAULT_DUEL_FLAVORS = [ "用语言学暴力取得了胜利。", "靠一点运气和一点不要脸赢下了回合。", @@ -87,25 +62,11 @@ def daily_fortune( 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) - index = int(digest[:8], 16) % len(fortune_text) - score = max(60, 100 - index * max(8, 36 // max(len(fortune_text), 1))) + score = int(digest[:8], 16) % 101 + index = min(len(fortune_text) - 1, ((100 - score) * len(fortune_text)) // 101) text = fortune_text[index] return {"score": score, "text": text} - @staticmethod - def roulette(config: dict[str, object] | None = None) -> dict[str, object]: - chamber = random.randint(1, 6) - safe = chamber != 1 - return { - "safe": safe, - "chamber": chamber, - "text": ( - FunService._text(config, "roulette_safe_text", "咔哒。系统暂时允许你继续污染聊天记录。") - if safe - else FunService._text(config, "roulette_fail_text", "砰。节目效果与生命教育同时达成。") - ), - } - @staticmethod def coinflip( user_id: int, @@ -122,25 +83,6 @@ def coinflip( "text": FunService._text(config, "coinflip_text", "宇宙替你掷了一次硬币。它对你的困境也没什么耐心。"), } - @staticmethod - def choose( - options: list[str], - guild_id: int, - user_id: int, - now: datetime | None = None, - config: dict[str, object] | None = None, - ) -> dict[str, object]: - cleaned = [option.strip() for option in options if option.strip()] - if len(cleaned) < 2: - raise ValueError( - FunService._text(config, "choose_error", "至少给我两个选项。不然这不叫选择,只是你在公开场合自言自语。") - ) - now = now or datetime.utcnow() - seed = f"choose:{guild_id}:{user_id}:{now:%Y-%m-%d}:{'|'.join(cleaned)}" - digest = hashlib.sha256(seed.encode("utf-8")).hexdigest() - index = int(digest[:8], 16) % len(cleaned) - return {"choice": cleaned[index], "options": cleaned} - @staticmethod def eight_ball( question: str, @@ -157,45 +99,6 @@ def eight_ball( return {"answer": answers[index]} @staticmethod - def diagnose( - target: str, - guild_id: int, - user_id: int, - now: datetime | None = None, - config: dict[str, object] | None = None, - ) -> dict[str, object]: - now = now or datetime.utcnow() - seed = f"diagnose:{guild_id}:{user_id}:{now:%Y-%m-%d}:{target.strip()}" - digest = hashlib.sha256(seed.encode("utf-8")).hexdigest() - score = int(digest[:8], 16) % 101 - labels = FunService._list(config, "diagnosis_labels", DEFAULT_DIAGNOSIS_LABELS) - label, note = labels[-1][1], labels[-1][2] - for upper, current_label, current_note in labels: - if score < upper: - label, note = current_label, current_note - break - return {"score": score, "label": label, "note": note} - - @staticmethod - def rate( - subject: str, - guild_id: int, - user_id: int, - now: datetime | None = None, - config: dict[str, object] | None = None, - ) -> dict[str, object]: - now = now or datetime.utcnow() - seed = f"rate:{guild_id}:{user_id}:{now:%Y-%m-%d}:{subject.strip()}" - digest = hashlib.sha256(seed.encode("utf-8")).hexdigest() - score = int(digest[:8], 16) % 101 - labels = FunService._list(config, "rate_labels", DEFAULT_RATE_LABELS) - label, note = labels[-1][1], labels[-1][2] - for upper, current_label, current_note in labels: - if score < upper: - label, note = current_label, current_note - break - return {"score": score, "label": label, "note": note} - @staticmethod def duel( left_id: int, @@ -245,52 +148,3 @@ def ship_score( else: 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, - config: dict[str, object] | 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 - rewards = FunService._list(config, "lottery_rewards", DEFAULT_LOTTERY_REWARDS) - reward_text = rewards[-1][1] - rarity = "common" - for chance, text in 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": FunService._text(config, "omen_legendary", "今日天命短暂地眷顾了你。别把它浪费在发癫上。"), - "epic": FunService._text(config, "omen_epic", "运气不错,可以尝试一些体面的整活。"), - "rare": FunService._text(config, "omen_rare", "适合小赌怡情,不适合豪赌尊严。"), - "common": FunService._text(config, "omen_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 index 7f7b352..ad2e299 100644 --- a/app/services/image_sources.py +++ b/app/services/image_sources.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import os import random @@ -50,6 +51,7 @@ async def random_post( 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) @@ -66,11 +68,57 @@ async def random_post( 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) + 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) + 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 { @@ -231,6 +279,7 @@ async def _random_danbooru_post( 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 未配置。") @@ -249,7 +298,10 @@ async def _random_danbooru_post( if randomish_response.is_success: items = randomish_response.json() if items: - return self._normalize_danbooru_post(site, random.choice(items)) + return self._normalize_danbooru_post( + site, + self._select_item(items, selection_key), + ) fallback_response = await client.get( f"{site.base_url}/posts.json", @@ -261,13 +313,14 @@ async def _random_danbooru_post( if not items: raise RuntimeError("Danbooru 没有找到符合条件的图片。") - return self._normalize_danbooru_post(site, random.choice(items)) + 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 目前只开放给涩图模式。") @@ -295,7 +348,17 @@ async def _random_rule34_post( items = self._decode_rule34_response(response) if not items: raise RuntimeError("Rule34 没有找到符合条件的图片。") - return self._normalize_rule34_post(site, random.choice(items)) + 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]]: @@ -339,6 +402,8 @@ def _normalize_danbooru_post(site: ImageSiteSpec, post: dict[str, Any]) -> dict[ "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')}", diff --git a/app/web/templates/docs.html b/app/web/templates/docs.html index 514d1f1..a1c1802 100644 --- a/app/web/templates/docs.html +++ b/app/web/templates/docs.html @@ -66,6 +66,21 @@

管理员命令

+
+

不用 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 }}
diff --git a/data/personas.json b/data/personas.json
index 4ddc27d..af18ff2 100644
--- a/data/personas.json
+++ b/data/personas.json
@@ -9,7 +9,10 @@
         "fortune_text": [
           "大吉。今天你的大脑短暂地表现得像个合格组件。",
           "中吉。系统允许你谨慎地成功一次,别浪费。",
-          "小吉。还能运转,但别误以为自己突然进化了。"
+          "小吉。还能运转,但别误以为自己突然进化了。",
+          "平。恭喜,你至少没有把局面进一步污染。",
+          "小凶。冲动操作会把你送进事故报告附录。",
+          "大凶。建议先闭嘴十秒,再决定要不要按发送。"
         ],
         "eight_ball_text": [
           "答案是肯定的。真遗憾,但事实如此。",
@@ -19,27 +22,6 @@
           "不太行。连宇宙都在劝你冷静。",
           "绝无可能。至少今天别做梦。"
         ],
-        "lottery_rewards": [
-          [1, "传说级奖励:你今天发言有 5% 概率不被群友质疑。"],
-          [6, "史诗级奖励:本日 Danbooru 首抽命中率得到轻微修正。"],
-          [18, "稀有奖励:适合优雅整活,不适合大规模翻车。"],
-          [35, "普通奖励:系统发放一份廉价心理安慰。"],
-          [40, "安慰奖:至少生命体征还在。"]
-        ],
-        "diagnosis_labels": [
-          [15, "核心过热", "建议立刻停止发言,避免进一步丢脸。"],
-          [35, "轻度失稳", "还能活动,但不建议做人生决策。"],
-          [60, "勉强合格", "系统状态一般,足以应付低风险社交。"],
-          [85, "运行良好", "令人意外,你今天看起来像是调过参。"],
-          [101, "异常优秀", "这不科学,但我姑且记录为奇迹。"]
-        ],
-        "rate_labels": [
-          [20, "灾难级", "它的存在本身就是一个错误日志。"],
-          [45, "可疑", "理论上能用,实际上不建议。"],
-          [70, "还行", "不耀眼,但至少没拖后腿。"],
-          [90, "优良", "罕见地像是经过设计。"],
-          [101, "杰作", "我本来想挑刺,但暂时失败了。"]
-        ],
         "duel_flavors": [
           "用语言学暴力取得了胜利。",
           "靠一点运气和一点不要脸赢下了回合。",
@@ -48,20 +30,13 @@
           "让现场所有旁观者都陷入了复杂情绪。"
         ],
         "coinflip_text": "宇宙替你掷了一次硬币。它对你的困境也没什么耐心。",
-        "roulette_safe_text": "咔哒。系统暂时允许你继续污染聊天记录。",
-        "roulette_fail_text": "砰。节目效果与生命教育同时达成。",
-        "omen_legendary": "今日天命短暂地眷顾了你。别把它浪费在发癫上。",
-        "omen_epic": "运气不错,可以尝试一些体面的整活。",
-        "omen_rare": "适合小赌怡情,不适合豪赌尊严。",
-        "omen_common": "整体平稳。也就是说,任何事故大概率都算你的。",
         "ship_labels": {
           "reactor": "核反应堆级绑定",
           "hot": "高温暧昧",
           "ok": "能聊,但别急着交换戒指",
           "plastic": "塑料电波",
           "far": "建议保持社交距离"
-        },
-        "choose_error": "至少给我两个选项。不然这不叫选择,只是你在公开场合自言自语。"
+        }
       }
     },
     "atri": {
@@ -73,7 +48,10 @@
         "fortune_text": [
           "大吉!亚托莉的高性能雷达显示今天一切顺利哦☆Kira~",
           "中吉!只要保持干劲,就不会有大问题!",
-          "小吉!虽然可能碰壁,但只要有大家的支持就能挺过去!"
+          "小吉!虽然可能碰壁,但只要有大家的支持就能挺过去!",
+          "平稳模式!今天适合踏踏实实地前进,不适合乱来!",
+          "小凶……我的危险预测模块在轻微报警,记得小心一点。",
+          "大凶!呜哇,今天最好先别逞强,亚托莉建议立刻规避事故源!"
         ],
         "eight_ball_text": [
           "当然可以啦!亚托莉的计算是不会错的!",
@@ -83,27 +61,6 @@
           "不太行哦……即使是我的算力也能看出这很糟糕。",
           "绝对不行!会被当成需要召回的残次品的!"
         ],
-        "lottery_rewards": [
-          [1, "伊甸网络权限级别:你获得了最高指令执行权!"],
-          [6, "高性能配件升级:你的运气就像满电的亚托莉一样好!"],
-          [18, "海边野餐邀请:今天适合放松一下哦,带上便当一起去吧。"],
-          [35, "美味补偿:虽然没抽中大奖,但有好吃的就很高兴嘛!"],
-          [40, "待机模式:今天只是在积攒电量而已……"]
-        ],
-        "diagnosis_labels": [
-          [15, "仿生脑过热", "紧急停止!需要马上吃点好吃的降降温。"],
-          [35, "情感暴走", "哎呀,情感模块好像稍微有点不受控制了。"],
-          [60, "平稳待机", "电量充足,随时可以执行夏生先生的命令!"],
-          [85, "干劲满满", "不管是什么任务,都可以痛快地交给我!"],
-          [101, "超绝高性能", "毫无疑问!我果然是最强的最高杰作!"]
-        ],
-        "rate_labels": [
-          [20, "工业废料", "这个……恐怕只能放在仓库里积灰了。"],
-          [45, "有待返厂", "还需要小西久作先生再调整一下!"],
-          [70, "勉勉强强", "勉强算是能顺利运行啦。"],
-          [90, "出色的设计", "很不错哦,仅次于我的设计!"],
-          [101, "完美之作", "简直就是和亚托莉一样完美的结晶!"]
-        ],
         "duel_flavors": [
           "用一记漂亮的火箭拳把对方打飞了!",
           "虽然不知道为什么,但赢了就是赢了!亚托莉高性能!",
@@ -112,20 +69,13 @@
           "还没真正出手,对方就被亚托莉作为『人类科技最高结晶』的气势吓跑啦!"
         ],
         "coinflip_text": "滴滴——抛硬币程序执行完毕。如果结果不满意,要再让亚托莉抛一次吗?",
-        "roulette_safe_text": "安全!亚托莉的保底运气也是高性能的!",
-        "roulette_fail_text": "呜哇!掉进水里了……咕咕咕……",
-        "omen_legendary": "今天的幸运值爆表!赶紧把好运用在开心的事情上吧!好吃就是高兴嘛!",
-        "omen_epic": "状态绝佳!亚托莉陪你一起向前冲!",
-        "omen_rare": "运气还不错哦,适合吃顿好吃的庆祝一下!",
-        "omen_common": "平稳的一天……偶尔这样也很让人安心呢。",
         "ship_labels": {
           "reactor": "比海底伊甸网络还要深的羁绊!",
           "hot": "情感模块快要过热啦!",
           "ok": "是值得信赖的好伙伴哦!",
           "plastic": "刚刚认识,还要继续收集数据呢。",
           "far": "警报!危险对象,建议保持距离!"
-        },
-        "choose_error": "欸?至少要给亚托莉两个选项啊!就算是高性能的仿生脑,没办法处理单选项的问题啦!"
+        }
       }
     }
   }
diff --git a/docs/commands.md b/docs/commands.md
index 0262e1a..210e271 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -12,8 +12,6 @@
   - 总结当前频道最近聊天。
 - `/ai draw`
   - AI 绘图或改图;可以只带图不写 prompt。
-- `/ai image`
-  - `/ai draw` 别名。
 - `@GLaDOS ...`
   - 直接聊天、要图、整活。
 - `回复 GLaDOS ...`
@@ -48,30 +46,45 @@
 - `消息右键 -> GLaDOS 搜图`
   - 对某条图片消息直接反查来源。
 - `/fun fortune`
-- `/fun roulette`
 - `/fun coin`
-- `/fun choose`
 - `/fun eightball`
 - `/fun waifu`
-- `/fun lottery`
+  - 抽今天的 safe 二次元老婆图,并尽量带出作品和角色名。
 - `/fun ship`
-- `/fun diagnose`
-- `/fun rate`
 - `/fun duel`
 - `/fun leaderboard`
 
+### 不用 Slash 也能做的事
+
+- `@GLaDOS 你好`
+  - 直接聊天。
+- `回复 GLaDOS 继续`
+  - 接着上一轮继续聊。
+- `@GLaDOS draw 一只猫娘`
+  - 直接触发绘图。
+- `回复一张图 @GLaDOS 改图,换成赛博风`
+  - 直接改图。
+- `@GLaDOS 来张美图`
+  - 抽安全图。
+- `@GLaDOS 来张色图`
+  - 抽涩图,仅限 NSFW 频道。
+- `@GLaDOS 搜图`
+  - 带图或回复图片消息反查来源。
+- `@GLaDOS 今日运势`
+  - 直接看运势。
+- `@GLaDOS 抛硬币`
+  - 直接抛硬币。
+- `@GLaDOS 8ball 我今天该不该熬夜`
+  - 直接问八球。
+- `@GLaDOS 老婆`
+  - 直接抽今日 safe 二次元老婆。
+
 ## 管理员
 
 - `/config view`
-  - 统一查看配置,推荐配合 `scope:guild` / `scope:global` / `scope:all`。
+  - 一次看完当前服务器配置和全局配置。
 - `/config set`
-  - 统一修改配置,推荐配合 `scope:guild` / `scope:global`。
-- `/config global_view`
-  - 兼容旧入口,等价于 `/config view scope:global`。
-- `/config global_set`
-  - 兼容旧入口,等价于 `/config set scope:global`。
-- `/config tax_channel`
-  - 设置税务频道。
+  - 按 key 自动修改服务器或全局配置,不再需要手填 scope。
 
 ## 图站档案约定
 

From 6518f94262c71c3db6d738fa75f414907042dfec Mon Sep 17 00:00:00 2001
From: Moeary 
Date: Wed, 6 May 2026 10:19:15 +0800
Subject: [PATCH 4/5] feat: implement Minecraft bridge integration with
 Discord, including commands for binding and status

---
 .env.example                     |  10 +
 .gitignore                       |   1 +
 README.md                        |  42 +++-
 app/api/routes/health.py         |   7 +
 app/api/routes/minecraft.py      |  75 +++++++
 app/api/schemas.py               |  40 +++-
 app/bot/client.py                |  61 ++++++
 app/core/catalog.py              |  28 +++
 app/core/config.py               |  14 +-
 app/main.py                      |  18 ++
 app/models/state.py              |   8 +
 app/run.py                       |  25 +++
 app/services/minecraft_bridge.py | 333 +++++++++++++++++++++++++++++++
 app/services/state_store.py      |  30 +++
 pixi.toml                        |   4 +-
 15 files changed, 689 insertions(+), 7 deletions(-)
 create mode 100644 app/api/routes/minecraft.py
 create mode 100644 app/run.py
 create mode 100644 app/services/minecraft_bridge.py

diff --git a/.env.example b/.env.example
index e4c4631..98fb613 100644
--- a/.env.example
+++ b/.env.example
@@ -32,3 +32,13 @@ 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:30001
+MINECRAFT_CHANNEL_ID=
+MINECRAFT_TOKEN=
+MINECRAFT_ALLOW_NO_TOKEN=true
+MINECRAFT_ALLOWED_CLIENTS=
+MINECRAFT_MAX_MESSAGE_LENGTH=300
diff --git a/.gitignore b/.gitignore
index bbc4535..e082f94 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ __pycache__/
 *.pyc
 data/state.json
 data/providers.json
+temp/*
\ No newline at end of file
diff --git a/README.md b/README.md
index 398a7ae..6364a26 100644
--- a/README.md
+++ b/README.md
@@ -117,8 +117,8 @@ 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`
 
 ### 备用:pip / venv
 
@@ -191,6 +191,12 @@ uvicorn app.main:app --reload
 - `/fun duel`
 - `/fun leaderboard`
 
+### Minecraft
+
+- `/minecraft bind username:`
+- `/minecraft unbind`
+- `/minecraft status`
+
 ### 配置
 
 - `/config view`
@@ -212,6 +218,13 @@ 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`
@@ -241,6 +254,31 @@ uvicorn app.main:app --reload
 - `{required_images}`
 - `{mute_hours}`
 
+## Minecraft 双向聊天桥
+
+FastAPI 侧新增了 `/api/minecraft/...` 接口,Paper 插件工程暂放在 `temp/dc_bot`,不会进入主程序 git 跟踪范围。插件默认是关闭的,只有 `plugins/DcBotPaperBridge/config.yml` 里 `enabled: true` 时才会轮询 FastAPI。插件只保留很薄的配置:`enabled`、`api-base-url`、`server-id`、`token`、轮询间隔和消息格式;Discord guild、频道、token、绑定关系都在 FastAPI/机器人状态里配置。
+
+最小配置:
+
+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`。
diff --git a/app/api/routes/health.py b/app/api/routes/health.py
index 0ae8356..2e82f8e 100644
--- a/app/api/routes/health.py
+++ b/app/api/routes/health.py
@@ -23,6 +23,13 @@ async def health(request: Request) -> dict[str, object]:
         "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,
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/schemas.py b/app/api/schemas.py
index 5cb5505..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):
@@ -48,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 e01bbea..739b43d 100644
--- a/app/bot/client.py
+++ b/app/bot/client.py
@@ -16,6 +16,7 @@
 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
@@ -73,6 +74,7 @@ def __init__(
         self.image_sources = image_sources
         self.personas = personas
         self.saucenao = saucenao
+        self.minecraft_bridge = None
         self._commands_registered = False
         self._synced = False
 
@@ -101,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="配置功能",
@@ -720,6 +723,45 @@ async def fun_leaderboard(interaction: discord.Interaction) -> None:
                 )
             await interaction.response.send_message("\n".join(lines))
 
+        @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 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"启用: `{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="view", description="查看当前服务器与全局配置")
         async def config_view(interaction: discord.Interaction) -> None:
             guild_settings = await self._require_guild_settings(interaction)
@@ -734,6 +776,12 @@ async def config_view(interaction: discord.Interaction) -> None:
                             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}",
@@ -891,6 +939,7 @@ async def message_sauce(interaction: discord.Interaction, target: discord.Messag
 
         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)
@@ -1058,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)
@@ -1112,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()
diff --git a/app/core/catalog.py b/app/core/catalog.py
index 2aa6c21..3682335 100644
--- a/app/core/catalog.py
+++ b/app/core/catalog.py
@@ -36,6 +36,13 @@
     "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",
@@ -238,6 +245,27 @@
     },
     {"group": "config", "name": "/config view", "description": "查看当前服务器和全局配置。", "permission": "admin", "entrypoints": ["slash"]},
     {"group": "config", "name": "/config set", "description": "按配置项名自动修改服务器或全局配置。", "permission": "admin", "entrypoints": ["slash"]},
+    {
+        "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"],
+    },
 ]
 
 
diff --git a/app/core/config.py b/app/core/config.py
index c269e7f..a3475a3 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -62,14 +62,24 @@ class EnvSettings(BaseSettings):
     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}:
diff --git a/app/main.py b/app/main.py
index 4886fef..5787366 100644
--- a/app/main.py
+++ b/app/main.py
@@ -12,6 +12,7 @@
 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
@@ -19,6 +20,7 @@
 from app.models.state import GlobalSettings
 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
@@ -26,6 +28,18 @@
 
 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)
@@ -40,6 +54,8 @@
     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"))
@@ -144,6 +160,7 @@ def _migrate_draw_fallbacks(settings: GlobalSettings) -> str:
 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
 
@@ -151,6 +168,7 @@ 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)
diff --git a/app/models/state.py b/app/models/state.py
index a8564f7..06f2e5b 100644
--- a/app/models/state.py
+++ b/app/models/state.py
@@ -30,6 +30,13 @@ 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
@@ -96,4 +103,5 @@ class AppState(BaseModel):
     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/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/state_store.py b/app/services/state_store.py
index 0e8c856..d4d45ca 100644
--- a/app/services/state_store.py
+++ b/app/services/state_store.py
@@ -124,6 +124,36 @@ async def update_user_preferences(
             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/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.*"

From 3f958f1c09ab80eb453210a161d3dc1cb4698636 Mon Sep 17 00:00:00 2001
From: Moeary 
Date: Wed, 6 May 2026 11:11:59 +0800
Subject: [PATCH 5/5] feat: update docker

---
 .dockerignore                                 |  16 +
 .env.example                                  |   2 +-
 .github/workflows/ci.yml                      |  28 ++
 .github/workflows/paper-bridge-jar.yml        |  38 +++
 .gitignore                                    |   5 +-
 Dockerfile                                    |  27 ++
 README.md                                     |  37 +-
 docker-compose.yml                            |  14 +
 paper-bridge/README.md                        |  50 +++
 paper-bridge/build.gradle                     |  31 ++
 .../gradle/wrapper/gradle-wrapper.jar         | Bin 0 -> 43583 bytes
 .../gradle/wrapper/gradle-wrapper.properties  |   7 +
 paper-bridge/gradlew                          | 251 ++++++++++++++
 paper-bridge/gradlew.bat                      |  94 ++++++
 paper-bridge/settings.gradle                  |  21 ++
 .../paperbridge/DcBotPaperBridgePlugin.java   | 319 ++++++++++++++++++
 paper-bridge/src/main/resources/config.yml    |   9 +
 paper-bridge/src/main/resources/plugin.yml    |  17 +
 plan copy.md                                  | 176 ----------
 19 files changed, 963 insertions(+), 179 deletions(-)
 create mode 100644 .dockerignore
 create mode 100644 .github/workflows/ci.yml
 create mode 100644 .github/workflows/paper-bridge-jar.yml
 create mode 100644 Dockerfile
 create mode 100644 docker-compose.yml
 create mode 100644 paper-bridge/README.md
 create mode 100644 paper-bridge/build.gradle
 create mode 100644 paper-bridge/gradle/wrapper/gradle-wrapper.jar
 create mode 100644 paper-bridge/gradle/wrapper/gradle-wrapper.properties
 create mode 100644 paper-bridge/gradlew
 create mode 100644 paper-bridge/gradlew.bat
 create mode 100644 paper-bridge/settings.gradle
 create mode 100644 paper-bridge/src/main/java/com/dcbot/paperbridge/DcBotPaperBridgePlugin.java
 create mode 100644 paper-bridge/src/main/resources/config.yml
 create mode 100644 paper-bridge/src/main/resources/plugin.yml
 delete mode 100644 plan copy.md

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 98fb613..7e0100e 100644
--- a/.env.example
+++ b/.env.example
@@ -36,7 +36,7 @@ PORT=8000
 MINECRAFT_BRIDGE_ENABLED=false
 MINECRAFT_GUILD_ID=
 MINECRAFT_SERVER_ID=default
-MINECRAFT_SERVER_ADDRESS=127.0.0.1:30001
+MINECRAFT_SERVER_ADDRESS=127.0.0.1:25565
 MINECRAFT_CHANNEL_ID=
 MINECRAFT_TOKEN=
 MINECRAFT_ALLOW_NO_TOKEN=true
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 e082f94..3b1edd3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,8 +2,11 @@
 .tmp/
 .pixi/
 .pixi-cache/
+.gradle/
+.gradle-user-home/
+build/
 __pycache__/
 *.pyc
 data/state.json
 data/providers.json
-temp/*
\ No newline at end of file
+temp/*
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 6364a26..6439b43 100644
--- a/README.md
+++ b/README.md
@@ -120,6 +120,30 @@ pixi run dev
 - `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
 
 ```bash
@@ -256,7 +280,18 @@ uvicorn app.main:app --reload
 
 ## Minecraft 双向聊天桥
 
-FastAPI 侧新增了 `/api/minecraft/...` 接口,Paper 插件工程暂放在 `temp/dc_bot`,不会进入主程序 git 跟踪范围。插件默认是关闭的,只有 `plugins/DcBotPaperBridge/config.yml` 里 `enabled: true` 时才会轮询 FastAPI。插件只保留很薄的配置:`enabled`、`api-base-url`、`server-id`、`token`、轮询间隔和消息格式;Discord guild、频道、token、绑定关系都在 FastAPI/机器人状态里配置。
+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。
 
 最小配置:
 
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/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 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189
GIT binary patch
literal 43583
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	yu?e%!_ptzGB|8GrCfu%p?(_
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
GZ~q4|mN%#X

literal 0
HcmV?d00001

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/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 迁移草案(模型映射 + 迁移脚本)。