Files
DerrorsandClaude Opus 5.5 cb8c577be6 feat: 控制 Bot 里发「/subs 片名」直接找剧订阅,/subs 列表不再列出已播完的订阅
/subs 后面写片名(可加季号)时,复用管理台「一句话找剧」在 TMDB 找候选,每季一个按钮,点选即订阅;待审批里已有这部剧这一季时可以一并转存。
/subs 列表只列在追的剧,已播完、自动结束的订阅不再列出(播完时的通知上仍可继续订阅)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:11:30 +08:00

414 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""控制 Bot:修正识别结果、列表与批量操作。"""
from __future__ import annotations
import asyncio
import re
import struct
import time
import types
import unittest
from unittest.mock import patch
from telethon.errors import InvalidBufferError, MessageIdInvalidError
from reelay.control import manage
from reelay.control.bot import ControlBot
from reelay.core import messages, secret_store
from reelay.core.messages import parse_callback
from reelay.core.contracts import TaskState
from tests.fakes import ServiceCase, bot_for, tv_ctx
class TestParseCorrection(unittest.TestCase):
def test_phrases(self):
parse = manage.parse_correction
self.assertEqual(parse("繁花 2023 剧集 S01E03"),
{"title": "繁花", "year": 2023, "media_type": "tv", "season": 1, "episodes": [3]})
self.assertEqual(parse("片名 繁花"), {"title": "繁花"})
self.assertEqual(parse("第 2 季 E01-E05"), {"season": 2, "episodes": [1, 2, 3, 4, 5]})
self.assertEqual(parse("《Doctor X:白色黑手党时代》 第1-5集"),
{"title": "Doctor X:白色黑手党时代", "episodes": [1, 2, 3, 4, 5]})
self.assertEqual(parse("类型:电影"), {"media_type": "movie", "season": None, "episodes": []})
self.assertEqual(parse("Doctor X 4K"), {"title": "Doctor X", "resolution": "2160p"})
self.assertEqual(parse(" "), {})
with self.assertRaises(ValueError):
parse("第 9-3 集")
class TestCorrection(ServiceCase):
async def test_correct_a_pending_task_from_telegram(self):
task = await self.pending("3")
bot = bot_for(self.service)
self.assertIn(("✏️ 修正", f"fx:{task.task_id}"), [b for row in messages.approval_buttons(task) for b in row])
prompt, buttons, _, toast = await bot.open(f"fx:{task.task_id}", chat="c")
self.assertIn(task.task_id, prompt)
self.assertEqual(buttons, messages.correction_buttons(task.task_id))
# 回复了提示,或点「修正」后直接发的下一条消息,都当作修正内容
self.assertEqual(bot.correction_target("x", messages.plain(prompt)), task.task_id)
self.assertEqual(bot.correction_target("c", None), task.task_id)
self.assertIsNone(bot.correction_target("c", None)) # 只算一次
text, buttons, _ = await bot.correct(task.task_id, "片名 繁花 第 2 季 E05")
parsed = self.store.get_task(task.task_id).parsed
self.assertEqual((parsed.title, parsed.season, parsed.episodes), ("繁花", 2, [5]))
self.assertIn("✏️ <b>已修正</b>:片名:剧 → 繁花;季:1 → 2;集:E03 → E05", text)
self.assertEqual(buttons, messages.approval_buttons(self.store.get_task(task.task_id)))
self.assertIn("没看出要改什么", (await bot.correct(task.task_id, "…"))[0])
await self.service.approve(task.task_id) # 已经批准、排队转存:不能再改
self.assertIn("没有修改:排队中", (await bot.correct(task.task_id, "片名 甲"))[0])
self.assertEqual((await bot.open(f"fx:{task.task_id}"))[3], "现在不能修正(当前:queued)")
async def test_failed_task_can_be_corrected_then_retried(self):
task = await self.pending("4")
task.state = TaskState.FAILED
task.trail.append("10:00:00 转存前校验未通过,需人工处理:无法从文件名识别集号:a.mp4")
self.store.save_task(task)
text, buttons, _ = messages.task_card(task)
self.assertIn("先点「修正」,再重试", text)
self.assertIn([("✏️ 修正", f"fx:{task.task_id}")], buttons)
await bot_for(self.service).correct(task.task_id, "第 4 集")
self.assertEqual(self.store.get_task(task.task_id).parsed.episodes, [4])
await self.service.retry(task.task_id)
async def test_reparse_button(self):
task = await self.pending("5")
text, _, _ = await bot_for(self.service).callback(f"rp:{task.task_id}", "提示")
self.assertIn("已重新识别", text)
self.assertEqual(self.store.get_task(task.task_id).state, TaskState.RECEIVED)
class TestLists(ServiceCase):
async def test_pending_list_reopens_cards_and_approves_everything(self):
tasks = [await self.pending(str(i)) for i in range(1, 11)]
bot = bot_for(self.service)
text, buttons = manage.pending_list(self.service)
self.assertIn("待审批 10", text)
self.assertEqual(buttons[0], [("📺 剧 S01 E03", f"cd:{tasks[0].task_id}")])
self.assertEqual(buttons[-2], [("下一页 ➡️", "pl:8")])
self.assertEqual(buttons[-1], [("✅ 全部批准(10)", "pa"), ("🚫 全部忽略(10)", "pr")])
self.assertEqual(len((await bot.callback("pl:8"))[1]), 2 + 2) # 第二页 2 条 + 翻页 + 一键操作
self.assertTrue(all(len(data.encode()) <= 64 for row in buttons for _, data in row))
card, card_buttons, _, _ = await bot.open(f"cd:{tasks[0].task_id}")
self.assertEqual(card, messages.approval_card(tasks[0]))
text, confirm, _ = await bot.callback("pa")
self.assertIn("这 <b>10</b> 条", text)
label, data = confirm[0][0]
self.assertEqual((label, data.split(":")[0]), ("确认批准转存", "pay"))
later = await self.pending("11") # 确认前又到了一条:不在这次的计划里
text, _, _ = await bot.callback(data)
self.assertIn("已批准,正在转存,完成后逐条通知 10 条", text)
self.assertEqual({self.store.get_task(t.task_id).state for t in tasks}, {TaskState.QUEUED})
self.assertEqual(self.store.get_task(later.task_id).state, TaskState.PENDING_APPROVAL)
self.assertIn("已经过期或处理过了", (await bot.callback(data))[0]) # 同一个确认按钮不能再用
await self.service.reject(later.task_id)
text, buttons = manage.pending_list(self.service)
self.assertTrue(text.startswith("📋 <b>待处理</b>\n\n✅ 没有需要你确认或处理的资源"))
self.assertEqual(buttons, [[("📊 运行概况", "st")]])
async def test_status_and_help(self):
await self.pending("1")
text, buttons = manage.status_view(self.service)
self.assertTrue(text.startswith("📊 <b>运行概况</b>"))
self.assertIn("📌 <b>待处理</b>\n待审批 1 · 待人工 0 · 失败 0", text)
self.assertEqual(buttons, [[("📋 待处理 1", "pl:0")], [("🔄 刷新", "st")]])
self.assertEqual((await bot_for(self.service).callback("st"))[0], text)
text, _ = manage.help_view(self.service)
self.assertIn("/pending 待审批与待人工", text)
self.assertNotIn("/help", text)
async def test_digest_and_backfill_notices_carry_the_button(self):
sent = []
async def sender(**kwargs):
sent.append(kwargs)
console = self.registry.get_provider("notify", "console")
console.configure(sender=sender)
self.addCleanup(console.configure)
self.store.stage_notice("approval_digest", "⏳ 2 条资源等待审批", ["console"], buttons=messages.PENDING_BUTTONS)
await self.pl.dispatch_deliveries()
self.assertEqual(sent[-1]["buttons"], [[["📋 查看待审批", "pl:0"]]])
async def test_subscriptions_and_ignored_works(self):
item = self.subscribe({"title": "繁花", "season": 1, "tmdbid": 9})
self.store.add_ignored_work("title:广告剧", {"title": "广告剧"})
bot = bot_for(self.service)
text, buttons = manage.subs_list(self.service)
self.assertEqual(buttons, [[("🗑 取消订阅 繁花 S01", f"us:{item['id']}")]])
text, confirm, _ = await bot.callback(f"us:{item['id']}")
self.assertIn("取消订阅《繁花》第 1 季", text)
text, _, _ = await bot.callback(f"usy:{item['id']}")
self.assertIn("🗑 已取消订阅《繁花》第 1 季", text)
self.assertEqual(self.store.subscriptions(), [])
self.assertEqual((await bot.callback(f"usy:{item['id']}"))[2], "这条订阅已经不在了")
# 已播完、自动结束的订阅不在列表里;播完时通知上的「继续订阅」照常可用
item = self.subscribe({"title": "繁花", "season": 1, "tmdbid": 9})
self.store.set_subscription_finished(item["id"], {"date": "2026-10-01", "episodes": 30})
text, buttons = manage.subs_list(self.service)
self.assertNotIn("繁花", text)
self.assertIn("没有在追的剧", text)
self.assertNotIn(f"so:{item['id']}", [data for row in buttons for _, data in row])
await bot.callback(f"so:{item['id']}")
self.assertIn("繁花", manage.subs_list(self.service)[0]) # 继续订阅后回到列表
self.assertIsNone(self.store.subscription(item["id"])["finished"])
text, buttons = manage.ignored_list(self.service)
self.assertIn("广告剧", text)
text, _, _ = await bot.callback(buttons[0][0][1])
self.assertIn("已恢复接收《广告剧》", text)
self.assertEqual(self.store.ignored_works(), [])
class TestBulkPlans(ServiceCase):
"""「全部批准 / 全部忽略」先列出计划,确认时只处理计划里的任务,且只执行一次。"""
def plan(self, action):
text, buttons = manage.bulk_confirm(self.service, action)
name, (plan_id,) = parse_callback(buttons[0][0][1])
self.assertEqual(name, f"{action}y")
return text, plan_id
async def test_only_tasks_listed_at_confirmation_are_handled_once(self):
first = await self.pending("1")
text, plan_id = self.plan("pa")
self.assertIn("<b>1</b> 条", text)
later = await self.pending("2") # 确认之后才到的任务不在计划里
self.assertIn("已批准,正在转存,完成后逐条通知 1 条", await manage.bulk_run(self.service, plan_id))
self.assertEqual(self.store.get_task(first.task_id).state, TaskState.QUEUED)
self.assertEqual(self.store.get_task(later.task_id).state, TaskState.PENDING_APPROVAL)
self.assertIn("已经过期或处理过了", await manage.bulk_run(self.service, plan_id))
async def test_tasks_handled_meanwhile_are_reported_not_failed(self):
first, _ = await self.pending("1"), await self.pending("2")
_, plan_id = self.plan("pr")
await self.service.approve(first.task_id)
self.assertEqual(await manage.bulk_run(self.service, plan_id), "✅ 已忽略 1 条,1 条期间已被处理")
self.assertEqual(self.store.get_task(first.task_id).state, TaskState.QUEUED)
async def test_expired_plans_do_nothing(self):
task = await self.pending("1")
_, plan_id = self.plan("pa")
with patch("reelay.control.manage.time.time", return_value=time.time() + manage.BULK_TTL + 1):
self.assertIn("过期", await manage.bulk_run(self.service, plan_id))
self.assertEqual(self.store.get_task(task.task_id).state, TaskState.PENDING_APPROVAL)
class TestCardSync(ServiceCase):
"""审批卡片在别处(管理台、批量操作、「不再接收」……)处理后,Bot 把卡片改成处理结果。"""
def bot(self):
sent, edits = [], []
class Client:
fail = None
async def send_message(self, chat, text, **kwargs):
sent.append(text)
return types.SimpleNamespace(id=len(sent))
async def edit_message(self, chat, message_id, text, **kwargs):
if self.fail:
raise self.fail
edits.append((chat, message_id, text, kwargs["buttons"]))
bot = bot_for(self.service)
bot._client, bot._owner = Client(), "42"
bot._Button = types.SimpleNamespace(inline=lambda label, data: (label, data.decode()), url=lambda label, url: (label, url))
return bot, edits
async def test_cards_follow_tasks_handled_elsewhere(self):
first, second, third = [await self.pending(str(i)) for i in (1, 2, 3)]
bot, edits = self.bot()
for task in (first, second, third):
await bot.send(messages.approval_card(task), messages.approval_buttons(task), html=True, card=task.task_id)
await bot.send("不是审批卡片", html=True)
self.assertEqual(await bot.sync_cards(), 0) # 都还在待审批
await self.service.approve(first.task_id) # 在管理台批准
self.assertEqual(await bot.sync_cards(), 1)
chat, message_id, text, buttons = edits[-1]
self.assertEqual((chat, message_id), (42, 1))
self.assertEqual(text, messages.approval_result(messages.approval_card(self.store.get_task(first.task_id)),
"approved"))
self.assertIsNone(buttons) # 审批按钮去掉了
self.assertEqual(await bot.sync_cards(), 0) # 只改一次
await self.service.ignore_work(second.task_id) # 「不再接收」:同一部作品的其他卡片一并改掉
self.assertEqual(await bot.sync_cards(), 2)
self.assertEqual([m for _, m, _, _ in edits[-2:]], [2, 3])
self.assertTrue(all(text.endswith("⛔ <b>已忽略</b>,之后《剧》的消息不再处理") for _, _, text, _ in edits[-2:]))
async def test_cards_answered_on_telegram_or_gone_are_dropped(self):
first, second = await self.pending("1"), await self.pending("2")
bot, edits = self.bot()
await bot.send("卡片", html=True, card=first.task_id)
await bot.send("卡片", html=True, card=second.task_id)
self.store.card_drop("42", 1, settled_only=True) # 点了卡片上的按钮,但任务还在待审批:继续跟踪
await self.service.approve(first.task_id)
self.store.card_drop("42", 1, settled_only=True) # 卡片上点了「转存」:结果已写在卡片上
await self.service.reject(second.task_id)
bot._client.fail = ConnectionError("断开")
with self.assertRaises(ConnectionError): # 连不上 Telegram:下次再改
await bot.sync_cards()
bot._client.fail = MessageIdInvalidError(request=None) # 卡片被删了:不再重试
self.assertEqual(await bot.sync_cards(), 0)
bot._client.fail = None
self.assertEqual(await bot.sync_cards(), 0)
self.assertEqual(edits, [])
class TestReconnect(ServiceCase):
"""控制 Bot 与 Telegram 断开后在进程里重新连接;启动时连不上也不退出。"""
async def until(self, check, timeout=2):
async def poll():
while not check():
await asyncio.sleep(0.005)
await asyncio.wait_for(poll(), timeout)
async def test_bot_reconnects_instead_of_exiting(self):
clients, failures = [], [ConnectionError("blocked")] # 启动时代理不通
class Client:
def __init__(self, *args, **kwargs):
self.options, self.connected, self.gone = kwargs, False, None
clients.append(self)
def on(self, event):
return lambda handler: handler
async def start(self, bot_token):
if failures:
raise failures.pop(0)
self.connected = True
async def __call__(self, request): # 命令菜单
return None
def is_connected(self):
return self.connected
async def run_until_disconnected(self):
self.gone = asyncio.get_running_loop().create_future()
await self.gone
def drop(self, error=None):
self.connected = False
self.gone.set_exception(error) if error else self.gone.set_result(None)
async def disconnect(self):
self.connected = False
if self.gone and not self.gone.done():
self.gone.set_result(None)
module = types.ModuleType("telethon")
module.TelegramClient, module.Button = Client, None
module.events = types.SimpleNamespace(NewMessage=lambda **kwargs: None, CallbackQuery=lambda **kwargs: None)
module.functions = types.SimpleNamespace(bots=types.SimpleNamespace(SetBotCommandsRequest=lambda **kw: kw))
module.types = types.SimpleNamespace(BotCommandScopeDefault=lambda: None, BotCommand=lambda **kw: kw)
bot = bot_for(self.service)
bot._token, bot._owner, bot._problem = "123:abc", "42", None
throttled = lambda: InvalidBufferError(struct.pack("<i", -429)) # noqa: E731
with patch.dict("sys.modules", {"telethon": module}), patch("reelay.core.telegram.RECONNECT_SEC", 0.01), \
patch("reelay.control.bot.RETRY_SECONDS", 0.01):
await bot.start() # 启动时连不上:不退出,稍后自己连
self.assertEqual(bot.health(), {"status": "disconnected", "error": "连不上 Telegram:blocked"})
runner = asyncio.create_task(bot.run_forever())
await self.until(lambda: bot.health()["status"] == "connected")
self.assertIs(clients[-1].options["auto_reconnect"], False) # 断线由 Bot 自己重连
failures.append(throttled()) # 恢复时被 Telegram 限流:稍后再连
clients[-1].drop(throttled())
await self.until(lambda: bot.health()["status"] == "connected" and not failures)
clients[-1].drop() # 心跳没有回应:run_until_disconnected 正常结束,同样重连
await self.until(lambda: len(clients) == 5 and bot.health()["status"] == "connected")
self.assertFalse(runner.done())
failures.append(ValueError("Bot Token 失效")) # 不是网络的问题:照常抛出
clients[-1].drop()
with self.assertRaises(ValueError):
await asyncio.wait_for(runner, 2)
class TestFindShow(ServiceCase):
"""「/subs 片名」:在 TMDB 找剧,点选一季订阅;待审批里已有这部剧时可以一并转存。"""
SHOW = {"tmdbid": 300146, "title": "嫁金钗", "original_title": "Where the Mask Ends", "year": 2026,
"poster_path": None, "overview": "古装剧", "seasons": [{"season": 1, "episodes": 24}, {"season": 2, "episodes": 30}]}
DETAILS = {"id": 300146, "name": "嫁金钗", "genre_ids": [18], "seasons": [{"season": 1, "episodes": 24},
{"season": 2, "episodes": 30}]}
def setUp(self):
super().setUp()
for patcher in (patch("reelay.core.tmdb.api_key", return_value="key"),
patch("reelay.core.tmdb.tv_details", return_value=self.DETAILS)):
patcher.start()
self.addCleanup(patcher.stop)
self.bot = bot_for(self.service)
def resolved(self, *candidates, season=None):
async def resolve(text):
return {"query": {"title": "嫁金钗", "season": season, "year": None}, "candidates": list(candidates),
"tmdb": True}
self.service.resolve_subscription = resolve
async def test_search_lists_seasons_and_marks_the_subscribed_ones(self):
self.subscribe({"title": "嫁金钗", "season": 1, "tmdbid": 300146})
self.resolved(self.SHOW)
text, buttons = await self.bot.find_show("嫁金钗")
self.assertIn("📺 <b>嫁金钗</b> (2026)\nWhere the Mask Ends", text)
self.assertEqual(buttons, [[("✅ 嫁金钗 S01 已订阅", "sl"), ("🔔 嫁金钗 S02 · 30 集", "sa:300146:2")]])
self.resolved(self.SHOW, season=2) # 「/subs 嫁金钗 第二季」:只列这一季
self.assertEqual((await self.bot.find_show("嫁金钗 第二季"))[1], [[("🔔 嫁金钗 S02 · 30 集", "sa:300146:2")]])
self.resolved()
self.assertIn("TMDB 上没找到「嫁金钗」", (await self.bot.find_show("嫁金钗"))[0])
async def test_subscribing_a_season_offers_to_save_its_pending_episodes(self):
waiting = tv_ctx(title="嫁金钗", tmdbid=300146, episodes=(19,))
waiting.state = TaskState.PENDING_APPROVAL
self.store.save_task(waiting)
text, buttons, toast = await self.bot.callback("sa:300146:1")
self.assertEqual(text, "🔔 已订阅《嫁金钗》第 1 季(共 24 集),之后的新集自动转存。\n待审批里还有 1 条这部剧。")
self.assertEqual(toast, "已订阅")
(label, data), = buttons[0]
self.assertEqual((label, data.split(":")[0]), ("✅ 一并转存 1 条", "pay"))
self.assertEqual([(s["title"], s["season"], s["total"]) for s in self.store.subscriptions()], [("嫁金钗", 1, 24)])
self.assertIn("已批准", (await self.bot.callback(data))[0])
self.assertEqual(self.store.get_task(waiting.task_id).state, TaskState.QUEUED)
self.assertEqual((await self.bot.callback("sa:300146:1"))[2], "已经订阅过《嫁金钗》第 1 季") # 重复点
self.assertEqual((await self.bot.callback("sa:300146:9"))[2], "TMDB 上《嫁金钗》没有第 9 季")
async def test_subs_with_a_title_searches_and_without_lists(self):
handlers, replies = {}, []
class Client:
def on(self, event):
return lambda handler: handlers.setdefault(event.get("pattern"), handler)
events = types.SimpleNamespace(NewMessage=lambda **kw: kw, CallbackQuery=lambda **kw: kw)
self.bot._client, self.bot._owner, self.bot._Button = Client(), "42", None
self.bot._register_handlers(events)
pattern = next(p for p in handlers if p and "subs" in p)
self.resolved(self.SHOW)
async def send(text):
async def reply(body, **kwargs):
replies.append(body)
await handlers[pattern](types.SimpleNamespace(sender_id=42, raw_text=text, reply=reply,
pattern_match=re.match(pattern, text)))
await send("/subs 嫁金钗")
await send("/subs")
self.assertTrue(replies[0].startswith("🔔 <b>找剧订阅</b> · 嫁金钗"))
self.assertTrue(replies[1].startswith("🔔 <b>追剧订阅</b>"))
self.assertIn("/subs 片名", replies[1]) # 还没有订阅时提示可以直接找剧
class TestHealth(ServiceCase):
def test_unconfigured_bot_is_not_reported_as_disconnected(self):
with secret_store.override():
health = ControlBot(self.service).health()
self.assertEqual(health["status"], "not_configured") # 总览显示「未配置」,不是红色的连接中断
self.assertIn("设置 → Telegram", health["error"])
self.configure({**self.CFG, "telegram": {"owner_id": "42"}})
with secret_store.override(control_bot_token="123:abc"):
self.assertEqual(ControlBot(self.service).health(), {"status": "disconnected"}) # 配好了但还没连上
if __name__ == "__main__":
unittest.main()