diff --git a/backend/community_manager/actions/chat.py b/backend/community_manager/actions/chat.py index 854d9630..7265687b 100644 --- a/backend/community_manager/actions/chat.py +++ b/backend/community_manager/actions/chat.py @@ -13,7 +13,11 @@ RPCError, ) from telethon.utils import get_peer_id -from aiogram.utils.markdown import text as fmt_text, bold as fmt_bold +from aiogram.utils.markdown import ( + text as fmt_text, + bold as fmt_bold, + markdown_decoration, +) from community_manager.dtos.chat import TargetChatMembersDTO from community_manager.events import ChatAdminChangeEventBuilder @@ -344,7 +348,11 @@ async def _refresh(self, chat: TelegramChat) -> TelegramChat: logger.warning( f"Chat {chat.id!r} has insufficient permissions set. Disabling it..." ) - self.telegram_chat_service.set_insufficient_privileges(chat_id=chat.id) + if not chat.insufficient_privileges: + self.telegram_chat_service.set_insufficient_privileges(chat_id=chat.id) + await self._notify_insufficient_privileges( + chat_id=chat.id, chat_title=chat.title + ) raise except ( @@ -629,12 +637,58 @@ async def on_bot_chat_member_update( self.telegram_chat_service.set_insufficient_privileges( chat_id=chat.id, value=True ) + await self._notify_insufficient_privileges( + chat_id=chat.id, chat_title=chat.title + ) elif chat.insufficient_privileges: logger.info("Sufficient permissions for the bot in chat %d", chat.id) self.telegram_chat_service.set_insufficient_privileges( chat_id=chat.id, value=False ) + async def _notify_insufficient_privileges( + self, chat_id: int, chat_title: str + ) -> None: + try: + managers = ( + self.db_session.query(TelegramChatUser) + .filter( + TelegramChatUser.chat_id == chat_id, + TelegramChatUser.is_manager_admin.is_(True), + ) + .all() + ) + telegram_ids = [m.user.telegram_id for m in managers if m.user] + + if not telegram_ids: + logger.warning(f"No manager admins found for chat {chat_id} to notify.") + return + + text = fmt_text( + "⚠️ ", + fmt_bold("Insufficient Privileges"), + "\n\n", + "The bot no longer has sufficient administrative privileges to manage the chat ", + fmt_bold(chat_title), + " \\(ID: ", + markdown_decoration.quote(str(chat_id)), + "\\)\\.\n\n", + "Please ensure the bot is added as an administrator with the required permissions\\.", + sep="", + ) + async with TelegramBotApiService() as bot_service: + for tg_id in telegram_ids: + try: + await bot_service.send_message(chat_id=tg_id, text=text) + except Exception as e: + logger.warning( + f"Failed to notify manager {tg_id} for chat {chat_id}: {e}" + ) + except Exception as e: + logger.error( + f"Error in _notify_insufficient_privileges for chat {chat_id}: {e}" + ) + async def on_join_request( self, telegram_user_id: int, diff --git a/backend/core/src/core/dtos/chat/__init__.py b/backend/core/src/core/dtos/chat/__init__.py index 6d0dfb03..f25e753b 100644 --- a/backend/core/src/core/dtos/chat/__init__.py +++ b/backend/core/src/core/dtos/chat/__init__.py @@ -34,10 +34,10 @@ class BaseTelegramChatDTO(TelegramChatPreviewDTO): username: str | None is_enabled: bool join_url: str | None = None + insufficient_privileges: bool class TelegramChatDTO(BaseTelegramChatDTO): - insufficient_privileges: bool is_full_control: bool @classmethod @@ -128,6 +128,7 @@ def from_object( join_url=join_url, members_count=members_count, is_enabled=obj.is_enabled, + insufficient_privileges=obj.insufficient_privileges, ) else: return cls( @@ -143,6 +144,7 @@ def from_object( is_enabled=obj.is_enabled, is_eligible=False, join_url=None, + insufficient_privileges=obj.insufficient_privileges, ) diff --git a/backend/core/src/core/migrations/versions/1750345690-ec82e8aa0d67-telegram_chat_rule_group.py b/backend/core/src/core/migrations/versions/1750345690-ec82e8aa0d67-telegram_chat_rule_group.py index 2501dcd5..8457ec3c 100644 --- a/backend/core/src/core/migrations/versions/1750345690-ec82e8aa0d67-telegram_chat_rule_group.py +++ b/backend/core/src/core/migrations/versions/1750345690-ec82e8aa0d67-telegram_chat_rule_group.py @@ -138,6 +138,9 @@ def set_proper_rule_group_id_in_table( for chat_id, group_id in chat_id_group_id_mapping.items() ] + if not params: + return + # Use executemany for better performance with many records connection.execute( sa.text( diff --git a/backend/core/src/core/migrations/versions/1780501788-105b4511d5ca-add_last_activity_to_user_wallet.py b/backend/core/src/core/migrations/versions/1780501788-105b4511d5ca-add_last_activity_to_user_wallet.py new file mode 100644 index 00000000..f514dbfb --- /dev/null +++ b/backend/core/src/core/migrations/versions/1780501788-105b4511d5ca-add_last_activity_to_user_wallet.py @@ -0,0 +1,32 @@ +"""add_last_activity_to_user_wallet + +Revision ID: 105b4511d5ca +Revises: 6c6b45ffe090 +Create Date: 2026-06-03 15:49:48.553736 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = "105b4511d5ca" +down_revision: Union[str, None] = "6c6b45ffe090" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "user_wallet", sa.Column("last_activity", mysql.BIGINT(), nullable=True) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("user_wallet", "last_activity") + # ### end Alembic commands ### diff --git a/backend/core/src/core/models/wallet.py b/backend/core/src/core/models/wallet.py index 78fec0d0..8c1636e9 100644 --- a/backend/core/src/core/models/wallet.py +++ b/backend/core/src/core/models/wallet.py @@ -27,6 +27,11 @@ class UserWallet(Base): address = mapped_column(BlockchainAddressRawField, primary_key=True) user_id = mapped_column(ForeignKey("user.id"), nullable=False) balance = mapped_column(BIGINT, nullable=True, doc="Balance of the wallet in TONs") + last_activity = mapped_column( + BIGINT, + nullable=True, + doc="Last activity timestamp of the wallet on the blockchain", + ) # DEPRECATED attribute hide_wallet = mapped_column(Boolean, default=False, nullable=False) diff --git a/backend/core/src/core/services/wallet.py b/backend/core/src/core/services/wallet.py index b7ecdeab..eee9d791 100644 --- a/backend/core/src/core/services/wallet.py +++ b/backend/core/src/core/services/wallet.py @@ -101,7 +101,9 @@ def turn_visibility_off(self, user_id: int) -> None: ).update({"hide_wallet": True}) self.db_session.flush() - def set_balance(self, address_raw: str, balance: int) -> None: + def set_balance( + self, address_raw: str, balance: int, last_activity: int | None = None + ) -> None: """ Updates the balance for a specific wallet address using the database session. @@ -111,10 +113,15 @@ def set_balance(self, address_raw: str, balance: int) -> None: :param address_raw: The wallet address whose balance needs to be updated. :param balance: The new balance to be set for the given wallet address in nano + :param last_activity: Optional last activity timestamp of the wallet """ + updates = {"balance": balance} + if last_activity is not None: + updates["last_activity"] = last_activity + self.db_session.query(UserWallet).filter( UserWallet.address == address_raw, - ).update({"balance": balance}) + ).update(updates) def count(self) -> int: return self.db_session.query(UserWallet).count() diff --git a/backend/indexer_blockchain/tasks.py b/backend/indexer_blockchain/tasks.py index c935bb72..ed138b39 100644 --- a/backend/indexer_blockchain/tasks.py +++ b/backend/indexer_blockchain/tasks.py @@ -22,14 +22,14 @@ async def get_all_nfts_per_user( - blockchain_service: TonApiService, address: str, nft_collections: list[str] + blockchain_service: TonApiService, address: str ) -> NftItems: nft_items = [] - for collection_address in nft_collections: - async for batch in blockchain_service.get_all_nft_items_for_user( - wallet_address=address, collection_address=collection_address - ): - nft_items.extend(batch.nft_items) + # Query all NFT items in paginated batches + async for batch in blockchain_service.get_all_nft_items_for_user( + wallet_address=address, collection_address=None + ): + nft_items.extend(batch.nft_items) return NftItems(nft_items=nft_items) @@ -46,6 +46,20 @@ def fetch_wallet_details(address: str) -> None: blockchain_service = TonApiService() account_info = asyncio.run(blockchain_service.get_account_info(address)) + current_last_activity = account_info.last_activity + raw_address = account_info.address.to_raw() + + with DBService().db_session() as db_session: + wallet_service = WalletService(db_session) + wallet = wallet_service.get_user_wallet(raw_address) + stored_last_activity = wallet.last_activity if wallet else None + + if stored_last_activity is not None and current_last_activity is not None: + if stored_last_activity == current_last_activity: + logger.info( + f"Skipping wallet {address!r} sync: last_activity has not changed ({current_last_activity})." + ) + return jettons_balances: JettonsBalances = asyncio.run( blockchain_service.get_all_jetton_balances(address) @@ -64,16 +78,25 @@ def fetch_wallet_details(address: str) -> None: get_all_nfts_per_user( blockchain_service=blockchain_service, address=address, - nft_collections=whitelist_collection_addresses, ) ) + # Pre-filter fetched NFT items against whitelisted collections in memory + whitelist_set = set(whitelist_collection_addresses) + filtered_nfts = [ + item + for item in nft_items.nft_items + if item.collection and item.collection.address.to_raw() in whitelist_set + ] + nft_items = NftItems(nft_items=filtered_nfts) + with DBService().db_session() as db_session: wallet_service = WalletService(db_session) wallet_service.set_balance( - account_info.address.to_raw(), + raw_address, # It already contains the balance in nano int(str(account_info.balance)), + last_activity=current_last_activity, ) jetton_service = JettonService(db_session) diff --git a/backend/tests/unit/community_manager/actions/test_insufficient_privileges.py b/backend/tests/unit/community_manager/actions/test_insufficient_privileges.py new file mode 100644 index 00000000..7783ec2f --- /dev/null +++ b/backend/tests/unit/community_manager/actions/test_insufficient_privileges.py @@ -0,0 +1,175 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from community_manager.actions.chat import CommunityManagerChatAction +from community_manager.events import ChatAdminChangeEventBuilder +from core.exceptions.chat import TelegramChatNotSufficientPrivileges +from core.dtos.chat import TelegramChatDTO +from tests.factories import TelegramChatFactory, TelegramChatUserFactory, UserFactory + + +@pytest.fixture +def chat_action(db_session, mocker): + # Only mock external network/infrastructure dependencies + mocker.patch("community_manager.actions.chat.RedisService") + mocker.patch("community_manager.actions.chat.CDNService") + mocker.patch("community_manager.actions.chat.TelethonService") + + action = CommunityManagerChatAction(db_session) + return action + + +@pytest.mark.asyncio +async def test_on_bot_chat_member_update_transitions_to_insufficient( + db_session, chat_action +): + # Create models using existing factories + chat = TelegramChatFactory.with_session(db_session).create( + id=123, + title="Test Chat", + insufficient_privileges=False, + ) + user = UserFactory.with_session(db_session).create( + telegram_id=999, + first_name="Test", + ) + TelegramChatUserFactory.with_session(db_session).create( + chat=chat, + user=user, + is_admin=True, + is_manager_admin=True, + ) + db_session.commit() + + chat_dto = TelegramChatDTO.from_object(chat) + + event = MagicMock(spec=ChatAdminChangeEventBuilder.Event) + event.sufficient_bot_privileges = False + event.new_participant = MagicMock() + + with patch( + "community_manager.actions.chat.TelegramBotApiService" + ) as MockBotService: + mock_bot_service = AsyncMock() + MockBotService.return_value.__aenter__.return_value = mock_bot_service + + await chat_action.on_bot_chat_member_update(event, chat_dto) + + # Verify the database state actually updated + db_session.refresh(chat) + assert chat.insufficient_privileges is True + + # Verify the message was sent to the owner + mock_bot_service.send_message.assert_awaited_once() + _, kwargs = mock_bot_service.send_message.call_args + assert kwargs["chat_id"] == 999 + assert "Insufficient Privileges" in kwargs["text"] + + +@pytest.mark.asyncio +async def test_on_bot_chat_member_update_already_insufficient_no_notify( + db_session, chat_action +): + chat = TelegramChatFactory.with_session(db_session).create( + id=123, + title="Test Chat", + insufficient_privileges=True, + ) + db_session.commit() + + chat_dto = TelegramChatDTO.from_object(chat) + + event = MagicMock(spec=ChatAdminChangeEventBuilder.Event) + event.sufficient_bot_privileges = False + event.new_participant = MagicMock() + + with patch( + "community_manager.actions.chat.TelegramBotApiService" + ) as MockBotService: + mock_bot_service = AsyncMock() + MockBotService.return_value.__aenter__.return_value = mock_bot_service + + await chat_action.on_bot_chat_member_update(event, chat_dto) + + # Insufficient privileges should remain True + db_session.refresh(chat) + assert chat.insufficient_privileges is True + + # No new notification should be sent if it was already insufficient + mock_bot_service.send_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_on_bot_chat_member_update_transitions_to_sufficient( + db_session, chat_action +): + chat = TelegramChatFactory.with_session(db_session).create( + id=123, + title="Test Chat", + insufficient_privileges=True, + ) + db_session.commit() + + chat_dto = TelegramChatDTO.from_object(chat) + + event = MagicMock(spec=ChatAdminChangeEventBuilder.Event) + event.sufficient_bot_privileges = True + event.new_participant = MagicMock() + + with patch( + "community_manager.actions.chat.TelegramBotApiService" + ) as MockBotService: + mock_bot_service = AsyncMock() + MockBotService.return_value.__aenter__.return_value = mock_bot_service + + await chat_action.on_bot_chat_member_update(event, chat_dto) + + # Insufficient privileges should update to False + db_session.refresh(chat) + assert chat.insufficient_privileges is False + + # No warning notification sent when restoring sufficient privileges + mock_bot_service.send_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_refresh_transitions_to_insufficient(db_session, chat_action): + chat = TelegramChatFactory.with_session(db_session).create( + id=123, + title="Test Chat", + insufficient_privileges=False, + ) + user = UserFactory.with_session(db_session).create( + telegram_id=888, + first_name="Test", + ) + TelegramChatUserFactory.with_session(db_session).create( + chat=chat, + user=user, + is_admin=True, + is_manager_admin=True, + ) + db_session.commit() + + chat_action._get_chat_data = AsyncMock( + side_effect=TelegramChatNotSufficientPrivileges("Insufficient privileges") + ) + + with patch( + "community_manager.actions.chat.TelegramBotApiService" + ) as MockBotService: + mock_bot_service = AsyncMock() + MockBotService.return_value.__aenter__.return_value = mock_bot_service + + with pytest.raises(TelegramChatNotSufficientPrivileges): + await chat_action._refresh(chat) + + # Verify the database state actually updated + db_session.refresh(chat) + assert chat.insufficient_privileges is True + + # Verify notification was sent + mock_bot_service.send_message.assert_awaited_once() + _, kwargs = mock_bot_service.send_message.call_args + assert kwargs["chat_id"] == 888 + assert "Insufficient Privileges" in kwargs["text"] diff --git a/backend/tests/unit/indexer_blockchain/test_tasks.py b/backend/tests/unit/indexer_blockchain/test_tasks.py new file mode 100644 index 00000000..55783e86 --- /dev/null +++ b/backend/tests/unit/indexer_blockchain/test_tasks.py @@ -0,0 +1,170 @@ +from contextlib import contextmanager +import pytest +from unittest.mock import AsyncMock, MagicMock +from sqlalchemy.orm import Session + +from core.models.wallet import UserWallet +from pytonapi.schema.jettons import JettonsBalances +from pytonapi.schema.nft import NftItems +from indexer_blockchain.tasks import fetch_wallet_details +from tests.factories.wallet import UserWalletFactory + + +@pytest.fixture(autouse=True) +def mock_redis_service(mocker): + return mocker.patch("indexer_blockchain.tasks.RedisService") + + +@pytest.fixture(autouse=True) +def mock_db_session(db_session, mocker): + @contextmanager + def _mock_session(): + yield db_session + + return mocker.patch( + "indexer_blockchain.tasks.DBService.db_session", side_effect=_mock_session + ) + + +@pytest.mark.usefixtures("db_session") +class TestIndexerTasks: + def test_fetch_wallet_details_initial_sync(self, db_session: Session, mocker): + # 1. Arrange: Create wallet with last_activity = None + raw_address = ( + "0:1111111111111111111111111111111111111111111111111111111111111111" + ) + UserWalletFactory.with_session(db_session).create( + address=raw_address, + last_activity=None, + ) + db_session.commit() + + # Mock TonApiService + mock_service_class = mocker.patch("indexer_blockchain.tasks.TonApiService") + mock_service = mock_service_class.return_value + + # Mock get_account_info + account_info = MagicMock() + account_info.last_activity = 123456 + account_info.balance = 1000000000 + account_info.address.to_raw.return_value = raw_address + mock_service.get_account_info = AsyncMock(return_value=account_info) + + # Mock get_all_jetton_balances + mock_service.get_all_jetton_balances = AsyncMock( + return_value=JettonsBalances(balances=[]) + ) + + # Mock get_all_nft_items_for_user as async generator + async def mock_get_nfts(*args, **kwargs): + yield NftItems(nft_items=[]) + + mock_service.get_all_nft_items_for_user = mock_get_nfts + + # 2. Act + fetch_wallet_details(raw_address) + + # 3. Assert: All external services called, and last_activity updated in DB + mock_service.get_account_info.assert_called_once_with(raw_address) + mock_service.get_all_jetton_balances.assert_called_once_with(raw_address) + + db_session.expire_all() + updated_wallet = ( + db_session.query(UserWallet).filter_by(address=raw_address).one() + ) + assert updated_wallet.last_activity == 123456 + assert updated_wallet.balance == 1000000000 + + def test_fetch_wallet_details_skips_when_last_activity_unchanged( + self, db_session: Session, mocker + ): + # 1. Arrange: Create wallet with last_activity = 123456 + raw_address = ( + "0:2222222222222222222222222222222222222222222222222222222222222222" + ) + UserWalletFactory.with_session(db_session).create( + address=raw_address, + last_activity=123456, + balance=5000000000, + ) + db_session.commit() + + # Mock TonApiService + mock_service_class = mocker.patch("indexer_blockchain.tasks.TonApiService") + mock_service = mock_service_class.return_value + + # Mock get_account_info (same last_activity) + account_info = MagicMock() + account_info.last_activity = 123456 + account_info.balance = 5000000000 + account_info.address.to_raw.return_value = raw_address + mock_service.get_account_info = AsyncMock(return_value=account_info) + + # Mock other methods to ensure we fail if they are called + mock_service.get_all_jetton_balances = AsyncMock() + mock_service.get_all_nft_items_for_user = MagicMock() + + # 2. Act + fetch_wallet_details(raw_address) + + # 3. Assert: get_account_info called, but others skipped + mock_service.get_account_info.assert_called_once_with(raw_address) + mock_service.get_all_jetton_balances.assert_not_called() + mock_service.get_all_nft_items_for_user.assert_not_called() + + db_session.expire_all() + updated_wallet = ( + db_session.query(UserWallet).filter_by(address=raw_address).one() + ) + assert updated_wallet.last_activity == 123456 + assert updated_wallet.balance == 5000000000 + + def test_fetch_wallet_details_syncs_when_last_activity_changed( + self, db_session: Session, mocker + ): + # 1. Arrange: Create wallet with last_activity = 123456 + raw_address = ( + "0:3333333333333333333333333333333333333333333333333333333333333333" + ) + UserWalletFactory.with_session(db_session).create( + address=raw_address, + last_activity=123456, + balance=5000000000, + ) + db_session.commit() + + # Mock TonApiService + mock_service_class = mocker.patch("indexer_blockchain.tasks.TonApiService") + mock_service = mock_service_class.return_value + + # Mock get_account_info (new last_activity = 999999) + account_info = MagicMock() + account_info.last_activity = 999999 + account_info.balance = 6000000000 + account_info.address.to_raw.return_value = raw_address + mock_service.get_account_info = AsyncMock(return_value=account_info) + + # Mock get_all_jetton_balances + mock_service.get_all_jetton_balances = AsyncMock( + return_value=JettonsBalances(balances=[]) + ) + + # Mock get_all_nft_items_for_user as async generator + async def mock_get_nfts(*args, **kwargs): + yield NftItems(nft_items=[]) + + mock_service.get_all_nft_items_for_user = mock_get_nfts + + # 2. Act + fetch_wallet_details(raw_address) + + # 3. Assert: All external services called, and last_activity updated in DB + mock_service.get_account_info.assert_called_once_with(raw_address) + mock_service.get_all_jetton_balances.assert_called_once_with(raw_address) + + db_session.expire_all() + updated_wallet = ( + db_session.query(UserWallet).filter_by(address=raw_address).one() + ) + assert updated_wallet.last_activity == 999999 + assert updated_wallet.balance == 6000000000 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ef4b4e39..2ff738f2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,14 +1,12 @@ import { ThemeContext } from '@context' import '@styles/index.scss' import { TonConnectUIProvider } from '@tonconnect/ui-react' -import { checkIsMobile, checkStartAppParams } from '@utils' +import { checkStartAppParams } from '@utils' import { useContext, useEffect } from 'react' import { useNavigate, useParams } from 'react-router-dom' import config from '@config' -import { AuthService } from '@services' import { useUser, useUserActions } from '@store' -import { useAuthQuery } from '@store-new' import Routes from './Routes' diff --git a/frontend/src/pages/admin/ChatPage/components/ChatHeader/ChatHeader.tsx b/frontend/src/pages/admin/ChatPage/components/ChatHeader/ChatHeader.tsx index dc04c6e1..f07b69c0 100644 --- a/frontend/src/pages/admin/ChatPage/components/ChatHeader/ChatHeader.tsx +++ b/frontend/src/pages/admin/ChatPage/components/ChatHeader/ChatHeader.tsx @@ -86,6 +86,20 @@ export const ChatHeader = () => { )} + {chat?.insufficientPrivileges && ( + + + ⚠️} + text={ + + The bot lacks sufficient privileges to manage this chat. Please ensure the bot is an admin with all permissions. + + } + /> + + + )} { {chat.description} + {chat?.insufficientPrivileges && ( + + + ⚠️} + text={ + + The bot lacks sufficient privileges to manage that chat. + + } + /> + + + )} ) }