test_channel_management.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. from __future__ import annotations
  2. import asyncio
  3. from datetime import UTC, datetime, timedelta
  4. from types import SimpleNamespace
  5. from unittest.mock import AsyncMock
  6. import pytest
  7. from pyrogram.enums import ChatMembersFilter, ChatMemberStatus, ChatType
  8. from pyrogram.errors import MessageEmpty
  9. def prepare_channel(app_modules, *, chat_id: int = -100123) -> None:
  10. app = app_modules.wbb.app
  11. app.chats[chat_id] = SimpleNamespace(
  12. id=chat_id,
  13. type=ChatType.CHANNEL,
  14. title="测试频道",
  15. username="test_channel",
  16. description="简介",
  17. permissions=None,
  18. photo=None,
  19. )
  20. app.members[(chat_id, 999)] = SimpleNamespace(
  21. status=ChatMemberStatus.ADMINISTRATOR,
  22. privileges=SimpleNamespace(
  23. can_manage_chat=True,
  24. can_post_messages=True,
  25. can_edit_messages=True,
  26. can_delete_messages=True,
  27. can_pin_messages=True,
  28. can_change_info=True,
  29. can_promote_members=True,
  30. can_invite_users=True,
  31. ),
  32. )
  33. async def test_channel_connect_rejects_group_and_non_admin(app_modules):
  34. service = app_modules.load("wbb.services.channel_management")
  35. prepare_channel(app_modules)
  36. result = await service.ensure_channel(-100123)
  37. assert result["type"] == "channel"
  38. app_modules.wbb.app.members[(-100123, 999)].status = ChatMemberStatus.MEMBER
  39. with pytest.raises(service.ChatManagementError) as caught:
  40. await service.ensure_channel(-100123)
  41. assert caught.value.code == "bot_not_admin"
  42. app_modules.wbb.app.chats[-100123].type = ChatType.SUPERGROUP
  43. with pytest.raises(service.ChatManagementError) as caught:
  44. await service.ensure_channel(-100123)
  45. assert caught.value.code == "not_channel"
  46. async def test_channel_admin_candidates_use_telegram_recent_and_search(app_modules, monkeypatch):
  47. service = app_modules.load("wbb.services.channel_management")
  48. prepare_channel(app_modules)
  49. calls = []
  50. def member(user_id: int, name: str, *, status=ChatMemberStatus.MEMBER, is_bot=False):
  51. return SimpleNamespace(
  52. status=status, privileges=None, custom_title=None, until_date=None,
  53. user=SimpleNamespace(
  54. id=user_id, username=None, first_name=name, last_name=None,
  55. is_bot=is_bot, is_deleted=False,
  56. ),
  57. )
  58. async def get_chat_members(chat_id, *, query, limit, filter):
  59. calls.append((chat_id, query, limit, filter))
  60. for item in [
  61. member(42, "Keven"),
  62. member(999, "Bot", status=ChatMemberStatus.ADMINISTRATOR, is_bot=True),
  63. member(43, "Owner", status=ChatMemberStatus.OWNER),
  64. ]:
  65. yield item
  66. monkeypatch.setattr(app_modules.app, "get_chat_members", get_chat_members, raising=False)
  67. recent = await service.list_channel_admin_candidates(-100123)
  68. searched = await service.list_channel_admin_candidates(-100123, query="@keven", limit=80)
  69. assert [item["user"]["id"] for item in recent] == ["42"]
  70. assert [item["user"]["id"] for item in searched] == ["42"]
  71. assert calls == [
  72. (-100123, "", 30, ChatMembersFilter.RECENT),
  73. (-100123, "keven", 30, ChatMembersFilter.SEARCH),
  74. ]
  75. app_modules.app.members[(-100123, 999)].privileges.can_promote_members = False
  76. with pytest.raises(service.ChatManagementError) as caught:
  77. await service.list_channel_admin_candidates(-100123)
  78. assert caught.value.code == "bot_permission_missing"
  79. async def test_channel_admin_candidates_lookup_numeric_member(app_modules, monkeypatch):
  80. service = app_modules.load("wbb.services.channel_management")
  81. prepare_channel(app_modules)
  82. calls = []
  83. async def bot_api(method, **params):
  84. calls.append((method, params))
  85. return {
  86. "status": "member",
  87. "user": {"id": 8960210159, "first_name": "Keven", "username": "uesd1515", "is_bot": False},
  88. }
  89. monkeypatch.setattr(service, "channel_bot_api", bot_api)
  90. result = await service.list_channel_admin_candidates(-100123, query="8960210159")
  91. assert result[0]["user"]["id"] == "8960210159"
  92. assert calls == [("getChatMember", {"chat_id": -100123, "user_id": 8960210159})]
  93. async def test_manual_channel_connect_resolves_uncached_public_id(app_modules, monkeypatch):
  94. service = app_modules.load("wbb.services.channel_management")
  95. prepare_channel(app_modules)
  96. app_modules.wbb.BOT_TOKEN = "test-token"
  97. original_get_chat = app_modules.app.get_chat
  98. warmed = False
  99. async def get_chat(peer):
  100. nonlocal warmed
  101. if peer == -100123 and not warmed:
  102. raise ValueError("Peer id invalid: -100123")
  103. if peer == "@test_channel":
  104. warmed = True
  105. return await original_get_chat(-100123)
  106. return await original_get_chat(peer)
  107. class Response:
  108. async def __aenter__(self):
  109. return self
  110. async def __aexit__(self, *_args):
  111. return None
  112. async def json(self):
  113. return {"ok": True, "result": {"id": -100123, "type": "channel", "username": "test_channel"}}
  114. class Session:
  115. async def __aenter__(self):
  116. return self
  117. async def __aexit__(self, *_args):
  118. return None
  119. def post(self, *_args, **_kwargs):
  120. return Response()
  121. monkeypatch.setattr(app_modules.app, "get_chat", get_chat)
  122. monkeypatch.setattr(service, "ClientSession", lambda **_kwargs: Session())
  123. result = await service.ensure_channel(-100123)
  124. assert result["chat_id"] == -100123 and warmed
  125. async def test_channel_list_and_group_list_are_isolated(app_modules):
  126. service = app_modules.load("wbb.services.channel_management")
  127. chat_service = app_modules.load("wbb.services.chat_management")
  128. prepare_channel(app_modules)
  129. await service.ensure_channel(-100123)
  130. await app_modules.wbb.db.managed_chats.insert_one({
  131. "bot_id": "primary", "chat_id": -100456, "type": "supergroup",
  132. "title": "群", "accessible": True,
  133. })
  134. channels, channel_total = await service.list_channels(query="", page=1, page_size=20)
  135. groups, group_total = await chat_service.list_accessible_chats(page=1, page_size=20)
  136. assert channel_total == 1 and channels[0]["chat_id"] == -100123
  137. assert group_total == 1 and groups[0]["chat_id"] == -100456
  138. async def test_generic_channel_observation_keeps_unavailable_status(app_modules):
  139. dbadmin = app_modules.load("wbb.utils.dbadmin")
  140. await dbadmin.upsert_managed_chat(
  141. chat_id=-100123, title="频道", username=None, chat_type="channel",
  142. accessible=False, bot_status="administrator",
  143. )
  144. await dbadmin.upsert_managed_chat(
  145. chat_id=-100123, title="频道新名", username=None, chat_type="channel",
  146. accessible=None,
  147. )
  148. record = await app_modules.wbb.db.managed_chats.find_one({"chat_id": -100123})
  149. assert record["accessible"] is False
  150. assert record["title"] == "频道新名"
  151. async def test_channel_observation_deduplicates_updates_and_scopes_bot(app_modules):
  152. service = app_modules.load("wbb.services.channel_management")
  153. prepare_channel(app_modules)
  154. message = SimpleNamespace(
  155. chat=app_modules.wbb.app.chats[-100123], id=8,
  156. text="原文", caption=None, media=None, date=datetime.now(UTC),
  157. )
  158. await service.observe_channel_post(message)
  159. message.text = "改文"
  160. await service.observe_channel_post(message)
  161. assert await service.postsdb.count_documents({}) == 1
  162. post = await service.postsdb.find_one({"bot_id": "primary"})
  163. assert post["text"] == "改文" and post["source"] == "telegram"
  164. await service.observe_channel_deletion(-100123, 8)
  165. assert (await service.postsdb.find_one({"post_id": post["post_id"]}))["status"] == "deleted"
  166. app_modules.wbb.BOT_PROFILE_ID = "second"
  167. service.BOT_PROFILE_ID = "second"
  168. dbmodule = app_modules.load("wbb.utils.dbchannel")
  169. dbmodule.BOT_PROFILE_ID = "second"
  170. await service.observe_channel_post(message)
  171. assert await service.postsdb.count_documents({}) == 2
  172. async def test_scheduled_post_publishes_once_and_can_be_canceled(app_modules):
  173. service = app_modules.load("wbb.services.channel_management")
  174. prepare_channel(app_modules)
  175. scheduled = await service.create_channel_post(-100123, {
  176. "text": "计划帖", "publish_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
  177. })
  178. assert scheduled["status"] == "scheduled"
  179. changed = await service.edit_channel_post(-100123, scheduled["post_id"], {"text": "更新计划"})
  180. assert changed["text"] == "更新计划"
  181. await service.postsdb.update_one(
  182. {"post_id": scheduled["post_id"]},
  183. {"$set": {"publish_at": datetime.now(UTC) - timedelta(seconds=1)}},
  184. )
  185. await service.sweep_channel_posts()
  186. await service.sweep_channel_posts()
  187. assert app_modules.wbb.app.sent_messages == [(-100123, "更新计划")]
  188. assert (await service.get_post(-100123, scheduled["post_id"]))["status"] == "published"
  189. canceled = await service.create_channel_post(-100123, {
  190. "text": "不发", "publish_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
  191. })
  192. assert (await service.delete_channel_post(-100123, canceled["post_id"]))["status"] == "canceled"
  193. async def test_scheduled_post_can_be_edited_near_publish_time(app_modules):
  194. service = app_modules.load("wbb.services.channel_management")
  195. prepare_channel(app_modules)
  196. scheduled = await service.create_channel_post(-100123, {
  197. "text": "临近发布", "publish_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
  198. })
  199. await service.postsdb.update_one(
  200. {"post_id": scheduled["post_id"]},
  201. {"$set": {"publish_at": datetime.now(UTC) + timedelta(seconds=2)}},
  202. )
  203. current = await service.get_post(-100123, scheduled["post_id"])
  204. updated = await service.edit_channel_post(-100123, scheduled["post_id"], {
  205. "text": "已修改", "publish_at": current["publish_at"].replace(tzinfo=UTC).isoformat(),
  206. })
  207. assert updated["text"] == "已修改"
  208. assert updated["status"] == "scheduled"
  209. async def test_uncertain_send_is_not_retried(app_modules, monkeypatch):
  210. service = app_modules.load("wbb.services.channel_management")
  211. prepare_channel(app_modules)
  212. send = AsyncMock(side_effect=TimeoutError("timeout after send"))
  213. monkeypatch.setattr(service.app, "send_message", send)
  214. with pytest.raises(service.ChatManagementError) as caught:
  215. await service.create_channel_post(-100123, {"text": "结果不明"})
  216. assert caught.value.code == "post_delivery_uncertain"
  217. await service.sweep_channel_posts()
  218. assert send.await_count == 1
  219. post = await service.postsdb.find_one({"text": "结果不明"})
  220. assert post["status"] == "uncertain"
  221. async def test_telegram_rejection_is_failed_not_uncertain(app_modules, monkeypatch):
  222. service = app_modules.load("wbb.services.channel_management")
  223. prepare_channel(app_modules)
  224. monkeypatch.setattr(
  225. service.app, "send_message", AsyncMock(side_effect=MessageEmpty()),
  226. )
  227. with pytest.raises(service.ChatManagementError) as caught:
  228. await service.create_channel_post(-100123, {"text": "Telegram 拒绝"})
  229. assert caught.value.code == "post_send_failed"
  230. post = await service.postsdb.find_one({"text": "Telegram 拒绝"})
  231. assert post["status"] == "failed"
  232. async def test_published_post_edit_and_delete_require_telegram_permission(app_modules, monkeypatch):
  233. service = app_modules.load("wbb.services.channel_management")
  234. prepare_channel(app_modules)
  235. post = await service.create_channel_post(-100123, {"text": "原文"})
  236. edited = await service.edit_channel_post(-100123, post["post_id"], {"text": "新文"})
  237. assert edited["text"] == "新文"
  238. assert app_modules.wbb.app.edited_messages == [(-100123, 1, "新文")]
  239. monkeypatch.setattr(service.app, "delete_messages", AsyncMock(return_value=1))
  240. deleted = await service.delete_channel_post(-100123, post["post_id"])
  241. assert deleted["status"] == "deleted"
  242. async def test_media_post_can_replace_media_and_channel_admin_rights_are_checked(app_modules, monkeypatch):
  243. service = app_modules.load("wbb.services.channel_management")
  244. prepare_channel(app_modules)
  245. post = await service.create_channel_post(-100123, {
  246. "text": "第一张", "media_type": "photo", "file_id": "photo-file-1",
  247. "media_filename": "first.jpg", "media_size": 1234, "media_mime_type": "image/jpeg",
  248. })
  249. assert app_modules.app.sent_photos == [(-100123, "photo-file-1", "第一张")]
  250. assert post["media_filename"] == "first.jpg" and post["media_size"] == 1234
  251. edit_media = AsyncMock()
  252. monkeypatch.setattr(service.app, "edit_message_media", edit_media, raising=False)
  253. changed = await service.edit_channel_post(-100123, post["post_id"], {
  254. "text": "第二张", "media_type": "photo", "file_id": "photo-file-2",
  255. })
  256. assert changed["file_id"] == "photo-file-2"
  257. assert edit_media.await_count == 1
  258. member = {"status": "member", "user": {"id": 42, "first_name": "用户"}}
  259. admin = {"status": "administrator", "user": member["user"], "can_post_messages": True}
  260. bot_api = AsyncMock(side_effect=[member, True, admin])
  261. monkeypatch.setattr(service, "channel_bot_api", bot_api)
  262. promoted = await service.set_channel_admin(-100123, 42, ["can_post_messages"])
  263. assert promoted["status"] == "administrator"
  264. assert promoted["privileges"] == ["can_post_messages"]
  265. assert [item.args[0] for item in bot_api.await_args_list] == [
  266. "getChatMember", "promoteChatMember", "getChatMember",
  267. ]
  268. assert bot_api.await_args_list[1].kwargs["can_post_messages"] is True
  269. assert bot_api.await_args_list[1].kwargs["can_edit_messages"] is False
  270. bot_api.reset_mock(side_effect=True)
  271. bot_api.side_effect = [admin, True]
  272. removed = await service.remove_channel_admin(-100123, 42)
  273. assert removed["removed"] is True
  274. assert [item.args[0] for item in bot_api.await_args_list] == [
  275. "getChatMember", "promoteChatMember",
  276. ]
  277. assert not any(
  278. bot_api.await_args_list[1].kwargs[key] for key in service.CHANNEL_ADMIN_PRIVILEGES
  279. )
  280. with pytest.raises(service.ChatManagementError) as caught:
  281. await service.set_channel_admin(-100123, 42, ["can_restrict_members"])
  282. assert caught.value.code == "invalid_privileges"
  283. @pytest.mark.parametrize("media_type, method, argument", [
  284. ("animation", "send_animation", "animation"),
  285. ("video", "send_video", "video"),
  286. ("document", "send_document", "document"),
  287. ])
  288. async def test_media_post_dispatches_supported_types(app_modules, monkeypatch, media_type, method, argument):
  289. service = app_modules.load("wbb.services.channel_management")
  290. prepare_channel(app_modules)
  291. send = AsyncMock(return_value=SimpleNamespace(id=91))
  292. monkeypatch.setattr(service.app, method, send, raising=False)
  293. post = await service.create_channel_post(-100123, {
  294. "text": "媒体帖", "media_type": media_type, "file_id": "telegram-file-id",
  295. })
  296. assert post["status"] == "published" and post["message_id"] == 91
  297. assert send.await_args.args == (-100123,)
  298. assert send.await_args.kwargs[argument] == "telegram-file-id"
  299. async def test_revoked_bot_post_role_prevents_scheduled_send(app_modules):
  300. service = app_modules.load("wbb.services.channel_management")
  301. prepare_channel(app_modules)
  302. scheduled = await service.create_channel_post(-100123, {
  303. "text": "原定发布", "publish_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
  304. })
  305. await service.postsdb.update_one(
  306. {"post_id": scheduled["post_id"]},
  307. {"$set": {"publish_at": datetime.now(UTC) - timedelta(seconds=1)}},
  308. )
  309. service.BOT_PERMISSIONS = {"channel.profile"}
  310. await service.sweep_channel_posts()
  311. assert app_modules.app.sent_messages == []
  312. assert (await service.get_post(-100123, scheduled["post_id"]))["status"] == "failed"
  313. async def test_concurrent_claim_and_stale_restart_do_not_resend(app_modules):
  314. service = app_modules.load("wbb.services.channel_management")
  315. dbmodule = app_modules.load("wbb.utils.dbchannel")
  316. prepare_channel(app_modules)
  317. scheduled = await service.create_channel_post(-100123, {
  318. "text": "重启前任务", "publish_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
  319. })
  320. await service.postsdb.update_one(
  321. {"post_id": scheduled["post_id"]},
  322. {"$set": {"publish_at": datetime.now(UTC) - timedelta(seconds=1)}},
  323. )
  324. first, second = await asyncio.gather(dbmodule.claim_due_posts(), dbmodule.claim_due_posts())
  325. assert len(first) + len(second) == 1
  326. await service.postsdb.update_one(
  327. {"post_id": scheduled["post_id"]},
  328. {"$set": {"updated_at": datetime.now(UTC) - timedelta(minutes=6)}},
  329. )
  330. await service.sweep_channel_posts()
  331. assert app_modules.app.sent_messages == []
  332. assert (await service.get_post(-100123, scheduled["post_id"]))["status"] == "uncertain"
  333. def test_channel_route_permissions(app_modules):
  334. permissions = app_modules.load("wbb.services.bot_permissions")
  335. assert permissions.api_permission("GET", "/api/admin/v1/channels") == "channel.any"
  336. assert permissions.api_permission("GET", "/api/admin/v1/channels/-100") == "channel.any"
  337. assert permissions.api_permission("POST", "/api/admin/v1/channels") == "channel.profile"
  338. assert permissions.api_permission("PUT", "/api/admin/v1/channels/-100/admins/42") == "channel.admins"
  339. assert permissions.api_permission("PATCH", "/api/admin/v1/channels/-100/posts/p1") == "channel.posts"