Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions backend/api/routes/admin/chat/manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
103 changes: 101 additions & 2 deletions backend/community_manager/actions/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}"
)
15 changes: 15 additions & 0 deletions backend/community_manager/tasks/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 28 additions & 37 deletions backend/core/src/core/actions/authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
EligibilitySummaryStickerCollectionInternalDTO,
EligibilitySummaryJettonInternalDTO,
EligibilitySummaryNftCollectionInternalDTO,
ChatMemberEligibilityResultDTO,
)
from core.dtos.gift.collection import GiftCollectionDTO
from core.dtos.resource import JettonDTO, NftCollectionDTO
Expand Down Expand Up @@ -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 []
Expand Down Expand Up @@ -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(
Expand Down
29 changes: 29 additions & 0 deletions backend/core/src/core/actions/chat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion backend/core/src/core/dtos/chat/rule/internal.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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
Loading