├── Makefile ├── data ├── .gitignore └── config_example.json ├── .gitignore ├── .gitmodules ├── Dockerfile ├── requirements.txt ├── liker ├── setup │ ├── daemons.py │ ├── constants.py │ ├── logs.py │ └── dependencies.py ├── run.py ├── command │ ├── params.py │ ├── handler_set_reactions.py │ ├── handler_update_markup.py │ └── handler_take_message.py ├── state │ ├── space_state.py │ ├── channel_state.py │ ├── reaction_hashes.py │ ├── markup_trail.py │ ├── comment_trail.py │ ├── markup_queue.py │ └── enabled_channels.py ├── enabling_manager.py └── custom_markup │ ├── markup_utils.py │ ├── channel_post_handler.py │ ├── comment_handler.py │ └── markup_synchronizer.py ├── requirements_test.txt ├── README.md └── tests └── test_integrated_ping.py /Makefile: -------------------------------------------------------------------------------- 1 | include helpy_files/Makefile 2 | -------------------------------------------------------------------------------- /data/.gitignore: -------------------------------------------------------------------------------- 1 | /config.json* 2 | /state 3 | /logs 4 | /csv_logs 5 | /*.session 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .ipynb_checkpoints 3 | /*.session 4 | /*.session-journal 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "helpy_files"] 2 | path = helpy_files 3 | url = https://github.com/luckybots/helpy_files 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9.2-buster 2 | 3 | COPY requirements.txt /tmp/requirements.txt 4 | RUN pip install -r /tmp/requirements.txt && rm /tmp/requirements.txt 5 | 6 | COPY --chown=$UID liker /app/liker 7 | 8 | WORKDIR /app 9 | ENV PYTHONPATH=/app 10 | 11 | CMD ["python", "liker/run.py"] 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2020.12.5 2 | chardet==4.0.0 3 | idna==2.10 4 | Inject==4.3.1 5 | pyaes==1.6.1 6 | pyasn1==0.4.8 7 | pymitter==0.3.0 8 | pyTelegramBotAPI==3.7.9 9 | python-jsonstore==1.3.0 10 | requests==2.25.1 11 | rsa==4.7.2 12 | Telethon==1.21.1 13 | typeguard==2.12.0 14 | urllib3>=1.26.4 15 | tengi==0.5.6 16 | -------------------------------------------------------------------------------- /liker/setup/daemons.py: -------------------------------------------------------------------------------- 1 | import inject 2 | from tengi import MessagesLogger, AbuseJanitor 3 | 4 | 5 | def create_daemon_instances(): 6 | """ 7 | Create instances that aren't referenced by application tree (starting from App), so they should be created 8 | explicitly 9 | :return: 10 | """ 11 | inject.instance(MessagesLogger) 12 | inject.instance(AbuseJanitor) 13 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | attrs==20.3.0 2 | certifi==2020.12.5 3 | chardet==4.0.0 4 | idna==2.10 5 | iniconfig==1.1.1 6 | Inject==4.3.1 7 | packaging==20.9 8 | pluggy==0.13.1 9 | py==1.10.0 10 | pymitter==0.3.0 11 | pyparsing==2.4.7 12 | pyTelegramBotAPI==3.7.6 13 | pytest==6.2.2 14 | python-dateutil==2.8.1 15 | python-jsonstore==1.3.0 16 | requests==2.25.1 17 | six==1.15.0 18 | toml==0.10.2 19 | urllib3>=1.26.4 20 | tengi 21 | -------------------------------------------------------------------------------- /liker/run.py: -------------------------------------------------------------------------------- 1 | # noinspection PyUnresolvedReferences 2 | from liker.setup import logs # has import side effect 3 | import inject 4 | from tengi import App 5 | 6 | from liker.setup.dependencies import bind_app_dependencies 7 | from liker.setup.daemons import create_daemon_instances 8 | 9 | 10 | @inject.autoparams() 11 | def main(): 12 | inject.configure(bind_app_dependencies) 13 | create_daemon_instances() 14 | 15 | app = inject.instance(App) 16 | app.run() 17 | 18 | 19 | if __name__ == '__main__': 20 | main() 21 | -------------------------------------------------------------------------------- /liker/command/params.py: -------------------------------------------------------------------------------- 1 | from tengi import CommandParam, tengi_command_params 2 | 3 | command_params = tengi_command_params.params + [ 4 | CommandParam(name='--channel_id', 5 | help_str='Channel id', 6 | param_type=str), 7 | CommandParam(name='--reactions', 8 | help_str='Reaction array separated with " "', 9 | param_type=str, 10 | nargs='+'), 11 | CommandParam(name='--message_id', 12 | help_str='Message id', 13 | param_type=int), 14 | CommandParam(name='--bot_token', 15 | help_str='Bot token', 16 | param_type=str), 17 | CommandParam(name='--n', 18 | help_str='N items to take', 19 | param_type=int), 20 | ] 21 | -------------------------------------------------------------------------------- /liker/state/space_state.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import inject 3 | from pathlib import Path 4 | from typing import Dict 5 | from typeguard import typechecked 6 | 7 | from liker.state.enabled_channels import EnabledChannels 8 | from liker.state.channel_state import ChannelState 9 | 10 | logger = logging.getLogger(__file__) 11 | 12 | 13 | class SpaceState: 14 | enabled_channels = inject.attr(EnabledChannels) 15 | 16 | def __init__(self, state_dir: Path): 17 | self.state_dir = state_dir 18 | self.channels: Dict[str, ChannelState] = {} 19 | 20 | @typechecked 21 | def ensure_channel_state(self, str_channel_id: str) -> ChannelState: 22 | if str_channel_id not in self.channels: 23 | self.channels[str_channel_id] = ChannelState(state_dir=self.state_dir, 24 | str_channel_id=str_channel_id) 25 | return self.channels[str_channel_id] 26 | 27 | def update(self): 28 | for ch in self.channels.values(): 29 | ch.update() 30 | -------------------------------------------------------------------------------- /liker/state/channel_state.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import inject 3 | from tengi.state.timed_preserver import TimedPreserver 4 | from tengi import Config 5 | 6 | from liker.state.reaction_hashes import ReactionHashes 7 | from liker.state.markup_queue import MarkupQueue 8 | from liker.state.markup_trail import MarkupTrail 9 | from liker.state.comment_trail import CommentTrail 10 | 11 | 12 | class ChannelState(TimedPreserver): 13 | config = inject.attr(Config) 14 | 15 | def __init__(self, state_dir: Path, str_channel_id: str): 16 | state_path = state_dir / f'{str_channel_id}.json' 17 | save_period = self.config['channel_state_save_seconds'] 18 | super().__init__(state_path, save_period=save_period) 19 | 20 | last_reactions_path = state_dir / f'{str_channel_id}_last_reactions.json' 21 | self.last_reactions = ReactionHashes(last_reactions_path) 22 | 23 | self.markup_queue = MarkupQueue(self.state) 24 | self.markup_trail = MarkupTrail(self.state) 25 | self.comment_trail = CommentTrail(self.state) 26 | 27 | def update(self): 28 | super().update() 29 | self.last_reactions.update() 30 | -------------------------------------------------------------------------------- /liker/state/reaction_hashes.py: -------------------------------------------------------------------------------- 1 | import inject 2 | from tengi import Config 3 | from tengi.state.timed_preserver import * 4 | 5 | 6 | class ReactionHashes(TimedPreserver): 7 | config = inject.attr(Config) 8 | 9 | def __init__(self, state_file_path: Path): 10 | save_period = self.config['last_reactions_save_seconds'] 11 | super().__init__(state_file_path, save_period=save_period) 12 | 13 | def has(self, reaction_hash: str) -> bool: 14 | if 'hashes' not in self.state: 15 | return False 16 | 17 | hashes = self.state['hashes'] 18 | return reaction_hash in hashes 19 | 20 | def save(self, hashes): 21 | n_to_keep = self.config['last_reactions'] 22 | hashes = hashes[:n_to_keep] 23 | self.state['hashes'] = hashes 24 | 25 | def add(self, reaction_hash: str): 26 | if 'hashes' not in self.state: 27 | self.state['hashes'] = [] 28 | hashes = self.state['hashes'] 29 | hashes.insert(0, reaction_hash) 30 | self.save(hashes) 31 | 32 | def remove(self, reaction_hash: str): 33 | if 'hashes' not in self.state: 34 | return 35 | hashes = self.state['hashes'] 36 | hashes = [x for x in hashes if x != reaction_hash] 37 | self.save(hashes) 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Liker 2 | Liker is a Telegram bot that allows you to add reactions (likes, etc.) to channel posts. Liker also supports post comments. 3 | 4 | ## How to use Liker 5 | You can either use an existing Liker bot available at https://t.me/liker10_bot. In order to do that: 6 | 7 | 1. Give Liker bot an admin permission to edit posts in your channel. 8 | 9 | 2. If you have a discussion group (e.g. post comments) — add Liker to the group also. 10 | 11 | 3. In order to customize reactions (👍 is a default reaction) send the following command to Liker in Telegram: 12 | ``` 13 | /set_reactions —channel_id [YOUR_CHANNEL_ID] —reactions [SPACE_SEPARATED_REACTIONS] 14 | ``` 15 | For example: 16 | ``` 17 | /set_reactions —channel_id @awesome_channel —reactions 👍 ❤ 😡 18 | ``` 19 | 20 | ## Build liker from sources 21 | To build your own version of Liker: 22 | 23 | 1. Download the source code 24 | ``` 25 | git clone --recurse-submodules https://github.com/luckybots/liker.git 26 | ``` 27 | If you don't add `--recurse-submodules` -- you'll get an error during `make build` (make: *** No rule to make target 'build') 28 | 29 | 2. Create and customize `data/config.json` according to `data/config_example.json`. 30 | In the `config.json` provide `bot_token` value. 31 | 32 | 3. To run with Docker use 33 | ``` 34 | make build 35 | make run-it 36 | ``` 37 | -------------------------------------------------------------------------------- /data/config_example.json: -------------------------------------------------------------------------------- 1 | { 2 | "protected_vars": [ 3 | "hash_salt", 4 | "hash_trim_bytes", 5 | "admin_password", 6 | "remembered_passwords" 7 | ], 8 | "hash_salt": "", 9 | "hash_bytes": 10, 10 | "bot_token": "", 11 | "use_telegram_user_api": false, 12 | "telegram_api_session": "liker", 13 | "telegram_api_id": 0, 14 | "telegram_api_hash": "", 15 | "admin_password": "", 16 | "remembered_passwords": { 17 | }, 18 | "enable_only_for": [], 19 | "last_reactions": 10000, 20 | "last_reactions_save_seconds": 10, 21 | "channel_rate_per_minute": 20, 22 | "channel_rate_min_seconds": 1, 23 | "global_rate_per_second": 30, 24 | "reply_markup_trail": 100, 25 | "comment_trail": 100, 26 | "channel_state_save_seconds": 2, 27 | "response_start": "Liker bot allows you to add reactions (likes, etc.) to channel posts. Give Liker bot an admin permission to edit posts in your channel. Use /help to get more information.", 28 | "response_help": "Give Liker bot an admin permission to edit posts in your channel. Use /set_reactions command to customize channel post reactions", 29 | "response_reaction_added": "{}", 30 | "response_reaction_removed": "{} removed", 31 | "response_command_parser_error": "Unknown or incomplete command {command}", 32 | "response_unknown_command": "Unknown or incomplete command {command}" 33 | } -------------------------------------------------------------------------------- /liker/state/markup_trail.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import inject 3 | from jsonstore import JsonStore 4 | from typing import Optional 5 | from typeguard import typechecked 6 | from tengi import Config 7 | 8 | logger = logging.getLogger(__file__) 9 | 10 | 11 | class MarkupTrail: 12 | config = inject.attr(Config) 13 | 14 | def __init__(self, state: JsonStore): 15 | self.state = state 16 | 17 | def ensure_trail(self): 18 | if 'markup_trail' not in self.state: 19 | self.state['markup_trail'] = {} 20 | return self.state['markup_trail'] 21 | 22 | def update_trail(self, trail): 23 | self.state['markup_trail'] = trail 24 | 25 | @typechecked 26 | def add(self, str_message_id: str, str_markup: str): 27 | trail = self.ensure_trail() 28 | if str_message_id in trail: 29 | del trail[str_message_id] 30 | trail_max_len = self.config['reply_markup_trail'] 31 | trail_items = [(str_message_id, str_markup)] + list(trail.items()) 32 | trail_items = trail_items[:trail_max_len] 33 | trail = dict(trail_items) 34 | self.update_trail(trail) 35 | 36 | @typechecked 37 | def try_get(self, str_message_id: str) -> Optional[str]: 38 | trail = self.ensure_trail() 39 | str_markup = trail.get(str_message_id, None) 40 | return str_markup 41 | -------------------------------------------------------------------------------- /liker/state/comment_trail.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import inject 3 | from jsonstore import JsonStore 4 | from typing import Optional 5 | from typeguard import typechecked 6 | from tengi import Config 7 | 8 | logger = logging.getLogger(__file__) 9 | 10 | 11 | class CommentTrail: 12 | config = inject.attr(Config) 13 | 14 | def __init__(self, state: JsonStore): 15 | self.state = state 16 | 17 | def ensure_trail(self): 18 | if 'comment_trail' not in self.state: 19 | self.state['comment_trail'] = {} 20 | return self.state['comment_trail'] 21 | 22 | def update_trail(self, trail): 23 | self.state['comment_trail'] = trail 24 | 25 | @typechecked 26 | def add(self, str_message_id: str, comment_dict: dict): 27 | trail = self.ensure_trail() 28 | if str_message_id in trail: 29 | del trail[str_message_id] 30 | trail_max_len = self.config['comment_trail'] 31 | trail_items = [(str_message_id, comment_dict)] + list(trail.items()) 32 | trail_items = trail_items[:trail_max_len] 33 | trail = dict(trail_items) 34 | self.update_trail(trail) 35 | 36 | @typechecked 37 | def try_get(self, str_message_id: str) -> Optional[dict]: 38 | trail = self.ensure_trail() 39 | comment_dict = trail.get(str_message_id, None) 40 | return comment_dict 41 | -------------------------------------------------------------------------------- /liker/setup/constants.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import sys 3 | 4 | LOG_FORMAT = '%(module)-12s: %(asctime)s %(levelname)s %(message)s' 5 | 6 | APP_DIR = Path(sys.modules['__main__'].__file__).parent.parent 7 | 8 | UPDATE_SECONDS = 1 9 | RESTART_SECONDS = 10 10 | 11 | BOT_LOOK_BACK_DAYS = 1 12 | LONG_POLLING_TIMEOUT = 1 13 | 14 | MESSAGES_LOG_PREFIX = 'messages' 15 | MESSAGES_LOG_CHAT_TYPES = ['private'] 16 | 17 | CHANNEL_POST_HANDLER = 'cph' 18 | 19 | DEFAULT_REACTIONS = ['👍'] 20 | COMMENT_TEXT = '💬' 21 | 22 | REACTION_HASH_BYTES = 4 23 | 24 | ABUSE_PERIOD_SECONDS = 600 25 | ABUSE_THRESHOLD = 1000 26 | ABUSE_JANITOR_SECONDS = 1 * 60 * 60 27 | 28 | TAKE_MESSAGE_VERBOSE_N = 10 29 | 30 | 31 | def data_dir(): 32 | return APP_DIR/'data' 33 | 34 | 35 | def state_dir(): 36 | return data_dir()/'state' 37 | 38 | 39 | def config_path(): 40 | return data_dir()/'config.json' 41 | 42 | 43 | def config_example_path(): 44 | return data_dir()/'config_example.json' 45 | 46 | 47 | def log_dir(): 48 | return data_dir()/'logs' 49 | 50 | 51 | def csv_log_dir(): 52 | return data_dir()/'csv_logs' 53 | 54 | 55 | def messages_log_dir(): 56 | return csv_log_dir()/'messages' 57 | 58 | 59 | def chat_ids_state_path(): 60 | return state_dir()/'chat_ids.json' 61 | 62 | 63 | def enabled_channels_state_path(): 64 | return state_dir()/'enabled_channels.json' 65 | 66 | 67 | def space_dir(): 68 | return state_dir()/'space' 69 | -------------------------------------------------------------------------------- /liker/state/markup_queue.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional 3 | from typeguard import typechecked 4 | from jsonstore import JsonStore 5 | 6 | logger = logging.getLogger(__file__) 7 | 8 | 9 | class MarkupQueue: 10 | def __init__(self, state: JsonStore): 11 | self.state = state 12 | 13 | def ensure_queue(self) -> dict: 14 | if 'markup_queue' not in self.state: 15 | self.state['markup_queue'] = {} 16 | return self.state['markup_queue'] 17 | 18 | def update_queue(self, markup_queue: dict): 19 | self.state['markup_queue'] = markup_queue 20 | 21 | @typechecked 22 | def add(self, str_message_id: str, str_markup: str, to_top: bool): 23 | queue = self.ensure_queue() 24 | if to_top: 25 | if str_message_id in queue: 26 | del queue[str_message_id] 27 | queue = dict([(str_message_id, str_markup)] + list(queue.items())) 28 | else: 29 | queue[str_message_id] = str_markup 30 | self.update_queue(queue) 31 | 32 | @typechecked 33 | def try_remove(self, str_message_id: str): 34 | queue = self.ensure_queue() 35 | if str_message_id in queue: 36 | del queue[str_message_id] 37 | self.update_queue(queue) 38 | 39 | @typechecked 40 | def try_get(self, str_message_id: str) -> Optional[str]: 41 | queue = self.ensure_queue() 42 | str_markup = queue.get(str_message_id, None) 43 | return str_markup 44 | -------------------------------------------------------------------------------- /tests/test_integrated_ping.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | import pytest 3 | from pathlib import Path 4 | from telebot import TeleBot 5 | from tengi.app import App 6 | from tengi.tests.test_utils import get_telegram_message_update 7 | 8 | 9 | @pytest.fixture() 10 | def project_data_path(): 11 | return Path(__file__).parent.parent/'data' 12 | 13 | 14 | def mock_get_updates(self, offset=None, limit=None, timeout=20, allowed_updates=None, long_polling_timeout = 20): 15 | if offset is None: # on the first look_back call 16 | return [] 17 | else: 18 | ping_u = get_telegram_message_update() 19 | ping_u.message.text = '/ping' 20 | return [ping_u] 21 | 22 | 23 | def test_integrated_ping(tmp_path, project_data_path, caplog): 24 | import logging 25 | caplog.set_level(logging.DEBUG) 26 | 27 | with patch('liker.setup.constants.APP_DIR', tmp_path), \ 28 | patch('liker.setup.constants.config_path', return_value=project_data_path / 'config_example.json'), \ 29 | patch('liker.setup.constants.config_example_path', return_value=project_data_path / 'config_example.json'), \ 30 | patch.object(App, 'should_interrupt', return_value=True), \ 31 | patch.object(TeleBot, 'get_updates', new=mock_get_updates), \ 32 | patch.object(TeleBot, 'send_message') as send_message: 33 | 34 | from liker.run import main 35 | 36 | main() 37 | 38 | send_message.assert_called_once() 39 | assert ('pong' in send_message.call_args.args) or \ 40 | ('pong' in send_message.call_args.kwargs.values()) 41 | -------------------------------------------------------------------------------- /liker/command/handler_set_reactions.py: -------------------------------------------------------------------------------- 1 | import inject 2 | import logging 3 | from tengi.command.command_handler import * 4 | from tengi import telegram_bot_utils 5 | 6 | from liker.state.enabled_channels import EnabledChannels 7 | from liker.enabling_manager import EnablingManager 8 | 9 | logger = logging.getLogger(__file__) 10 | 11 | 12 | class CommandHandlerSetReactions(CommandHandler): 13 | enabled_channels = inject.attr(EnabledChannels) 14 | enabling_manager = inject.attr(EnablingManager) 15 | 16 | def get_cards(self) -> Iterable[CommandCard]: 17 | return [CommandCard(command_str='/set_reactions', 18 | description='Set reactions for the given channel', 19 | is_admin=False), 20 | ] 21 | 22 | def handle(self, context: CommandContext): 23 | if context.command == '/set_reactions': 24 | channel_id = context.get_mandatory_arg('channel_id') 25 | reactions = context.get_mandatory_arg('reactions') 26 | 27 | if not telegram_bot_utils.is_proper_chat_id(channel_id): 28 | context.reply('channel_id should be a number or start from @', 29 | log_level=logging.INFO) 30 | return 31 | 32 | set_successfully = self.enabling_manager.try_set_reactions( 33 | channel_id=channel_id, 34 | reactions=reactions, 35 | reply_context=context, 36 | sender_id_to_check=context.sender_message.from_user.id) 37 | if not set_successfully: 38 | return 39 | 40 | context.reply(f'For {channel_id} reactions are {reactions}. Will be applied to new messages.', 41 | log_level=logging.INFO) 42 | else: 43 | raise ValueError(f'Unhandled command: {context.command}') 44 | -------------------------------------------------------------------------------- /liker/setup/logs.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging.config 3 | 4 | from liker.setup import constants 5 | 6 | 7 | class UTCFormatter(logging.Formatter): 8 | converter = time.gmtime 9 | 10 | 11 | def setup_logs(): 12 | constants.log_dir().mkdir(exist_ok=True, parents=True) 13 | logging_config = { 14 | 'version': 1, 15 | 'disable_existing_loggers': True, 16 | 'formatters': { 17 | 'standard': { 18 | '()': UTCFormatter, 19 | 'format': constants.LOG_FORMAT 20 | }, 21 | }, 22 | 'handlers': { 23 | 'console': { 24 | 'level': 'INFO', 25 | 'formatter': 'standard', 26 | 'class': 'logging.StreamHandler', 27 | 'stream': 'ext://sys.stdout', 28 | }, 29 | 'file': { 30 | 'level': 'INFO', 31 | 'formatter': 'standard', 32 | 'class': 'logging.handlers.TimedRotatingFileHandler', 33 | 'filename': constants.log_dir() / "log.log", 34 | 'when': "midnight", 35 | 'backupCount': 30, 36 | }, 37 | 'error': { 38 | 'level': 'ERROR', 39 | 'formatter': 'standard', 40 | 'class': 'logging.handlers.RotatingFileHandler', 41 | 'filename': constants.log_dir() / "errors.log", 42 | 'maxBytes': 1*1024*1024, 43 | 'backupCount': 1, 44 | }, 45 | }, 46 | 'loggers': { 47 | '': { 48 | 'handlers': ['console', 'file', 'error'], 49 | 'level': 'DEBUG', 50 | 'propagate': True, 51 | }, 52 | 'tengi': { 53 | 'handlers': ['console', 'file', 'error'], 54 | 'level': 'DEBUG', 55 | 'propagate': True, 56 | }, 57 | # Mute mtprotosender logs 58 | 'telethon': { 59 | 'propagate': False, 60 | }, 61 | }, 62 | } 63 | 64 | logging.config.dictConfig(logging_config) 65 | 66 | 67 | setup_logs() 68 | -------------------------------------------------------------------------------- /liker/state/enabled_channels.py: -------------------------------------------------------------------------------- 1 | from typeguard import typechecked 2 | from typing import List, Optional 3 | from tengi.state.preserver import * 4 | from tengi import telegram_bot_utils 5 | 6 | 7 | class EnabledChannels(Preserver): 8 | def __init__(self, state_file_path: Path): 9 | super().__init__(state_file_path) 10 | 11 | @typechecked 12 | def is_enabled(self, str_channel_id: str): 13 | return str_channel_id in self.state 14 | 15 | @typechecked 16 | def get_channel_dict(self, str_channel_id: str) -> dict: 17 | assert self.is_enabled(str_channel_id) 18 | return self.state[str_channel_id] 19 | 20 | @typechecked 21 | def set_channel_dict(self, str_channel_id: str, channel_dict: dict): 22 | if not telegram_bot_utils.is_int_chat_id(str_channel_id): 23 | raise ValueError('str_channel_id should be a number') 24 | self.state[str_channel_id] = channel_dict 25 | 26 | @typechecked 27 | def update_channel_dict(self, 28 | str_channel_id: str, 29 | reactions: List[str], 30 | linked_chat_id: Optional[int]): 31 | new_fields = { 32 | 'reactions': reactions, 33 | 'linked_chat_id': linked_chat_id 34 | } 35 | if self.is_enabled(str_channel_id): 36 | channel_dict = self.get_channel_dict(str_channel_id) 37 | channel_dict.update(new_fields) 38 | else: 39 | channel_dict = new_fields 40 | self.set_channel_dict(str_channel_id=str_channel_id, channel_dict=channel_dict) 41 | 42 | def disable_channel(self, str_channel_id: str): 43 | assert self.is_enabled(str_channel_id) 44 | with self.state: 45 | del self.state[str_channel_id] 46 | 47 | def enabled_channel_ids(self) -> List[int]: 48 | str_arr = self.state.__dict__['_data'].keys() 49 | arr = [int(x) for x in str_arr] 50 | return arr 51 | 52 | def try_get_channel_id_for_linked_chat_id(self, linked_chat_id) -> Optional[int]: 53 | result = None 54 | for ch_id in self.enabled_channel_ids(): 55 | ch_dict = self.get_channel_dict(str(ch_id)) 56 | if ch_dict['linked_chat_id'] == linked_chat_id: 57 | result = ch_id 58 | break 59 | return result 60 | -------------------------------------------------------------------------------- /liker/enabling_manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import inject 3 | from typing import Optional 4 | from telebot.apihelper import ApiTelegramException 5 | from tengi import Config, telegram_bot_utils, TelegramBot, ReplyContext 6 | 7 | from liker.state.enabled_channels import EnabledChannels 8 | 9 | logger = logging.getLogger(__file__) 10 | 11 | 12 | class EnablingManager: 13 | config = inject.attr(Config) 14 | telegram_bot = inject.attr(TelegramBot) 15 | enabled_channels = inject.attr(EnabledChannels) 16 | 17 | def try_set_reactions(self, 18 | channel_id, 19 | reactions: list, 20 | reply_context: ReplyContext, 21 | sender_id_to_check: Optional[int]) -> bool: 22 | enable_only_for = self.config['enable_only_for'] 23 | if enable_only_for and (telegram_bot_utils.to_int_chat_id_if_possible(channel_id) not in enable_only_for): 24 | reply_context.reply(f'Cannot enable for channel {channel_id}') 25 | return False 26 | 27 | try: 28 | channel_info = self.telegram_bot.bot.get_chat(channel_id) 29 | 30 | # Check sender user is an admin in the target chat 31 | if sender_id_to_check is not None: 32 | channel_admins = self.telegram_bot.bot.get_chat_administrators(channel_id) 33 | sender_is_admin = any([a.user.id == sender_id_to_check for a in channel_admins]) 34 | if not sender_is_admin: 35 | reply_context.reply( 36 | 'Cannot set reactions for the given chat as the sender is not an admin in there', 37 | log_level=logging.INFO) 38 | return False 39 | except ApiTelegramException: 40 | logging.info('Cannot get channel info, bot is not an admin in there') 41 | reply_context.reply(f'Add bot as an administrator to {channel_id}') 42 | return False 43 | 44 | 45 | 46 | channel_id_int = channel_info.id 47 | 48 | linked_chat_id = channel_info.linked_chat_id 49 | self.enabled_channels.update_channel_dict(str_channel_id=str(channel_id_int), 50 | reactions=reactions, 51 | linked_chat_id=linked_chat_id) 52 | return True 53 | -------------------------------------------------------------------------------- /liker/command/handler_update_markup.py: -------------------------------------------------------------------------------- 1 | import inject 2 | import logging 3 | from tengi.command.command_handler import * 4 | from telebot.types import InlineKeyboardMarkup, Message 5 | from typing import Tuple 6 | from tengi import CommandMissingArgError 7 | 8 | from liker.state.enabled_channels import EnabledChannels 9 | from liker.state.space_state import SpaceState 10 | from liker.custom_markup import markup_utils 11 | from liker.setup import constants 12 | from liker.custom_markup.markup_synchronizer import MarkupSynchronizer 13 | 14 | logger = logging.getLogger(__file__) 15 | 16 | 17 | class CommandHandlerUpdateMarkup(CommandHandler): 18 | enabled_channels = inject.attr(EnabledChannels) 19 | space_state = inject.attr(SpaceState) 20 | markup_synchronizer = inject.attr(MarkupSynchronizer) 21 | 22 | def get_cards(self) -> Iterable[CommandCard]: 23 | return [CommandCard(command_str='/update_markup', 24 | description='Set buttons according to reactions enabled', 25 | is_admin=True), 26 | CommandCard(command_str='/force_counter', 27 | description='Set custom value for the reactions counter', 28 | is_admin=True), 29 | ] 30 | 31 | def handle(self, context: CommandContext): 32 | if context.command == '/update_markup': 33 | channel_id, channel_message_id = self._get_root_message_info(context) 34 | str_trail_markup = self.space_state \ 35 | .ensure_channel_state(str(channel_id)) \ 36 | .markup_trail \ 37 | .try_get(str(channel_message_id)) 38 | trail_markup = None if (str_trail_markup is None) else InlineKeyboardMarkup.de_json(str_trail_markup) 39 | channel_dict = self.enabled_channels.get_channel_dict(str(channel_id)) 40 | enabled_reactions = channel_dict['reactions'] 41 | reply_markup = markup_utils.extend_reply_markup(current_markup=trail_markup, 42 | enabled_reactions=enabled_reactions, 43 | handler=constants.CHANNEL_POST_HANDLER, 44 | case_id='') 45 | self.markup_synchronizer.add(channel_id=channel_id, 46 | message_id=channel_message_id, 47 | reply_markup=reply_markup, 48 | to_top=True) 49 | context.reply('Markup change scheduled', log_level=logging.INFO) 50 | 51 | elif context.command == '/force_counter': 52 | var_name = context.get_mandatory_arg('name') 53 | var_value_int = context.get_mandatory_arg('value', cast_func=int) 54 | 55 | channel_id, channel_message_id = self._get_root_message_info(context) 56 | 57 | markup_trail = self.space_state.ensure_channel_state(str(channel_id)).markup_trail 58 | reply_markup_str = markup_trail.try_get(str(channel_message_id)) 59 | if reply_markup_str is None: 60 | context.reply('Markup is not cached, press a reaction button first') 61 | return 62 | 63 | reply_markup = InlineKeyboardMarkup.de_json(reply_markup_str) 64 | markup_utils.change_reaction_counter(reply_markup, reaction=var_name, value=var_value_int, is_delta=False) 65 | self.markup_synchronizer.add(channel_id=channel_id, 66 | message_id=channel_message_id, 67 | reply_markup=reply_markup, 68 | to_top=True) 69 | context.reply('Markup change scheduled', log_level=logging.INFO) 70 | else: 71 | raise ValueError(f'Unhandled command: {context.command}') 72 | 73 | def _get_root_message_info(self, context: CommandContext) -> Tuple[int, int]: 74 | ref_message: Message = context.sender_message.reply_to_message 75 | if (ref_message is None) or (ref_message.forward_from_chat is None): 76 | context.reply(f'Send {context.command} in comments to target channel post') 77 | raise CommandMissingArgError(f'Command {context.command} sent not as a reply to channel post') 78 | 79 | channel_id = ref_message.forward_from_chat.id 80 | if not self.enabled_channels.is_enabled(str(channel_id)): 81 | context.reply('Liker is not enabled for the given channel') 82 | raise CommandMissingArgError(f'Command {context.command} sent for not enabled channel') 83 | 84 | channel_message_id = ref_message.forward_from_message_id 85 | return channel_id, channel_message_id 86 | -------------------------------------------------------------------------------- /liker/custom_markup/markup_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from telebot import types 3 | from typing import Iterable, List, Optional 4 | from tengi import telegram_bot_utils 5 | 6 | from liker.setup import constants 7 | 8 | logger = logging.getLogger(__file__) 9 | 10 | 11 | def chunks(lst, n): 12 | """Yield successive n-sized chunks from lst.""" 13 | for i in range(0, len(lst), n): 14 | yield lst[i:i + n] 15 | 16 | 17 | def _num_str_to_number(num_str): 18 | result = None 19 | if num_str == '': 20 | result = 0 21 | else: 22 | try: 23 | result = int(num_str) 24 | except ValueError: 25 | pass 26 | return result 27 | 28 | 29 | def extend_reply_markup(current_markup: Optional[types.InlineKeyboardMarkup], 30 | enabled_reactions: list, 31 | handler: str, 32 | case_id: str, 33 | include_comment=True) -> types.InlineKeyboardMarkup: 34 | current_buttons: List[types.InlineKeyboardButton] = [] if (current_markup is None) \ 35 | else list(iterate_markup_buttons(current_markup)) 36 | 37 | if include_comment \ 38 | and (constants.COMMENT_TEXT not in enabled_reactions) \ 39 | and any((b for b in current_buttons if constants.COMMENT_TEXT in b.text)): 40 | enabled_reactions = enabled_reactions.copy() 41 | enabled_reactions.append(constants.COMMENT_TEXT) 42 | 43 | buttons_obj = [] 44 | for r in enabled_reactions: 45 | cur_btn = next((b for b in current_buttons if r in b.text), None) 46 | if cur_btn is None: 47 | text = f'{r}' 48 | data = telegram_bot_utils.encode_button_data(handler=handler, 49 | case_id=case_id, 50 | response=r) 51 | cur_btn = types.InlineKeyboardButton(text=text, 52 | callback_data=data) 53 | buttons_obj.append(cur_btn) 54 | return markup_from_buttons(buttons_obj) 55 | 56 | 57 | def assign_reaction_buttons_data(markup: Optional[types.InlineKeyboardMarkup], 58 | handler: str, 59 | case_id: str) -> None: 60 | for btn in iterate_markup_buttons(markup): 61 | if btn.url is not None: 62 | continue 63 | 64 | restored_reaction = btn.text.rstrip('0123456789-') 65 | data = telegram_bot_utils.encode_button_data(handler=handler, 66 | case_id=case_id, 67 | response=restored_reaction) 68 | btn.callback_data = data 69 | 70 | 71 | def change_reaction_counter(reply_markup: types.InlineKeyboardMarkup, reaction: str, value: int, is_delta: bool): 72 | for btn in iterate_markup_buttons(reply_markup): 73 | if reaction in btn.text: 74 | prefix = btn.text.rstrip('0123456789-') 75 | 76 | if is_delta: 77 | old_num_str = btn.text.replace(prefix, '') 78 | old_num = _num_str_to_number(old_num_str) 79 | if old_num is None: 80 | logger.error(f'Cannot parse button reaction state: {btn.text}') 81 | continue 82 | num = old_num + value 83 | else: 84 | num = value 85 | new_num_str = '' if (num == 0) else f'{num}' 86 | 87 | t_new = f'{prefix}{new_num_str}' 88 | btn.text = t_new 89 | return 90 | raise Exception(f'Can not change reaction counter: {reply_markup.to_json()}') 91 | 92 | 93 | def iterate_markup_buttons(reply_markup: types.InlineKeyboardMarkup) -> Iterable[types.InlineKeyboardButton]: 94 | for row in reply_markup.keyboard: 95 | for btn in row: 96 | yield btn 97 | 98 | 99 | def markup_from_buttons(buttons: Iterable[types.InlineKeyboardButton]) -> types.InlineKeyboardMarkup: 100 | buttons = list(buttons) 101 | if len(buttons) == 4: 102 | rows = chunks(buttons, 2) 103 | else: 104 | rows = chunks(buttons, 3) 105 | reply_markup = types.InlineKeyboardMarkup() 106 | for r in rows: 107 | reply_markup.add(*r) 108 | return reply_markup 109 | 110 | 111 | def add_url_button_to_markup(reply_markup: types.InlineKeyboardMarkup, 112 | text: str, 113 | url: str): 114 | new_b = types.InlineKeyboardButton(text, url) 115 | new_buttons = list(iterate_markup_buttons(reply_markup)) + [new_b] 116 | return markup_from_buttons(new_buttons) 117 | 118 | 119 | def markup_has_button(reply_markup: types.InlineKeyboardMarkup, text: str): 120 | result = False 121 | for btn in iterate_markup_buttons(reply_markup): 122 | result = text in btn.text 123 | if result: 124 | break 125 | return result 126 | 127 | -------------------------------------------------------------------------------- /liker/setup/dependencies.py: -------------------------------------------------------------------------------- 1 | from inject import Binder 2 | import inject 3 | from tengi import * 4 | 5 | from liker.setup import constants 6 | from liker.command.params import command_params 7 | from liker.state.enabled_channels import EnabledChannels 8 | from liker.state.space_state import SpaceState 9 | from liker.custom_markup.markup_synchronizer import MarkupSynchronizer 10 | from liker.custom_markup.channel_post_handler import ChannelPostHandler 11 | from liker.custom_markup.comment_handler import CommentHandler 12 | from liker.command.handler_set_reactions import CommandHandlerSetReactions 13 | from liker.enabling_manager import EnablingManager 14 | from liker.command.handler_update_markup import CommandHandlerUpdateMarkup 15 | from liker.command.handler_take_message import CommandHandlerTakeMessage 16 | 17 | 18 | def bind_app_dependencies(binder: Binder): 19 | binder.bind_to_constructor(App, lambda: App(update_funcs=[inject.instance(TelegramInboxHub).update, 20 | inject.instance(MarkupSynchronizer).update, 21 | inject.instance(AbuseJanitor).update, 22 | inject.instance(SpaceState).update], 23 | update_seconds=constants.UPDATE_SECONDS, 24 | restart_seconds=constants.RESTART_SECONDS)) 25 | binder.bind(Config, Config(config_path=constants.config_path(), 26 | example_path=constants.config_example_path())) 27 | 28 | binder.bind_to_constructor(Hasher, lambda: Hasher(config=inject.instance(Config))) 29 | binder.bind_to_constructor(TelegramBot, lambda: TelegramBot(token=inject.instance(Config)['bot_token'])) 30 | binder.bind_to_constructor(TelegramApi, 31 | lambda: TelegramApi( 32 | api_session_name=str(constants.data_dir() 33 | / inject.instance(Config)['telegram_api_session']), 34 | api_id=inject.instance(Config)['telegram_api_id'], 35 | api_hash=inject.instance(Config)['telegram_api_hash'])) 36 | binder.bind_to_constructor(TelegramCursor, 37 | lambda: TelegramCursor(bot=inject.instance(TelegramBot), 38 | look_back_days=constants.BOT_LOOK_BACK_DAYS, 39 | long_polling_timeout=constants.LONG_POLLING_TIMEOUT)) 40 | 41 | binder.bind_to_constructor(CommandHandlerPool, lambda: CommandHandlerPool(handlers=[ 42 | CommandHandlerEssentials(), 43 | CommandHandlerPassword(), 44 | CommandHandlerConfig(), 45 | CommandHandlerSetReactions(), 46 | CommandHandlerUpdateMarkup(), 47 | CommandHandlerTakeMessage(use_telegram_user_api=inject.instance(Config)['use_telegram_user_api']), 48 | ])) 49 | binder.bind_to_constructor(CommandParser, lambda: CommandParser(handler_pool=inject.instance(CommandHandlerPool), 50 | params=command_params)) 51 | binder.bind_to_constructor(CommandHub, lambda: CommandHub(config=inject.instance(Config), 52 | telegram_bot=inject.instance(TelegramBot), 53 | parser=inject.instance(CommandParser), 54 | handler_pool=inject.instance(CommandHandlerPool))) 55 | binder.bind_to_constructor(MessagesLogger, 56 | lambda: MessagesLogger(dir_path=constants.messages_log_dir(), 57 | file_name_prefix=constants.MESSAGES_LOG_PREFIX, 58 | command_parser=inject.instance(CommandHub).parser, 59 | hasher=inject.instance(Hasher), 60 | chat_types=constants.MESSAGES_LOG_CHAT_TYPES)) 61 | binder.bind_to_constructor(TelegramInboxHub, 62 | lambda: TelegramInboxHub(telegram_cursor=inject.instance(TelegramCursor), 63 | chain_handlers=[inject.instance(CommandHub), 64 | inject.instance(ChannelPostHandler), 65 | inject.instance(CommentHandler)])) 66 | binder.bind_to_constructor(ChatIdPreserver, 67 | lambda: ChatIdPreserver(state_file_path=constants.chat_ids_state_path())) 68 | binder.bind_to_constructor(EnabledChannels, 69 | lambda: EnabledChannels(state_file_path=constants.enabled_channels_state_path())) 70 | binder.bind_to_constructor(SpaceState, lambda: SpaceState(constants.space_dir())) 71 | binder.bind_to_constructor(MarkupSynchronizer, lambda: MarkupSynchronizer()) 72 | binder.bind_to_constructor(ChannelPostHandler, lambda: ChannelPostHandler()) 73 | binder.bind_to_constructor(CommentHandler, lambda: CommentHandler()) 74 | binder.bind_to_constructor(AbuseDetector, lambda: AbuseDetector(period_seconds=constants.ABUSE_PERIOD_SECONDS, 75 | abuse_threshold=constants.ABUSE_THRESHOLD)) 76 | binder.bind_to_constructor(AbuseJanitor, lambda: AbuseJanitor(abuse_detector=inject.instance(AbuseDetector), 77 | period_seconds=constants.ABUSE_JANITOR_SECONDS)) 78 | binder.bind_to_constructor(EnablingManager, lambda: EnablingManager()) 79 | -------------------------------------------------------------------------------- /liker/command/handler_take_message.py: -------------------------------------------------------------------------------- 1 | import inject 2 | import logging 3 | import time 4 | from telebot.apihelper import ApiTelegramException 5 | from tengi.command.command_handler import * 6 | from tengi import TelegramBot, TelegramApi, telegram_api_utils, telegram_error 7 | 8 | from liker.setup import constants 9 | from liker.custom_markup import markup_utils 10 | 11 | logger = logging.getLogger(__file__) 12 | 13 | 14 | class CommandHandlerTakeMessage(CommandHandler): 15 | def __init__(self, use_telegram_user_api: bool): 16 | self.use_telegram_user_api = use_telegram_user_api 17 | 18 | self.telegram_api = None 19 | if self.use_telegram_user_api: 20 | self.telegram_api = inject.instance(TelegramApi) 21 | 22 | def get_cards(self) -> Iterable[CommandCard]: 23 | return [CommandCard(command_str='/take_messages', 24 | description='Take ownership over channel message(s)', 25 | is_admin=True), 26 | ] 27 | 28 | def handle(self, context: CommandContext): 29 | if context.command == '/take_messages': 30 | if not self.use_telegram_user_api: 31 | context.reply('This command requires use_telegram_user_api to be enabled in config') 32 | return 33 | 34 | channel_id = context.get_mandatory_arg('channel_id') 35 | prev_bot_token = context.get_optional_arg('bot_token', default=None) 36 | from_message_id = context.get_mandatory_arg('message_id') 37 | n_backward_messages = context.get_optional_arg('n', default=1) 38 | 39 | try: 40 | arr_messages = self.telegram_api.get_chat_messages_backward(chat_id=channel_id, 41 | message_id=from_message_id, 42 | n_messages=n_backward_messages) 43 | except ValueError as ex: 44 | # Error like "Could not find the input entity for PeerChannel(channel_id=1322520409)", most likely 45 | # means the user wasn't added to the channel or channel doesn't exist 46 | context.reply(text=str(ex), log_level=logging.INFO) 47 | return 48 | 49 | n_messages = len(arr_messages) 50 | period = 60 / context.config['channel_rate_per_minute'] 51 | response_text = f'There are {n_messages:,} messages, will take approximately {n_messages * period:,.0f} ' \ 52 | f'seconds. Bot will not to respond to other commands and buttons clicks till finish' 53 | context.reply(response_text, log_level=logging.INFO) 54 | n_processed = 0 55 | 56 | prev_bot = None 57 | if prev_bot_token is not None: 58 | prev_bot = TelegramBot(token=prev_bot_token) 59 | else: 60 | context.reply(f'bot_token is not provided, will work fine if the messages have no buttons yet', 61 | log_level=logging.INFO) 62 | 63 | for msg in arr_messages: 64 | try: 65 | try: 66 | iteration_begin = time.time() 67 | # Verbose 68 | if True: 69 | if (n_processed > 0) and (n_processed % constants.TAKE_MESSAGE_VERBOSE_N == 0): 70 | context.reply(f'Processed {n_processed:,} messages', log_level=logging.INFO) 71 | n_processed += 1 72 | 73 | new_reply_markup = telegram_api_utils.api_to_bot_markup(msg.reply_markup) 74 | markup_utils.assign_reaction_buttons_data(markup=new_reply_markup, 75 | handler=constants.CHANNEL_POST_HANDLER, 76 | case_id='') 77 | 78 | if prev_bot is not None: 79 | # Reset reply markup, needed for another bot to be able to modify it 80 | prev_bot.bot.edit_message_reply_markup(chat_id=channel_id, 81 | message_id=msg.id, 82 | reply_markup=None) 83 | # Modify reply markup by the new bot 84 | context.telegram_bot.bot.edit_message_reply_markup(chat_id=channel_id, 85 | message_id=msg.id, 86 | reply_markup=new_reply_markup) 87 | logger.debug(f'Took {channel_id} message {msg.id}, will sleep for {period:.1f} seconds') 88 | 89 | iteration_end = time.time() 90 | iteration_remaining = period - (iteration_end - iteration_begin) 91 | if iteration_remaining > 0: 92 | logger.debug(f'Sleeping {iteration_remaining:.2f}') 93 | time.sleep(iteration_remaining) 94 | except ApiTelegramException as ex: 95 | if ex.error_code == telegram_error.TOO_MANY_REQUESTS: 96 | logging.warning(ex) 97 | time.sleep(10) 98 | elif ex.error_code == telegram_error.UNAUTHORIZED: 99 | logging.info(str(ex)) 100 | context.reply('Bot has no rights to perform the operation') 101 | return 102 | elif ex.error_code == telegram_error.BAD_REQUEST: 103 | logging.info(str(ex)) 104 | context.reply(f'Message {msg.id} was deleted or not yet posted') 105 | else: 106 | raise ex 107 | except Exception as ex: 108 | logger.exception(ex) 109 | context.reply(f'Error processing message {msg.id}: {str(ex)}') 110 | 111 | context.reply(f'for {channel_id} {n_messages} message(s) were taken', 112 | log_level=logging.INFO) 113 | else: 114 | raise ValueError(f'Unhandled command: {context.command}') 115 | -------------------------------------------------------------------------------- /liker/custom_markup/channel_post_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import inject 3 | from telebot.apihelper import ApiTelegramException 4 | from telebot.types import InlineKeyboardMarkup 5 | from tengi import Config, TelegramBot, telegram_bot_utils, Hasher, AbuseDetector, ReplyContextLogOnly 6 | from tengi.telegram.inbox_handler import * 7 | 8 | from liker.state.space_state import SpaceState 9 | from liker.state.enabled_channels import EnabledChannels 10 | from liker.custom_markup.markup_synchronizer import MarkupSynchronizer 11 | from liker.setup import constants 12 | from liker.custom_markup import markup_utils 13 | from liker.enabling_manager import EnablingManager 14 | 15 | logger = logging.getLogger(__file__) 16 | 17 | 18 | class ChannelPostHandler(TelegramInboxHandler): 19 | config = inject.attr(Config) 20 | hasher = inject.attr(Hasher) 21 | telegram_bot = inject.attr(TelegramBot) 22 | enabled_channels = inject.attr(EnabledChannels) 23 | space_state = inject.attr(SpaceState) 24 | markup_synchronizer = inject.attr(MarkupSynchronizer) 25 | abuse_detector = inject.attr(AbuseDetector) 26 | enabling_manager = inject.attr(EnablingManager) 27 | 28 | def channel_post(self, channel_post: types.Message) -> bool: 29 | channel_id: int = channel_post.chat.id 30 | 31 | str_channel_id = str(channel_id) 32 | if not self.enabled_channels.is_enabled(str_channel_id): 33 | did_enabled = self.enabling_manager.try_set_reactions(channel_id=channel_id, 34 | reactions=constants.DEFAULT_REACTIONS, 35 | reply_context=ReplyContextLogOnly(), 36 | sender_id_to_check=None) 37 | if not did_enabled: 38 | return False 39 | else: 40 | logger.info(f'Automatically enabled liker for channel: {channel_id}') 41 | 42 | message_id = channel_post.id 43 | 44 | channel_dict = self.enabled_channels.get_channel_dict(str_channel_id) 45 | enabled_reactions = channel_dict['reactions'] 46 | reply_markup = markup_utils.extend_reply_markup(current_markup=None, 47 | enabled_reactions=enabled_reactions, 48 | handler=constants.CHANNEL_POST_HANDLER, 49 | case_id='') 50 | self.markup_synchronizer.add(channel_id=channel_id, 51 | message_id=message_id, 52 | reply_markup=reply_markup, 53 | to_top=True) 54 | logger.info(f'Scheduled markup adding for {channel_id}, message {message_id}') 55 | return True 56 | 57 | def callback_query(self, callback_query: types.CallbackQuery) -> bool: 58 | button_data = callback_query.data 59 | if not telegram_bot_utils.is_button_data_encoded(button_data): 60 | return False 61 | handler, _case_id, reaction = telegram_bot_utils.decode_button_data(button_data) 62 | if handler != constants.CHANNEL_POST_HANDLER: 63 | return False 64 | if callback_query.message is None: 65 | return False 66 | chat_id = callback_query.message.chat.id 67 | if not self.enabled_channels.is_enabled(str(chat_id)): 68 | return False 69 | 70 | sender_id = callback_query.from_user.id 71 | abuse_cool_down = self.abuse_detector.check_abuse(sender_id) 72 | if abuse_cool_down is not None: 73 | logger.warning(f'Abuse detected: {self.hasher.trimmed(sender_id)}') 74 | return True 75 | 76 | channel_id: int = chat_id 77 | message_id: int = callback_query.message.id 78 | reply_markup_telegram = callback_query.message.reply_markup 79 | if reply_markup_telegram is None: 80 | logger.error(f'Received a callback without reply markup. Ignoring it. Channel {channel_id}, ' 81 | f'message {message_id}') 82 | return True 83 | # We create a copy of the current Telegram markup to be able to check if it's changed 84 | reply_markup_telegram_copy = InlineKeyboardMarkup.de_json(reply_markup_telegram.to_json()) 85 | 86 | reply_markup_queued = self.markup_synchronizer.try_get_markup(channel_id=channel_id, message_id=message_id) 87 | reply_markup_new = reply_markup_queued if (reply_markup_queued is not None) else reply_markup_telegram_copy 88 | 89 | reaction_id = f'{chat_id}_{message_id}_{sender_id}_{reaction}' 90 | reaction_hash = self.hasher.trimmed(reaction_id, hash_bytes=constants.REACTION_HASH_BYTES) 91 | channel_state = self.space_state.ensure_channel_state(str(channel_id)) 92 | if channel_state.last_reactions.has(reaction_hash): 93 | markup_utils.change_reaction_counter(reply_markup=reply_markup_new, 94 | reaction=reaction, 95 | value=-1, 96 | is_delta=True) 97 | channel_state.last_reactions.remove(reaction_hash) 98 | response_to_user = self.config['response_reaction_removed'].format(reaction) 99 | else: 100 | markup_utils.change_reaction_counter(reply_markup=reply_markup_new, 101 | reaction=reaction, 102 | value=1, 103 | is_delta=True) 104 | channel_state.last_reactions.add(reaction_hash) 105 | response_to_user = self.config['response_reaction_added'].format(reaction) 106 | 107 | if reply_markup_new.to_json() == reply_markup_telegram.to_json(): 108 | self.markup_synchronizer.try_remove(channel_id=channel_id, message_id=message_id) 109 | logger.debug(f'De-queuing markup as it was returned to original state') 110 | else: 111 | self.markup_synchronizer.add(channel_id=channel_id, message_id=message_id, reply_markup=reply_markup_new) 112 | 113 | try: 114 | self.telegram_bot.answer_callback_query(callback_query.id, text=response_to_user) 115 | except ApiTelegramException as ex: 116 | channel_username = callback_query.message.chat.username 117 | logger.info(f'Cannot answer callback query, callback_id={callback_query.id}, message_id={message_id}, ' 118 | f'channel_id={channel_id}, channel_username={channel_username}, ' 119 | f'most likely it is expired: {ex}') 120 | return True 121 | -------------------------------------------------------------------------------- /liker/custom_markup/comment_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import inject 3 | from telebot.types import InlineKeyboardMarkup 4 | from tengi import telegram_bot_utils 5 | from tengi.telegram.inbox_handler import * 6 | from tengi.telegram.constants import TELEGRAM_USER_ID 7 | 8 | from liker.state.enabled_channels import EnabledChannels 9 | from liker.state.space_state import SpaceState 10 | from liker.custom_markup import markup_utils 11 | from liker.custom_markup.markup_synchronizer import MarkupSynchronizer 12 | from liker.setup import constants 13 | 14 | logger = logging.getLogger(__file__) 15 | 16 | 17 | class CommentHandler(TelegramInboxHandler): 18 | """ 19 | Handles channel post comments in the linked group. Updates the counter of the comments. 20 | Unfortunately the bot can't receive events of message deletion -- thus the counter doesn't decrease in case of 21 | comments were deleted. 22 | """ 23 | enabled_chats = inject.attr(EnabledChannels) 24 | space_state = inject.attr(SpaceState) 25 | markup_synchronizer = inject.attr(MarkupSynchronizer) 26 | 27 | def message(self, message: types.Message) -> bool: 28 | if not telegram_bot_utils.is_group_message(message): 29 | return False 30 | 31 | if self._check_forward_from_channel(message): 32 | return True 33 | 34 | if self._check_reply_to_channel_post(message): 35 | return True 36 | 37 | return False 38 | 39 | def _check_forward_from_channel(self, message: types.Message) -> bool: 40 | if message.from_user.id != TELEGRAM_USER_ID: 41 | logger.debug('Check forward: ignoring message as it is not from Telegram') 42 | return False 43 | 44 | if message.forward_from_chat is None: 45 | logger.debug('Check forward: ignoring message as it is not forwarded') 46 | return False 47 | 48 | enabled_channel_ids = self.enabled_chats.enabled_channel_ids() 49 | channel_id = message.forward_from_chat.id 50 | if channel_id not in enabled_channel_ids: 51 | logger.debug('Check forward: ignoring message as it is not from enabled channel') 52 | return False 53 | 54 | channel_dict = self.enabled_chats.get_channel_dict(str(channel_id)) 55 | group_id = message.chat.id 56 | cur_linked_chat_id = channel_dict['linked_chat_id'] 57 | if cur_linked_chat_id != group_id: 58 | logger.info(f'Updating linked_chat_id for {channel_id} from {cur_linked_chat_id} to {group_id}') 59 | channel_dict['linked_chat_id'] = group_id 60 | self.enabled_chats.set_channel_dict(str_channel_id=str(channel_id), 61 | channel_dict=channel_dict) 62 | 63 | channel_message_id = message.forward_from_message_id 64 | thread_message_id = message.message_id 65 | 66 | reply_markup = self._try_find_reply_markup(channel_id=channel_id, message_id=channel_message_id) 67 | if reply_markup is None: 68 | logger.error(f'Was not able to get cached reply markup for {channel_id}') 69 | return True 70 | 71 | reply_markup = self._ensure_comment_button(reply_markup=reply_markup, 72 | group_id=group_id, 73 | thread_message_id=thread_message_id) 74 | self.markup_synchronizer.add(channel_id=channel_id, 75 | message_id=channel_message_id, 76 | reply_markup=reply_markup, 77 | to_top=True) 78 | logger.info(f'Comments button adding scheduled for chat_id={channel_id}, message_id={channel_message_id}') 79 | return True 80 | 81 | def _check_reply_to_channel_post(self, message: types.Message) -> bool: 82 | ref_message: types.Message = message.reply_to_message 83 | if ref_message is None: 84 | return False 85 | 86 | group_id = message.chat.id 87 | if ref_message.forward_from_chat is not None: 88 | channel_id = ref_message.forward_from_chat.id 89 | channel_message_id = ref_message.forward_from_message_id 90 | thread_message_id = ref_message.message_id 91 | else: 92 | channel_id = self.enabled_chats.try_get_channel_id_for_linked_chat_id(group_id) 93 | if channel_id is None: 94 | return False 95 | channel_state = self.space_state.ensure_channel_state(str(channel_id)) 96 | ref_message_trail = channel_state.comment_trail.try_get(str_message_id=str(ref_message.message_id)) 97 | if ref_message_trail is None: 98 | return False 99 | channel_message_id = ref_message_trail['channel_message_id'] 100 | thread_message_id = ref_message_trail['thread_message_id'] 101 | 102 | enabled_channel_ids = self.enabled_chats.enabled_channel_ids() 103 | if channel_id not in enabled_channel_ids: 104 | return False 105 | 106 | reply_markup = self._try_find_reply_markup(channel_id=channel_id, message_id=channel_message_id) 107 | if reply_markup is None: 108 | logger.warning(f'Ignoring a reply message as there is not cached reply markup: channel {channel_id}, ' 109 | f'channel message {channel_message_id}') 110 | return True 111 | 112 | reply_markup = self._ensure_comment_button(reply_markup=reply_markup, 113 | group_id=group_id, 114 | thread_message_id=thread_message_id) 115 | markup_utils.change_reaction_counter(reply_markup, 116 | reaction=constants.COMMENT_TEXT, 117 | value=1, 118 | is_delta=True) 119 | self.markup_synchronizer.add(channel_id=channel_id, 120 | message_id=channel_message_id, 121 | reply_markup=reply_markup, 122 | to_top=True) 123 | 124 | comment_message_id = message.message_id 125 | comment_dict = { 126 | 'channel_message_id': channel_message_id, 127 | 'thread_message_id': thread_message_id, 128 | } 129 | channel_state = self.space_state.ensure_channel_state(str(channel_id)) 130 | channel_state.comment_trail.add(str_message_id=str(comment_message_id), 131 | comment_dict=comment_dict) 132 | return True 133 | 134 | @staticmethod 135 | def _ensure_comment_button(reply_markup: InlineKeyboardMarkup, 136 | group_id: int, 137 | thread_message_id: int) -> InlineKeyboardMarkup: 138 | if not markup_utils.markup_has_button(reply_markup, constants.COMMENT_TEXT): 139 | short_group_id = telegram_bot_utils.get_short_chat_id(group_id) 140 | comments_url = f'https://t.me/c/{short_group_id}/999999999?thread={thread_message_id}' 141 | reply_markup = markup_utils.add_url_button_to_markup(reply_markup=reply_markup, 142 | text=constants.COMMENT_TEXT, 143 | url=comments_url) 144 | return reply_markup 145 | 146 | def _try_find_reply_markup(self, channel_id, message_id): 147 | channel_state = self.space_state.ensure_channel_state(str(channel_id)) 148 | reply_markup_str = channel_state.markup_queue.try_get(str(message_id)) 149 | if reply_markup_str is None: 150 | reply_markup_str = channel_state.markup_trail.try_get(str(message_id)) 151 | result = None if (reply_markup_str is None) else InlineKeyboardMarkup.de_json(reply_markup_str) 152 | return result 153 | -------------------------------------------------------------------------------- /liker/custom_markup/markup_synchronizer.py: -------------------------------------------------------------------------------- 1 | import inject 2 | import time 3 | import logging 4 | from telebot.apihelper import ApiTelegramException 5 | from typing import Optional 6 | from telebot.types import InlineKeyboardMarkup 7 | from tengi import Config, TelegramBot, telegram_error 8 | 9 | from liker.state.enabled_channels import EnabledChannels 10 | from liker.state.space_state import SpaceState 11 | 12 | logger = logging.getLogger(__file__) 13 | 14 | 15 | class MarkupSynchronizer: 16 | """ 17 | Handles Telegram message update limits 18 | https://core.telegram.org/bots/faq#my-bot-is-hitting-limits-how-do-i-avoid-this 19 | 30 messages per second 20 | 20 messages per minute to the same group 21 | State of the queue is stored in channel states and thus persisted 22 | """ 23 | config = inject.attr(Config) 24 | telegram_bot = inject.attr(TelegramBot) 25 | enabled_channels = inject.attr(EnabledChannels) 26 | space_state = inject.attr(SpaceState) 27 | 28 | def __init__(self): 29 | self.channel_update_times = {} 30 | 31 | def add(self, channel_id: int, message_id: int, reply_markup: InlineKeyboardMarkup, to_top=False): 32 | channel_state = self.space_state.ensure_channel_state(str(channel_id)) 33 | str_reply_markup = reply_markup.to_json() 34 | channel_state.markup_queue.add(str_message_id=str(message_id), 35 | str_markup=str_reply_markup, 36 | to_top=to_top) 37 | 38 | def try_remove(self, channel_id: int, message_id: int): 39 | channel_state = self.space_state.ensure_channel_state(str(channel_id)) 40 | channel_state.markup_queue.try_remove(str(message_id)) 41 | 42 | def try_get_markup(self, channel_id: int, message_id: int) -> Optional[InlineKeyboardMarkup]: 43 | channel_state = self.space_state.ensure_channel_state(str(channel_id)) 44 | str_reply_markup = channel_state.markup_queue.try_get(str(message_id)) 45 | reply_markup = None if (str_reply_markup is None) else InlineKeyboardMarkup.de_json(str_reply_markup) 46 | return reply_markup 47 | 48 | def _ensure_channel_update_times(self, channel_id: int) -> list: 49 | if channel_id not in self.channel_update_times: 50 | self.channel_update_times[channel_id] = [] 51 | return self.channel_update_times[channel_id] 52 | 53 | def update(self): 54 | enabled_channel_ids = self.enabled_channels.enabled_channel_ids() 55 | 56 | # TODO: implement global limit from self.config['global_rate_per_second'] to manage load from multiple 57 | # channels. Will be a problem in case of >100-200 channels connected to the bot 58 | cur_time = time.time() 59 | 60 | rate_per_minute = self.config['channel_rate_per_minute'] 61 | rate_avg_seconds = 60 / rate_per_minute 62 | rate_min_seconds = self.config['channel_rate_min_seconds'] 63 | rate_span = 2 * (rate_avg_seconds - rate_min_seconds) 64 | rate_span = max(rate_span, 0) 65 | 66 | for ch_id in enabled_channel_ids: 67 | try: 68 | upd_times = self._ensure_channel_update_times(ch_id) 69 | upd_times = [x for x in upd_times if (cur_time - x <= 60) and (cur_time >= x)] 70 | # If there was no updates in the channel last minute -- consume half a minute 71 | dt_to_consume = 30 if (not upd_times) else (cur_time - max(upd_times)) 72 | 73 | ch_state = self.space_state.ensure_channel_state(str(ch_id)) 74 | ch_queue = ch_state.markup_queue.ensure_queue() 75 | while ch_queue: 76 | m_id_str = None 77 | reply_markup_str = None 78 | try: 79 | m_id_str = list(ch_queue.keys())[0] 80 | 81 | # Handle channel being disabled during the iteration -- could happen in case bot was 82 | # removed from the channel 83 | if not self.enabled_channels.is_enabled(str(ch_id)): 84 | logger.info(f'Channel was disabled, ignoring {len(ch_queue):,} markups in the queue') 85 | ch_queue = {} 86 | break 87 | 88 | reply_markup_str = ch_queue[m_id_str] 89 | 90 | # If we made 'rate_per_minute' updates per last minute -- don't send more updates 91 | if len(upd_times) >= rate_per_minute: 92 | break 93 | 94 | # Make elastic delay time 95 | slowdown_factor = len(upd_times) / rate_per_minute 96 | cur_timeout = rate_min_seconds + slowdown_factor * rate_span 97 | logger.debug(f'queue timeout: {cur_timeout}') 98 | if cur_timeout > dt_to_consume: 99 | break 100 | 101 | m_id = int(m_id_str) 102 | 103 | reply_markup = InlineKeyboardMarkup.de_json(reply_markup_str) 104 | 105 | dt_to_consume -= cur_timeout 106 | upd_times.append(cur_time) 107 | 108 | self.telegram_bot.bot.edit_message_reply_markup(chat_id=ch_id, 109 | message_id=m_id, 110 | reply_markup=reply_markup) 111 | logger.debug(f'Markup synchronized for chat_id={ch_id}, message_id={m_id}') 112 | # We don't break loop for all exceptions except TOO_MANY_REQUESTS to avoid infinite error loop 113 | except ApiTelegramException as ex: 114 | if ex.error_code == telegram_error.TOO_MANY_REQUESTS: 115 | logger.error(f'Got TOO_MANY_REQUESTS error, will skip current channel update: {ex}') 116 | break 117 | elif (ex.error_code == telegram_error.BAD_REQUEST) and ('are exactly the same' in str(ex)): 118 | # Error: Bad Request: message is not modified: specified new message content and reply 119 | # markup are exactly the same as a current content and reply markup of the message" 120 | logger.warning(f'Cannot sync markup, chat_id={ch_id}, message_id={m_id_str}. {ex}') 121 | elif (ex.error_code == telegram_error.BAD_REQUEST) and \ 122 | ('''message can't be edited''' in str(ex)): 123 | # Error: Bad Request: message can't be edited 124 | logger.warning(f'Bot does not have post edit rights, chat_id={ch_id}, ' 125 | f'message_id={m_id_str}. {ex}') 126 | elif ex.error_code == telegram_error.FORBIDDEN: 127 | logger.warning(f'Bot was removed from the channel but tries to synchronize markup: ' 128 | f'chat_id={ch_id}, message_id={m_id_str}. {ex}') 129 | self.enabled_channels.disable_channel(str(ch_id)) 130 | else: 131 | logger.exception(f'chat_id={ch_id}, message_id={m_id_str}\n{ex}') 132 | except Exception as ex: 133 | logger.exception(f'chat_id={ch_id}, message_id={m_id_str}\n{ex}') 134 | 135 | # We delete markup from the queue only after it's synchronized 136 | if m_id_str is not None: 137 | if reply_markup_str is not None: 138 | ch_state.markup_trail.add(str_message_id=m_id_str, 139 | str_markup=reply_markup_str) 140 | del ch_queue[m_id_str] 141 | 142 | self.channel_update_times[ch_id] = upd_times 143 | ch_state.markup_queue.update_queue(ch_queue) 144 | except Exception as ex: 145 | logger.exception(ex) 146 | --------------------------------------------------------------------------------