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
1723from __future__ import annotations
1824
25+ import asyncio
1926import re
27+ from collections .abc import Coroutine
28+ from typing import Any
2029
2130import discord
2231import structlog
2332from discord import app_commands
2433from 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
2736from ..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+
3147log = 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+
34118class 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
98257async def setup (bot : commands .Bot ) -> None :
0 commit comments