Skip to content

Commit eef8c78

Browse files
committed
fix(share-command): /share 加异步轮询,消除"已收录但 feed 看不到"黑盒
用户反馈:在频道 /share 后 bot 立即回复"✅ 已收录到内卷地狱分享库",但 点进 /feed 看不到这条链接,不知道是被审核了还是出了什么问题。 根因: - 后端流是 submit → PENDING (落库) → enrichment worker 异步审核 → APPROVED / PENDING_MANUAL / FLAGGED / REJECTED - /feed 只展示 status=APPROVED 的链接(PENDING_MANUAL 等需要管理员后台 操作) - 原 commands cog 在 200 OK 立即回复"已收录",跟"在 /feed 上线"是两回事 - listener cog(频道消息监听式提交)已经做了"先告知正在审核 → 后台轮询 → 拿到终态再发第二条",但 /share slash command 不一致 改造让 /share 跟 listener 同款两阶段反馈,但用 message.edit 替代 reply (slash 是单条公开消息,edit 比再发一条更整洁): 1. submit 成功 → 立即用 PENDING 状态渲染消息 "URL\n-# ⏳ 已提交分享 · #id · by user · AI 审核中(约 30s)" 2. asyncio.create_task 启动后台轮询 GET /internal/{id}(每 2s,最多 30s) 3. 拿到终态 → message.edit 替换尾标: - APPROVED → ✅ 已收录到分享库 + 站内链接 - PENDING_MANUAL → 🟡 非白名单,等待人工复核 - FLAGGED → 🟡 AI 标记需复核 - REJECTED → ❌ 审核未通过 - ARCHIVED → 📦 系统归档 4. 超时仍 PENDING → 不动消息(继续显示 "⏳ AI 审核中"),用户可去 /u/<id>/shares 主动查进度 技术细节: - _safe 包装 fire-and-forget 任务,异常打 log(CancelledError 不吞, bot 优雅退出取消 task 不受影响) - _render_share_message 单一渲染函数,PENDING / 各终态共用,避免 字段顺序漂移 测试:ruff 0 issues + pytest 93 passed.
1 parent 9d77952 commit eef8c78

1 file changed

Lines changed: 181 additions & 22 deletions

File tree

src/chat_bot/cogs/commands.py

Lines changed: 181 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,41 +2,127 @@
22
33
/share <url>:手动提交链接到内卷地狱主站,**并在频道里公开展示**。
44
5-
设计思路(用户反馈:/share 比直接贴链接还差 → 没人会用):
5+
设计思路(用户反馈:原版"已收录"立即回复让用户去 /feed 看不到 PENDING
6+
链接,体验黑盒):
67
- Discord 本身会对任意 URL 自动 unfurl 出 OG 预览卡片(标题/描述/封面图)
7-
- 所以 /share 的回复 **把 URL 当纯文本** 发到频道,让 Discord 直接渲染,不自己造轮子
8-
- 额外加一行小字注释 "已收录到内卷地狱 · #id",用 Discord 的 `-#` subtext 语法
9-
- 不等后端异步审核完成、不轮询 status、不显示"审核中"——直接"已收录":
10-
- 这个事实在 API 返回 200 的那一刻就是真的(行已落 shared_links)
11-
- PENDING_MANUAL / FLAGGED 是主站展示层的决定,跟"是否收录"是两件事
12-
- 告诉用户"审核中"只会让人误以为失败(见 user feedback)
13-
14-
/share 现在对普通用户价值 = 直接贴 + 多一句"✅ 已收录"+主站链接跳转。
8+
- 所以 /share 的回复 **把 URL 当纯文本** 发到频道,让 Discord 直接渲染
9+
- 状态尾标(`-# ` Discord subtext 语法)实时反映审核进度:
10+
- 提交瞬间:⏳ 已提交,AI 审核中... (status=PENDING)
11+
- 后台 polling /internal/{id},拿到终态后 message.edit 替换尾标
12+
- APPROVED → ✅ 已收录到分享库 + 站内链接
13+
- PENDING_MANUAL → 🟡 非白名单,等待人工复核
14+
- FLAGGED → 🟡 AI 标记需复核
15+
- REJECTED → ❌ 审核未通过
16+
- ARCHIVED → 📦 系统归档
17+
18+
跟 listener cog 同款机制:先告知正在审核,拿到终态再展示真实结果。
19+
轮询超时(30s)静默——用户继续看到"⏳ AI 审核中",可去 /u/<id>/shares
20+
查进度。
1521
"""
1622

1723
from __future__ import annotations
1824

25+
import asyncio
1926
import re
27+
from collections.abc import Coroutine
28+
from typing import Any
2029

2130
import discord
2231
import structlog
2332
from discord import app_commands
2433
from discord.ext import commands
2534

26-
from ..api_client import DuplicateURL, InternalAPIError, submit_internal
35+
from ..api_client import DuplicateURL, InternalAPIError, fetch_link, submit_internal
2736
from ..config import Settings
2837

2938
_URL_RE = re.compile(r"^https?://[^\s<>\"'\]\)]+$", re.IGNORECASE)
3039

40+
_FEED_URL = "https://involutionhell.com/zh/feed"
41+
42+
# 轮询参数(与 listener cog 同款;常量重复定义避免 cog 间循环 import)
43+
_POLL_INTERVAL_SEC = 2.0
44+
_POLL_TIMEOUT_SEC = 30.0
45+
_TERMINAL_STATUSES = {"APPROVED", "PENDING_MANUAL", "FLAGGED", "REJECTED", "ARCHIVED"}
46+
3147
log = structlog.get_logger(__name__)
3248

3349

50+
def _render_share_message(
51+
*,
52+
url: str,
53+
link_id: int,
54+
user_display_name: str,
55+
recommendation: str | None,
56+
status: str,
57+
) -> str:
58+
"""渲染 /share 公开消息内容(status 不同尾标不同)。
59+
60+
URL 作为消息正文 → Discord 自动 unfurl OG 卡。
61+
`-# ` subtext 行展示 link_id / 提交人 / 状态。
62+
同一 link_id 在 PENDING → terminal 演进时多次重新渲染,格式保持一致。
63+
"""
64+
if status == "PENDING":
65+
caption = (
66+
f"-# ⏳ 已提交分享 · `#{link_id}` · by {user_display_name} "
67+
f"· AI 审核中(约 30s)"
68+
)
69+
elif status == "APPROVED":
70+
caption = (
71+
f"-# ✅ 已收录到 [内卷地狱分享库]({_FEED_URL}) "
72+
f"· `#{link_id}` · by {user_display_name}"
73+
)
74+
elif status == "PENDING_MANUAL":
75+
caption = (
76+
f"-# 🟡 已提交 · `#{link_id}` · by {user_display_name} "
77+
f"· 非白名单域名,等待人工复核"
78+
)
79+
elif status == "FLAGGED":
80+
caption = (
81+
f"-# 🟡 已提交 · `#{link_id}` · by {user_display_name} "
82+
f"· AI 标记需复核(如误判可私信管理员 appeal)"
83+
)
84+
elif status == "REJECTED":
85+
caption = (
86+
f"-# ❌ 已提交 · `#{link_id}` · by {user_display_name} "
87+
f"· 审核未通过"
88+
)
89+
elif status == "ARCHIVED":
90+
caption = (
91+
f"-# 📦 已提交 · `#{link_id}` · by {user_display_name} "
92+
f"· 系统已归档(原文失效)"
93+
)
94+
else:
95+
# 未知状态兜底,不让 caption 渲染崩
96+
caption = (
97+
f"-# 已提交 · `#{link_id}` · by {user_display_name} · 状态: {status}"
98+
)
99+
100+
content = f"{url}\n{caption}"
101+
if recommendation:
102+
content = f"> {recommendation}\n{content}"
103+
return content
104+
105+
106+
async def _safe(coro: Coroutine[Any, Any, Any], *, name: str) -> None:
107+
"""包装 background task:异常打 log 不让 fire-and-forget 静默失败。
108+
109+
CancelledError 不会被 except Exception 捕获(3.12 起继承自
110+
BaseException),bot 优雅退出取消 task 不会被这里吞掉。
111+
"""
112+
try:
113+
await coro
114+
except Exception:
115+
log.exception("share_command_background_task_failed", task=name)
116+
117+
34118
class ShareCommands(commands.Cog):
35119
def __init__(self, bot: commands.Bot, settings: Settings) -> None:
36120
self.bot = bot
37121
self.settings = settings
38122

39-
@app_commands.command(name="share", description="把链接收录到内卷地狱分享库(频道内公开)")
123+
@app_commands.command(
124+
name="share", description="把链接收录到内卷地狱分享库(频道内公开)"
125+
)
40126
@app_commands.describe(
41127
url="以 http:// 或 https:// 开头的完整 URL",
42128
recommendation="可选:一句话推荐语",
@@ -54,7 +140,7 @@ async def share(
54140
)
55141
return
56142

57-
# 不 thinking(不显示"bot is thinking..."),想让 OG 预览尽快出
143+
# 不 thinking(不显示"bot is thinking..."),让首条消息尽快出来
58144
await interaction.response.defer(ephemeral=False, thinking=False)
59145

60146
try:
@@ -79,20 +165,93 @@ async def share(
79165
return
80166
except Exception as e:
81167
log.error("slash_share_unexpected_error", url=url, error=str(e))
82-
await interaction.followup.send("提交失败,已通知开发者。", ephemeral=True)
168+
await interaction.followup.send(
169+
"提交失败,已通知开发者。", ephemeral=True
170+
)
83171
return
84172

85-
# 把 URL 作为消息正文发出去:Discord 自动 unfurl 出 OG 预览卡(标题/描述/封面图)
86-
# 小字 caption 用 `-# ...` 语法(Discord 的 subtext 行,显示为灰色细字)
87-
content = (
88-
f"{url}\n"
89-
f"-# ✅ 已收录到 [内卷地狱分享库](https://involutionhell.com/feed) "
90-
f"· `#{result.link_id}` · by {interaction.user.display_name}"
173+
# 第一条消息:以 PENDING 状态渲染发出。后台 polling 拿到终态后 edit。
174+
initial_content = _render_share_message(
175+
url=url,
176+
link_id=result.link_id,
177+
user_display_name=interaction.user.display_name,
178+
recommendation=recommendation,
179+
status="PENDING",
180+
)
181+
sent = await interaction.followup.send(
182+
content=initial_content, wait=True
183+
)
184+
185+
# 启动后台轮询任务(fire-and-forget,_safe 兜底)
186+
task_name = f"share_command_poll_{result.link_id}"
187+
asyncio.create_task(
188+
_safe(
189+
self._poll_and_edit(
190+
sent_message=sent,
191+
link_id=result.link_id,
192+
url=url,
193+
user_display_name=interaction.user.display_name,
194+
recommendation=recommendation,
195+
),
196+
name=task_name,
197+
),
198+
name=task_name,
91199
)
92-
if recommendation:
93-
content = f"> {recommendation}\n{content}"
94200

95-
await interaction.followup.send(content=content)
201+
async def _poll_and_edit(
202+
self,
203+
*,
204+
sent_message: discord.WebhookMessage,
205+
link_id: int,
206+
url: str,
207+
user_display_name: str,
208+
recommendation: str | None,
209+
) -> None:
210+
"""轮询 /internal/{id} 拿终态,命中后 edit 第一条消息显示真实状态。
211+
212+
与 listener cog 同款逻辑:每 2s 查一次,最多 30s 后超时静默。
213+
超时仍 PENDING:不动消息(继续显示"⏳ AI 审核中"),用户可去
214+
/u/<id>/shares 主动查进度。
215+
"""
216+
elapsed = 0.0
217+
while elapsed < _POLL_TIMEOUT_SEC:
218+
await asyncio.sleep(_POLL_INTERVAL_SEC)
219+
elapsed += _POLL_INTERVAL_SEC
220+
try:
221+
detail = await fetch_link(
222+
base_url=self.settings.internal_submit_url,
223+
internal_key=self.settings.internal_api_key.get_secret_value(),
224+
link_id=link_id,
225+
timeout=self.settings.chatbot_api_timeout,
226+
)
227+
except Exception as e:
228+
log.warning(
229+
"share_command_poll_error", link_id=link_id, error=str(e)
230+
)
231+
return
232+
if detail is None:
233+
# 404 不该发生(刚 submit 完就消失?),出现了也只能放弃
234+
log.warning("share_command_poll_404", link_id=link_id)
235+
return
236+
if detail.status in _TERMINAL_STATUSES:
237+
new_content = _render_share_message(
238+
url=url,
239+
link_id=link_id,
240+
user_display_name=user_display_name,
241+
recommendation=recommendation,
242+
status=detail.status,
243+
)
244+
try:
245+
await sent_message.edit(content=new_content)
246+
except discord.HTTPException as e:
247+
log.warning(
248+
"share_command_edit_failed",
249+
link_id=link_id,
250+
error=str(e),
251+
)
252+
return
253+
254+
log.info("share_command_poll_timeout", link_id=link_id)
96255

97256

98257
async def setup(bot: commands.Bot) -> None:

0 commit comments

Comments
 (0)