diff --git a/backend/api/routes/admin/chat/manage.py b/backend/api/routes/admin/chat/manage.py index 58f82cf..85e155e 100644 --- a/backend/api/routes/admin/chat/manage.py +++ b/backend/api/routes/admin/chat/manage.py @@ -97,8 +97,23 @@ async def update_chat_full_control( requestor=request.state.user, chat_slug=slug, ) - chat = await telegram_chat_action.set_control_level( + chat_result = await telegram_chat_action.set_control_level( is_fully_managed=chat.is_enabled, effective_in_days=chat.effective_in_days, ) - return TelegramChatFDO.model_validate(chat.model_dump()) + return TelegramChatFDO.model_validate(chat_result.model_dump()) + + +@admin_chat_manage_router.post("/control-dry-run") +async def trigger_chat_full_control_dry_run( + request: Request, + slug: str, + db_session: Session = Depends(get_db_session), +) -> TelegramChatFDO: + telegram_chat_action = TelegramChatManageAction( + db_session=db_session, + requestor=request.state.user, + chat_slug=slug, + ) + chat_result = await telegram_chat_action.trigger_control_level_dry_run() + return TelegramChatFDO.model_validate(chat_result.model_dump()) diff --git a/backend/community_manager/actions/chat.py b/backend/community_manager/actions/chat.py index 6da499c..f2b3199 100644 --- a/backend/community_manager/actions/chat.py +++ b/backend/community_manager/actions/chat.py @@ -1249,9 +1249,36 @@ async def kick_ineligible_chat_members( :raises MissingChatEntityError: Raised when the chat entity is missing for a member. :raises MissingUserEntityError: Raised when the user entity is missing for a member. """ - ineligible_members = self.authorization_action.get_ineligible_chat_members( - chat_members=chat_members + chat_members_to_check = [ + chat_member + for chat_member in chat_members + if (chat_member.chat.is_full_control or chat_member.is_managed) + and not chat_member.is_admin + ] + + if not chat_members_to_check: + logger.info("No chat members require eligibility check in this chunk") + return + + evaluation_results = ( + self.authorization_action.evaluate_chat_members_eligibility( + chat_members=chat_members_to_check + ) ) + + ineligible_members = [] + for res in evaluation_results: + if not res.is_eligible: + ineligible_members.append(res.member) + elif not res.member.is_managed and res.member.chat.is_full_control: + # User passed the check, but was previously unmanaged in a full control chat. + # Bring them under management so that they are continuously monitored. + res.member.is_managed = True + self.db_session.add(res.member) + logger.debug( + f"User {res.member.user.telegram_id!r} is now managed in chat {res.member.chat_id!r}." + ) + if not ineligible_members: logger.info("No ineligible chat members found") return @@ -1261,3 +1288,75 @@ async def kick_ineligible_chat_members( for member in ineligible_members: # kick_chat_member handles exceptions internally await self.kick_chat_member(member) + + async def check_chat_members_compliance_dry_run(self, chat_id: int) -> None: + """ + Iterates over all members of a chat in batches and performs a dry-run eligibility check. + Logs compliance summaries for ineligible members without kicking them. + + :param chat_id: The ID of the chat to check. + """ + logger.info(f"Starting dry-run check of chat members for chat {chat_id=!r}.") + + total_processed = 0 + total_non_managed = 0 + ineligible_managed_count = 0 + ineligible_non_managed_count = 0 + + for chat_members_chunk in self.telegram_chat_user_service.yield_all_for_chat( + chat_id=chat_id, + batch_size=100, + ): + chat_members_to_check = [ + chat_member + for chat_member in chat_members_chunk + if not chat_member.is_admin + ] + + if not chat_members_to_check: + logger.info( + "No chat members require eligibility check in this chunk. Continue..." + ) + continue + + evaluation_results = ( + self.authorization_action.evaluate_chat_members_eligibility( + chat_members=chat_members_to_check + ) + ) + + for result in evaluation_results: + member_is_managed = result.member.is_managed + + if not member_is_managed: + total_non_managed += 1 + + if not result.is_eligible: + if member_is_managed: + ineligible_managed_count += 1 + else: + ineligible_non_managed_count += 1 + + summary_json = ( + result.summary.model_dump_json() + if result.summary is not None + else "{}" + ) + logger.info( + f"Dry-run: User {result.member.user.telegram_id!r} is ineligible for chat {chat_id!r}. " + f"Managed: {member_is_managed}. Compliance summary: {summary_json}" + ) + + total_processed += len(chat_members_chunk) + logger.info( + f"Dry-run: Processed chunk of {len(chat_members_chunk)} users for chat {chat_id=!r}. " + f"Total processed: {total_processed}" + ) + + logger.info( + f"Dry-run summary for chat {chat_id}: " + f"Total processed: {total_processed}, " + f"Non-managed: {total_non_managed}, " + f"Ineligible (managed): {ineligible_managed_count}, " + f"Ineligible (non-managed): {ineligible_non_managed_count}" + ) diff --git a/backend/community_manager/tasks/chat.py b/backend/community_manager/tasks/chat.py index 7d7390e..f889fe7 100644 --- a/backend/community_manager/tasks/chat.py +++ b/backend/community_manager/tasks/chat.py @@ -56,6 +56,21 @@ def check_target_chat_members_task(chat_id: int) -> None: async_to_sync(check_target_chat_members)(chat_id) +async def check_target_chat_members_dry_run(chat_id: int) -> None: + with DBService().db_session() as db_session: + action = CommunityManagerUserChatAction(db_session) + await action.check_chat_members_compliance_dry_run(chat_id=chat_id) + + +@app.task( + name="check-target-chat-members-dry-run", + queue=CELERY_SYSTEM_QUEUE_NAME, + ignore_result=True, +) +def check_target_chat_members_dry_run_task(chat_id: int) -> None: + async_to_sync(check_target_chat_members_dry_run)(chat_id) + + async def refresh_chat_external_sources_async() -> None: with DBService().db_session() as db_session: # BotAPI does not need a telethon client diff --git a/backend/core/src/core/actions/authorization.py b/backend/core/src/core/actions/authorization.py index 55ccf06..47f7069 100644 --- a/backend/core/src/core/actions/authorization.py +++ b/backend/core/src/core/actions/authorization.py @@ -16,6 +16,7 @@ EligibilitySummaryStickerCollectionInternalDTO, EligibilitySummaryJettonInternalDTO, EligibilitySummaryNftCollectionInternalDTO, + ChatMemberEligibilityResultDTO, ) from core.dtos.gift.collection import GiftCollectionDTO from core.dtos.resource import JettonDTO, NftCollectionDTO @@ -220,34 +221,23 @@ def get_eligibility_rules( emoji=all_emoji_rules, ) - def get_ineligible_chat_members( + def evaluate_chat_members_eligibility( self, chat_members: list[TelegramChatUser], - ) -> list[TelegramChatUser]: + ) -> list[ChatMemberEligibilityResultDTO]: """ - Determines and returns a list of chat members who are ineligible to be part of their respective chats based on + Determines and returns eligibility evaluations for a list of chat members based on eligibility rules and various related data sources such as wallets, NFTs, and gifts. This functionality checks each member's eligibility against the chat's specific rules, wallet information, NFTs, jettons, stickers, and - other items associated with the user. Users that do not meet the criteria defined by the chat's eligibility - rules are considered ineligible. + other items associated with the user. :param chat_members: A list of TelegramChatUser objects representing the members of various Telegram chats. - :return: A list of TelegramChatUser objects representing chat members who are not eligible to be part of their - respective chats. + :return: A list of ChatMemberEligibilityResultDTO objects representing the eligibility of each member. """ members_per_chat = defaultdict(list) user_id_to_telegram_id = {} eligibility_rules_per_chat: dict[int, TelegramChatEligibilityRulesDTO] = {} - chat_members = [ - # Skip checks for non-managed users in the chats where full control is disabled - # and skip checks for admins - chat_member - for chat_member in chat_members - if (chat_member.chat.is_full_control or chat_member.is_managed) - and not chat_member.is_admin - ] - if not chat_members: logger.info("No chat members to check eligibility for. Skipping.") return [] @@ -295,38 +285,39 @@ def get_ineligible_chat_members( telegram_user_id=user_id_to_telegram_id[user_id] ) - ineligible_members = [] + results = [] for chat, members in members_per_chat.items(): for member in members: member_wallet = ( member.wallet_link.wallet if member.wallet_link else None ) member_wallet_address = member_wallet.address if member_wallet else None - if not ( - eligibility_summary := self.check_chat_member_eligibility( - eligibility_rules=eligibility_rules_per_chat[chat], - user=member.user, - user_wallet=member_wallet, - user_jettons=jetton_wallets_per_wallet.get( - member_wallet_address, [] - ), - user_nft_items=nft_items_per_wallet.get( - member_wallet_address, [] - ), - user_sticker_items=sticker_items_per_user.get( - member.user_id, [] - ), - user_gift_items=gift_items_per_user.get(member.user_id, []), - chat_member=member, - ) - ): + eligibility_summary = self.check_chat_member_eligibility( + eligibility_rules=eligibility_rules_per_chat[chat], + user=member.user, + user_wallet=member_wallet, + user_jettons=jetton_wallets_per_wallet.get( + member_wallet_address, [] + ), + user_nft_items=nft_items_per_wallet.get(member_wallet_address, []), + user_sticker_items=sticker_items_per_user.get(member.user_id, []), + user_gift_items=gift_items_per_user.get(member.user_id, []), + chat_member=member, + ) + if not eligibility_summary: logger.debug( f"User {member.user.telegram_id!r} is not eligible to be in chat {chat!r}." f"Eligibility summary: {eligibility_summary!r}" ) - ineligible_members.append(member) + results.append( + ChatMemberEligibilityResultDTO( + member=member, + is_eligible=bool(eligibility_summary), + summary=eligibility_summary, + ) + ) - return ineligible_members + return results @classmethod def check_chat_member_eligibility( diff --git a/backend/core/src/core/actions/chat/__init__.py b/backend/core/src/core/actions/chat/__init__.py index 85b68ad..6ef3a78 100644 --- a/backend/core/src/core/actions/chat/__init__.py +++ b/backend/core/src/core/actions/chat/__init__.py @@ -319,6 +319,35 @@ async def set_control_level( ) return TelegramChatDTO.from_object(chat) + async def trigger_control_level_dry_run(self) -> TelegramChatDTO: + """ + Triggers a dry-run of the chat control level checks. + Uses a 5-minute rate limit to prevent spamming. + """ + redis_service = RedisService() + if not redis_service.set( + f"set_control_level_dry_run_{self.chat.id}", "1", ex=300, nx=True + ): + logger.warning( + "An attempt to spam trigger_control_level_dry_run in chat %d", + self.chat.id, + ) + raise HTTPException( + status_code=HTTP_429_TOO_MANY_REQUESTS, + detail="Too many requests. Please wait 5 minutes before trying again.", + ) + + logger.info(f"Triggering control level dry run for chat {self.chat.id}") + + # Enqueue the dry run task + sender.send_task( + "check-target-chat-members-dry-run", + args=(self.chat.id,), + queue=CELERY_SYSTEM_QUEUE_NAME, + ) + + return TelegramChatDTO.from_object(self.chat) + async def get_with_eligibility_rules(self) -> TelegramChatWithRulesDTO: """ This is an administrative method to get chat with rules that includes disabled rules diff --git a/backend/core/src/core/dtos/chat/rule/internal.py b/backend/core/src/core/dtos/chat/rule/internal.py index 504939f..bc62b1a 100644 --- a/backend/core/src/core/dtos/chat/rule/internal.py +++ b/backend/core/src/core/dtos/chat/rule/internal.py @@ -1,10 +1,11 @@ -from pydantic import BaseModel, computed_field +from pydantic import BaseModel, ConfigDict, computed_field from core.enums.rule import EligibilityCheckType from core.dtos.gift.collection import GiftCollectionDTO from core.dtos.resource import NftCollectionDTO, JettonDTO from core.dtos.sticker import MinimalStickerCollectionDTO, MinimalStickerCharacterDTO from core.enums.nft import NftCollectionAsset +from core.models.chat import TelegramChatUser class EligibilitySummaryInternalDTO(BaseModel): @@ -110,3 +111,11 @@ def __bool__(self): def __repr__(self): return f"<{self.__class__.__name__} ({self.items=})>" + + +class ChatMemberEligibilityResultDTO(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + member: TelegramChatUser + is_eligible: bool + summary: RulesEligibilitySummaryInternalDTO | None diff --git a/backend/tests/unit/community_manager/actions/test_chat_kick.py b/backend/tests/unit/community_manager/actions/test_chat_kick.py index 32ac8f3..fa40572 100644 --- a/backend/tests/unit/community_manager/actions/test_chat_kick.py +++ b/backend/tests/unit/community_manager/actions/test_chat_kick.py @@ -1,18 +1,27 @@ import pytest +import logging from unittest.mock import AsyncMock, MagicMock, patch from community_manager.actions.chat import CommunityManagerUserChatAction -from core.models.chat import TelegramChatUser, TelegramChat -from core.models.user import User +from core.dtos.chat.rule.internal import ( + ChatMemberEligibilityResultDTO, + EligibilitySummaryInternalDTO, + RulesEligibilityGroupSummaryInternalDTO, + RulesEligibilitySummaryInternalDTO, +) +from core.enums.rule import EligibilityCheckType +from tests.factories import TelegramChatFactory, TelegramChatUserFactory, UserFactory @pytest.mark.asyncio async def test_kick_chat_member_admin_protection(db_session): action = CommunityManagerUserChatAction(db_session) - chat = TelegramChat(id=1, title="Test Chat", is_full_control=True) - user = User(id=1, telegram_id=123) - chat_user = TelegramChatUser( - user_id=1, chat_id=1, is_admin=True, is_managed=True, chat=chat, user=user + chat = TelegramChatFactory.with_session(db_session).create( + id=1, title="Test Chat", is_full_control=True + ) + user = UserFactory.with_session(db_session).create(id=1, telegram_id=123) + chat_user = TelegramChatUserFactory.with_session(db_session).create( + user=user, chat=chat, is_admin=True, is_managed=True ) # Mock bot_api_service to ensure it is NOT called @@ -26,10 +35,12 @@ async def test_kick_chat_member_admin_protection(db_session): @pytest.mark.asyncio async def test_kick_chat_member_normal_user(db_session): action = CommunityManagerUserChatAction(db_session) - chat = TelegramChat(id=1, title="Test Chat", is_full_control=True) - user = User(id=1, telegram_id=123) - chat_user = TelegramChatUser( - user_id=1, chat_id=1, is_admin=False, is_managed=True, chat=chat, user=user + chat = TelegramChatFactory.with_session(db_session).create( + id=1, title="Test Chat", is_full_control=True + ) + user = UserFactory.with_session(db_session).create(id=1, telegram_id=123) + chat_user = TelegramChatUserFactory.with_session(db_session).create( + user=user, chat=chat, is_admin=False, is_managed=True ) # Mock bot_api_service context manager @@ -45,3 +56,144 @@ async def test_kick_chat_member_normal_user(db_session): await action.kick_chat_member(chat_user) mock_service_instance.kick_chat_member.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_check_chat_members_compliance_dry_run_counters(db_session, caplog): + action = CommunityManagerUserChatAction(db_session) + chat = TelegramChatFactory.with_session(db_session).create( + id=1, title="Test Chat", is_full_control=True + ) + + # Create 3 users: + # 1. Managed and ineligible + # 2. Non-managed and ineligible + # 3. Non-managed and eligible (to test total non_managed counting) + user1 = UserFactory.with_session(db_session).create(id=1, telegram_id=111) + chat_user1 = TelegramChatUserFactory.with_session(db_session).create( + user=user1, chat=chat, is_admin=False, is_managed=True + ) + + user2 = UserFactory.with_session(db_session).create(id=2, telegram_id=222) + chat_user2 = TelegramChatUserFactory.with_session(db_session).create( + user=user2, chat=chat, is_admin=False, is_managed=False + ) + + user3 = UserFactory.with_session(db_session).create(id=3, telegram_id=333) + chat_user3 = TelegramChatUserFactory.with_session(db_session).create( + user=user3, chat=chat, is_admin=False, is_managed=False + ) + + # Mock telegram_chat_user_service yield + action.telegram_chat_user_service = MagicMock() + action.telegram_chat_user_service.yield_all_for_chat.return_value = [ + [chat_user1, chat_user2, chat_user3] + ] + + # Mock evaluate_chat_members_eligibility + action.authorization_action = MagicMock() + + res1 = ChatMemberEligibilityResultDTO( + member=chat_user1, + is_eligible=False, + summary=RulesEligibilitySummaryInternalDTO( + groups=[ + RulesEligibilityGroupSummaryInternalDTO( + id=1, + items=[ + EligibilitySummaryInternalDTO( + id=1, + group_id=1, + type=EligibilityCheckType.TONCOIN, + title="TON", + actual=0.5, + expected=1.0, + is_enabled=True, + ) + ], + ) + ], + wallet="EQD123", + ), + ) + res2 = ChatMemberEligibilityResultDTO( + member=chat_user2, + is_eligible=False, + summary=RulesEligibilitySummaryInternalDTO( + groups=[ + RulesEligibilityGroupSummaryInternalDTO( + id=2, + items=[ + EligibilitySummaryInternalDTO( + id=2, + group_id=2, + type=EligibilityCheckType.JETTON, + title="USDT", + actual=0, + expected=100, + is_enabled=True, + ) + ], + ) + ], + wallet=None, + ), + ) + res3 = ChatMemberEligibilityResultDTO( + member=chat_user3, + is_eligible=True, + summary=RulesEligibilitySummaryInternalDTO(groups=[]), + ) + + action.authorization_action.evaluate_chat_members_eligibility.return_value = [ + res1, + res2, + res3, + ] + + with caplog.at_level(logging.INFO): + await action.check_chat_members_compliance_dry_run(chat_id=1) + + # Check that counts were correctly logged + # Total processed: 3, Non-managed: 2, Ineligible (managed): 1, Ineligible (non-managed): 1 + assert "Total processed: 3" in caplog.text + assert "Non-managed: 2" in caplog.text + assert "Ineligible (managed): 1" in caplog.text + assert "Ineligible (non-managed): 1" in caplog.text + + assert ( + 'User 111 is ineligible for chat 1. Managed: True. Compliance summary: {"groups":[{"id":1,"items":[{"id":1,"group_id":1,"type":"toncoin","title":"TON","address_raw":null,"actual":0.5,"expected":1.0,"is_enabled":true,"category":null,"is_eligible":false}]}],"wallet":"EQD123"}' + in caplog.text + ) + assert ( + 'User 222 is ineligible for chat 1. Managed: False. Compliance summary: {"groups":[{"id":2,"items":[{"id":2,"group_id":2,"type":"jetton","title":"USDT","address_raw":null,"actual":0,"expected":100,"is_enabled":true,"category":null,"is_eligible":false}]}],"wallet":null}' + in caplog.text + ) + + +@pytest.mark.asyncio +async def test_kick_ineligible_chat_members_sets_managed_flag(db_session): + action = CommunityManagerUserChatAction(db_session) + chat = TelegramChatFactory.with_session(db_session).create( + id=1, title="Test Chat", is_full_control=True + ) + + user1 = UserFactory.with_session(db_session).create(id=1, telegram_id=111) + chat_user1 = TelegramChatUserFactory.with_session(db_session).create( + user=user1, chat=chat, is_admin=False, is_managed=False + ) + + action.authorization_action = MagicMock() + + res1 = ChatMemberEligibilityResultDTO( + member=chat_user1, + is_eligible=True, + summary=RulesEligibilitySummaryInternalDTO(groups=[]), + ) + action.authorization_action.evaluate_chat_members_eligibility.return_value = [res1] + + assert chat_user1.is_managed is False + + await action.kick_ineligible_chat_members([chat_user1]) + + assert chat_user1.is_managed is True