├── uml.png ├── src ├── data │ ├── __init__.py │ ├── message.json │ ├── guide.html │ ├── post_card.html │ └── posts.html ├── data_models │ ├── __init__.py │ ├── question.py │ ├── comment.py │ ├── answer.py │ └── base.py ├── handlers │ ├── __init__.py │ ├── base.py │ ├── command_handler.py │ ├── message_handler.py │ └── callback_handler.py ├── bot.py ├── filters.py ├── utils │ ├── io.py │ ├── common.py │ └── keyboard.py ├── db.py ├── jobs │ ├── auto_update_messages.py │ └── auto_delete_messages.py ├── constants.py ├── user.py └── run.py ├── user_journey.png ├── requirements.txt ├── LICENSE ├── README.md └── .gitignore /uml.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pytopia/stackoverflow-telegram-bot/HEAD/uml.png -------------------------------------------------------------------------------- /src/data/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | DATA_DIR = Path(__file__).resolve().parent 4 | -------------------------------------------------------------------------------- /user_journey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pytopia/stackoverflow-telegram-bot/HEAD/user_journey.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyTelegramBotAPI==4.2.2 2 | loguru==0.5.3 3 | emoji==1.5.0 4 | pymongo==4.0.1 5 | beautifulsoup4==4.10.0 -------------------------------------------------------------------------------- /src/data_models/__init__.py: -------------------------------------------------------------------------------- 1 | from src.data_models.answer import Answer 2 | from src.data_models.comment import Comment 3 | from src.data_models.question import Question 4 | -------------------------------------------------------------------------------- /src/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from src.handlers.command_handler import CommandHandler 2 | from src.handlers.message_handler import MessageHandler 3 | from src.handlers.callback_handler import CallbackHandler 4 | -------------------------------------------------------------------------------- /src/bot.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import telebot 4 | from telebot import apihelper 5 | 6 | # Middleware handlers 7 | apihelper.ENABLE_MIDDLEWARE = True 8 | 9 | # Initialize bot 10 | bot = telebot.TeleBot( 11 | os.environ['TELEGRAMBOT_TOKEN'], parse_mode='HTML' 12 | ) 13 | -------------------------------------------------------------------------------- /src/filters.py: -------------------------------------------------------------------------------- 1 | import telebot 2 | 3 | from src.bot import bot 4 | 5 | 6 | class IsAdmin(telebot.custom_filters.SimpleCustomFilter): 7 | # Class will check whether the user is admin or creator in group or not 8 | key = 'is_admin' 9 | 10 | @staticmethod 11 | def check(message: telebot.types.Message): 12 | return bot.get_chat_member(message.chat.id, message.from_user.id).status in ['administrator', 'creator'] 13 | -------------------------------------------------------------------------------- /src/data/message.json: -------------------------------------------------------------------------------- 1 | { 2 | "message_id": 428, 3 | "from": { 4 | "id": 371998922, 5 | "is_bot": false, 6 | "first_name": "Ali", 7 | "username": "Ali_987096", 8 | "language_code": "en" 9 | }, 10 | "chat": { 11 | "id": 371998922, 12 | "first_name": "Ali", 13 | "username": "Ali_987096", 14 | "type": "private" 15 | }, 16 | "date": 1639243854, 17 | "text": "saf" 18 | } 19 | -------------------------------------------------------------------------------- /src/data/guide.html: -------------------------------------------------------------------------------- 1 | :check_mark_button: Asking a good question 2 | 3 | You're ready to ask your first programming-related question and the community is here to help! To get you the best answers, we've provided some guidance: 4 | 5 | Before you post, search the site to make sure your question hasn't been answered. 6 | 7 | - Summarize the problem 8 | - Describe the problem in detail 9 | - Provide a link to the code or a screenshot 10 | - When appropriate, show some code 11 | 12 | -------------------------------------------------------------------------------- /src/utils/io.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | def read_json(filename): 5 | """ 6 | Reads json file and returns data. 7 | 8 | :param filename: Path to json file. 9 | :return: Data in json file. 10 | """ 11 | with open(filename, 'r') as f: 12 | return json.load(f) 13 | 14 | def write_json(data, filename, indent=4): 15 | """ 16 | Writes data to json file. 17 | 18 | :param data: Data to write. 19 | :param filename: Path to json file. 20 | :param indent: Indentation of json file. Default is 4. 21 | """ 22 | with open(filename, 'w') as f: 23 | json.dump(data, f, indent=indent) 24 | 25 | def read_file(filename): 26 | """ 27 | Reads file and returns text content. 28 | 29 | :param filename: Path to file. 30 | """ 31 | with open(filename, 'r') as f: 32 | return f.read() 33 | -------------------------------------------------------------------------------- /src/data/post_card.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{{emoji}}} {{{post_type}}} #{{{post_number}}}

4 |
👤 {{{user_identity}}}
5 |
6 |

{{{text}}}

7 | Answer 8 | Comment 9 |
10 | 15 |
16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ali Hejazizo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stackoverflow Telegram Bot 2 | 3 | Stackoverflow Telegram Bot is a Telegram bot that allows you to send questions to other users and receive answers from them, similar to Stackoverflow.com. 4 | 5 | Users can send question and receive answers, upvote/downvote, like, bookmark posts, and more. 6 | 7 | ## How to Run 8 | 1. Set your telegram bot token as environment variable `TELEGRAM_BOT_TOKEN`: 9 | ``` 10 | export TELEGRAM_BOT_TOKEN= 11 | ``` 12 | 13 | 2. Add `src` to `PYTHONPATH`: 14 | ``` 15 | export PYTHONPATH=${PWD} 16 | ``` 17 | 18 | 3. Run: 19 | ``` 20 | python src/run.py 21 | ``` 22 | 23 | **Note:** You need to set up your mongodb database first in `src/db.py`. 24 | 25 | ## UML Diagram 26 | See [UML Class Diagram](https://lucid.app/lucidchart/407122f0-176a-4d2e-bbe0-8f4f9929b823/edit?viewport_loc=-1156%2C-1499%2C4245%2C1512%2C0_0&invitationId=inv_5220253e-60fe-444f-ac44-f9daf499d31c) in Lucid Chart. 27 | 28 | UML Diagram 29 | 30 | ## User Journey 31 | See [User Journey](https://lucid.app/lucidchart/597ca4b7-5bb7-4de4-bfd9-17fffe63dc5a/edit?viewport_loc=-1089%2C-312%2C6708%2C2389%2C0_0&invitationId=inv_2e288ea9-2812-4b66-8a44-3e14f1d9794f) in Lucid Chart. 32 | 33 | User Journey 34 | -------------------------------------------------------------------------------- /src/data_models/question.py: -------------------------------------------------------------------------------- 1 | from src.constants import inline_keys, post_status 2 | from src.data_models.base import BasePost 3 | from src.utils.keyboard import create_keyboard 4 | from telebot import types 5 | 6 | 7 | class Question(BasePost): 8 | """ 9 | Class to handle questions sent by the users. 10 | """ 11 | def send(self) -> dict: 12 | """Send question to the right audience. 13 | We send questions to all users. 14 | 15 | :return: The question post. 16 | """ 17 | return self.send_to_all() 18 | 19 | def get_actions_keyboard(self) -> types.InlineKeyboardMarkup: 20 | """ 21 | Get question section actions keyboard. 22 | 23 | Keyboard changes depending on the user's role. 24 | If the user is the owner of the question, he can't send answer for it, but others can. 25 | """ 26 | keys, owner = super().get_actions_keys_and_owner() 27 | if owner != self.chat_id: 28 | keys.append(inline_keys.answer) 29 | 30 | # if post is closed, remove open post only actions from keyboard 31 | if self.post_status != post_status.OPEN: 32 | keys = self.remove_closed_post_actions(keys) 33 | 34 | reply_markup = create_keyboard(*keys, is_inline=True) 35 | return reply_markup 36 | -------------------------------------------------------------------------------- /src/handlers/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractclassmethod 2 | 3 | from src.constants import SETTINGS_START_MESSAGE, inline_keys 4 | from src.utils.keyboard import create_keyboard 5 | 6 | 7 | class BaseHandler(ABC): 8 | """ 9 | Base class for all telebot handlers. 10 | """ 11 | def __init__(self, stackbot, db): 12 | self.stackbot = stackbot 13 | self.db = db 14 | 15 | @abstractclassmethod 16 | def register(self): 17 | """ 18 | Register telebot handlers. 19 | """ 20 | 21 | def get_settings_keyboard(self): 22 | """ 23 | Returns settings main menu keyboard. 24 | """ 25 | muted_bot = self.stackbot.user.settings.get('muted_bot') 26 | if muted_bot: 27 | keys = [inline_keys.change_identity] 28 | else: 29 | keys = [inline_keys.change_identity] 30 | 31 | return create_keyboard(*keys, is_inline=True) 32 | 33 | def get_settings_text(self): 34 | """ 35 | Returns settings text message. 36 | """ 37 | text = SETTINGS_START_MESSAGE.format( 38 | first_name=self.stackbot.user.first_name, 39 | username=self.stackbot.user.username, 40 | identity=self.stackbot.user.identity, 41 | **self.stackbot.user.stats(), 42 | ) 43 | return text 44 | -------------------------------------------------------------------------------- /src/db.py: -------------------------------------------------------------------------------- 1 | import pymongo 2 | from loguru import logger 3 | 4 | def build_indexes(db): 5 | # users 6 | db.users.create_index([('chat.id', 1)], unique=True) 7 | db.users.create_index([('chat.id', 1), ('state', 1)]) 8 | db.users.create_index([('attachments.file_unique_id', 1)]) 9 | 10 | # posts 11 | db.post.create_index([('status', 1)]) 12 | db.post.create_index([('type', 1)]) 13 | db.post.create_index([('replied_to_post_id', 1)]) 14 | db.post.create_index([('chat.id', 1)]) 15 | db.post.create_index([('status', 1), ('type', 1), ('chat.id', 1)]) 16 | db.post.create_index([('status', 1), ('type', 1), ('replied_to_post_id', 1)]) 17 | 18 | # db.post.create_index([('text', 'text')]) 19 | 20 | # callback data 21 | db.callback_data.create_index([('chat_id', 1)]) 22 | db.callback_data.create_index([('message_id', 1)]) 23 | db.callback_data.create_index([('created_at', 1)]) 24 | db.callback_data.create_index([('chat_id', 1), ('message_id', 1)]) 25 | db.callback_data.create_index([('chat_id', 1), ('message_id', 1), ('post_id', 1)]) 26 | db.callback_data.create_index([('chat_id', 1), ('message_id', 1), ('created_at', 1)]) 27 | 28 | # auto update 29 | db.auto_update.create_index([('chat_id', 1), ('message_id', 1)]) 30 | 31 | # MongoDB connection 32 | client = pymongo.MongoClient("localhost", 27017) 33 | db = client.test 34 | 35 | # Build indexes 36 | logger.info('Building indexes...') 37 | build_indexes(db) 38 | logger.info('Indexes built.') 39 | -------------------------------------------------------------------------------- /src/utils/common.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import itertools 3 | import json 4 | from typing import Iterable, Tuple 5 | 6 | 7 | def human_readable_size(size, decimal_places=1): 8 | """ 9 | Convert size in bytes to human readable size. 10 | 11 | :param size: Size in bytes 12 | :param decimal_places: Number of decimal places 13 | :return: Human readable size 14 | """ 15 | for unit in ['B', 'KiB', 'MiB', 'GiB', 'TiB']: 16 | if size < 1024.0: 17 | break 18 | size /= 1024.0 19 | return f"{size:.{decimal_places}f} {unit}" 20 | 21 | 22 | def human_readable_unix_time(unix_time, timezone=None): 23 | """Convert unix time to human readable time. 24 | 25 | :param unix_time: Unix time 26 | :param timezone: Timezone, if None, use utc timezone. 27 | :return: Human readable time 28 | """ 29 | if timezone is None: 30 | timezone = datetime.timezone.utc 31 | return datetime.datetime.fromtimestamp(unix_time, timezone).strftime('%B %d %Y %H:%M:%S') 32 | 33 | 34 | def json_encoder(obj): 35 | """ 36 | JSON encoder that converts non serializable objects to null. 37 | 38 | :param obj: Object to encode 39 | :return: Serializable object with non-serializable objects converted to null 40 | """ 41 | try: 42 | json.dumps(obj) 43 | return obj 44 | except Exception as e: 45 | return None 46 | 47 | 48 | def chunked_iterable(iterable: Iterable, size: int) -> Tuple: 49 | """Yield successive n-sized chunks from iterable. 50 | :param iterable: Iterable to chunk 51 | :param size: Chunks size 52 | :yield: Chunk in tuple format 53 | """ 54 | it = iter(iterable) 55 | while True: 56 | chunk = tuple(itertools.islice(it, size)) 57 | if not chunk: 58 | break 59 | yield chunk 60 | -------------------------------------------------------------------------------- /src/jobs/auto_update_messages.py: -------------------------------------------------------------------------------- 1 | import concurrent.futures 2 | import time 3 | 4 | from loguru import logger 5 | from src.bot import bot 6 | from src.constants import inline_keys 7 | from src.data_models.base import BasePost 8 | from src.db import db 9 | from src.run import StackBot 10 | 11 | 12 | stackbot = StackBot(db=db, telebot=bot) 13 | UPDATE_SLEEP = 1 * 60 # seconds 14 | UPDATE_DELAY = 30 15 | 16 | 17 | def update_message(update_doc): 18 | chat_id = update_doc['chat_id'] 19 | message_id = update_doc['message_id'] 20 | 21 | callback_data = db.callback_data.find( 22 | {'chat_id': chat_id, 'message_id': message_id} 23 | ).sort('created_at', -1) 24 | 25 | try: 26 | callback_data = next(callback_data) 27 | except StopIteration: 28 | db.auto_update.delete_one({'_id': update_doc['_id']}) 29 | return 30 | 31 | current_time = time.time() 32 | if (current_time - callback_data['created_at']) < UPDATE_DELAY: 33 | return 34 | 35 | post_handler = BasePost( 36 | db=db, stackbot=stackbot, post_id=callback_data['post_id'], chat_id=chat_id, 37 | is_gallery=callback_data['is_gallery'], gallery_filters=callback_data['gallery_filters'] 38 | ) 39 | 40 | text, keyboard = post_handler.get_text_and_keyboard() 41 | if (inline_keys.show_less not in callback_data['buttons']) and (inline_keys.actions in callback_data['buttons']): 42 | stackbot.edit_message(chat_id, message_id, text=text, reply_markup=keyboard) 43 | 44 | while True: 45 | print('Start update process...') 46 | # with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: 47 | # for update_doc in db.auto_update.find(): 48 | # executor.submit(update_message, update_doc) 49 | 50 | for update_doc in db.auto_update.find(): 51 | try: 52 | update_message(update_doc) 53 | except Exception as e: 54 | logger.exception(e) 55 | 56 | time.sleep(UPDATE_SLEEP) 57 | -------------------------------------------------------------------------------- /src/jobs/auto_delete_messages.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from loguru import logger 4 | from src.bot import bot 5 | from src.constants import states 6 | from src.db import db 7 | from src.run import StackBot 8 | 9 | 10 | stackbot = StackBot(db=db, telebot=bot) 11 | DELETION_SLEEP = 10 # seconds 12 | KEEP_LAST_MESSAGES_NUMBER = 3 13 | 14 | while True: 15 | print('Start deletion process...') 16 | chat_ids = set() 17 | skip_chat_ids = set() 18 | for chat_id in db.auto_delete.distinct('chat_id'): 19 | # Only users in main states 20 | user = db.users.find_one({'chat.id': chat_id, 'state': states.MAIN}) 21 | if not user: 22 | continue 23 | 24 | # Only users that have more than 3 uncleaned messages 25 | num_messages = db.auto_delete.count_documents({'chat_id': chat_id}) 26 | 27 | # Delete messages 28 | for ind, doc in enumerate(db.auto_delete.find({'chat_id': chat_id})): 29 | chat_id = doc['chat_id'] 30 | message_id = doc['message_id'] 31 | current_time = time.time() 32 | 33 | # Don't delete the last message 34 | if ind >= (num_messages - KEEP_LAST_MESSAGES_NUMBER): 35 | continue 36 | 37 | # -1 flag shows the message should not be deleted 38 | if doc['delete_after'] == -1: 39 | continue 40 | 41 | remaining_time = doc['created_at'] + doc['delete_after'] - current_time 42 | if remaining_time > 0: 43 | continue 44 | 45 | # Delete message 46 | stackbot.delete_message(chat_id=chat_id, message_id=message_id) 47 | db.auto_delete.delete_one({'_id': doc['_id']}) 48 | 49 | # Delete message in callback data and auto_update data 50 | db.callback_data.delete_many({'chat_id': chat_id, 'message_id': message_id}) 51 | db.auto_update.delete_many({'chat_id': chat_id, 'message_id': message_id}) 52 | 53 | chat_ids.add(chat_id) 54 | 55 | time.sleep(DELETION_SLEEP) 56 | -------------------------------------------------------------------------------- /src/data_models/comment.py: -------------------------------------------------------------------------------- 1 | from bson.objectid import ObjectId 2 | from src.constants import post_status 3 | from src.data_models.base import BasePost 4 | from src.utils.keyboard import create_keyboard 5 | from telebot import types 6 | 7 | 8 | class Comment(BasePost): 9 | """ 10 | Class to handle the comments sent by the users on other posts. 11 | """ 12 | def __init__( 13 | self, db, stackbot, post_id: str = None, chat_id: str = None, 14 | is_gallery: bool = False, gallery_filters=None 15 | ): 16 | super().__init__( 17 | db=db, stackbot=stackbot, chat_id=chat_id, post_id=post_id, 18 | is_gallery=is_gallery, gallery_filters=gallery_filters 19 | ) 20 | self.supported_content_types = ['text'] 21 | 22 | def send(self) -> dict: 23 | """ 24 | Send the comment to the right audience. 25 | - Comment owner. 26 | - The post owner that comment is replied to. 27 | - Post followers. 28 | 29 | :param post_id: ObjectId of the comment post. 30 | :return: The comment post. 31 | """ 32 | post = self.as_dict() 33 | 34 | # Send to the user who sent the original post 35 | related_post = self.db.post.find_one({'_id': ObjectId(post['replied_to_post_id'])}) 36 | related_post_owner_chat_id = related_post['chat']['id'] 37 | 38 | # Send to Followers 39 | followers = self.get_followers() 40 | 41 | self.send_to_many(list({self.owner_chat_id, related_post_owner_chat_id}) + followers) 42 | return post 43 | 44 | def get_actions_keyboard(self) -> types.InlineKeyboardMarkup: 45 | """ 46 | Get comment section actions keyboard. 47 | """ 48 | keys, _ = super().get_actions_keys_and_owner() 49 | 50 | # if post is closed, remove open post only actions from keyboard 51 | if self.post_status != post_status.OPEN: 52 | keys = self.remove_closed_post_actions(keys) 53 | 54 | reply_markup = create_keyboard(*keys, is_inline=True) 55 | return reply_markup 56 | -------------------------------------------------------------------------------- /src/utils/keyboard.py: -------------------------------------------------------------------------------- 1 | import emoji 2 | from loguru import logger 3 | from telebot import types 4 | 5 | 6 | def create_keyboard( 7 | *keys, 8 | reply_row_width=2, inline_row_width=4, 9 | resize_keyboard=True, is_inline=False, callback_data=None 10 | ): 11 | from src.constants import inline_keys 12 | from src.constants import inline_keys_groups 13 | """ 14 | Create a keyboard with buttons. 15 | 16 | :param keys: List of buttons 17 | :param row_width: Number of buttons in a row. 18 | :param resize_keyboard: Resize keyboard to small ones (works with reply keys only, not inline keys). 19 | :param is_inline: If True, create inline keyboard. 20 | :param callback_data: If not None, use keys text as callback data. 21 | """ 22 | keys = list(keys) 23 | if callback_data and (len(keys) != len(callback_data)): 24 | logger.warning('Callback data length is not equal to keys length. Some keys will be missing.') 25 | 26 | # Empty keyboard 27 | if not keys: 28 | return 29 | 30 | if is_inline: 31 | # Set callback data to keys text 32 | if callback_data is None: 33 | callback_data = keys 34 | 35 | sort_by_array = [inline_keys_groups.get(callback, ind + 100) for ind, callback in enumerate(callback_data)] 36 | 37 | print(keys, callback_data, sort_by_array) 38 | sorted_array = sorted(zip(sort_by_array, keys, callback_data), key=lambda x: x[0]) 39 | print(sorted_array) 40 | 41 | old_value = sorted_array[0][0] 42 | buttons = [] 43 | markup = types.InlineKeyboardMarkup(row_width=inline_row_width) 44 | 45 | for sort_by, key, callback in sorted_array: 46 | if sort_by - old_value >= 10: 47 | markup.add(*buttons) 48 | buttons = [] 49 | 50 | old_value = sort_by 51 | key = emoji.emojize(key) 52 | button = types.InlineKeyboardButton(key, callback_data=callback) 53 | buttons.append(button) 54 | 55 | markup.add(*buttons) 56 | return markup 57 | 58 | else: 59 | # create reply keyboard 60 | keys = list(map(emoji.emojize, keys)) 61 | markup = types.ReplyKeyboardMarkup( 62 | row_width=reply_row_width, 63 | resize_keyboard=resize_keyboard 64 | ) 65 | buttons = map(types.KeyboardButton, keys) 66 | markup.add(*buttons) 67 | return markup 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # my custom gitignore 132 | .vscode/ 133 | *.json -------------------------------------------------------------------------------- /src/handlers/command_handler.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from src import constants 4 | from src.constants import keyboards, post_types, states 5 | from src.handlers.base import BaseHandler 6 | from src.user import User 7 | from src.data_models.base import BasePost 8 | from bson import ObjectId 9 | 10 | 11 | class CommandHandler(BaseHandler): 12 | def register(self): 13 | @self.stackbot.bot.middleware_handler(update_types=['message']) 14 | def init_message_handler(bot_instance, message): 15 | """ 16 | Initialize user to use in other message handlers. 17 | 18 | 1. Get user object (also registers user if not exists) 19 | """ 20 | # Getting updated user before message reaches any other handler 21 | self.stackbot.user = User( 22 | chat_id=message.chat.id, first_name=message.chat.first_name, 23 | db=self.db, stackbot=self.stackbot, 24 | ) 25 | 26 | @self.stackbot.bot.message_handler(commands=['start']) 27 | def start(message): 28 | """ 29 | This handler is called when user sends /start command. 30 | 31 | 1. Send Welcome Message 32 | 2. Insert (if user is new, or update) user in database. 33 | 3. Reset user data (settings, state, track data) 34 | """ 35 | self.stackbot.user.reset() 36 | self.stackbot.user.register(message) 37 | 38 | # Parse message text to get what user wants 39 | match = re.match('\/start (?P\w+)_(?P.+)', message.text) 40 | if not match: 41 | return 42 | 43 | action = match.group('action') 44 | post_id = ObjectId(match.group('post_id')) 45 | 46 | # Get the post type 47 | current_post_type = post_types.ANSWER if action == 'answer' else post_types.COMMENT 48 | 49 | # Update user 50 | self.stackbot.user.update_state(states.ANSWER_QUESTION if action == 'answer' else states.COMMENT_POST) 51 | self.stackbot.user.track(replied_to_post_id=post_id) 52 | 53 | # Send the requested post 54 | self.stackbot.user.post = BasePost( 55 | db=self.stackbot.user.db, stackbot=self.stackbot, 56 | post_id=post_id, chat_id=self.stackbot.user.chat_id, 57 | ) 58 | self.stackbot.user.post.send_to_one(self.stackbot.user.chat_id) 59 | 60 | # Ask user for his action input 61 | self.stackbot.user.send_message( 62 | constants.POST_START_MESSAGE.format( 63 | first_name=self.stackbot.user.first_name, 64 | post_type=current_post_type 65 | ), 66 | reply_markup=keyboards.send_post, 67 | ) 68 | -------------------------------------------------------------------------------- /src/data_models/answer.py: -------------------------------------------------------------------------------- 1 | from bson.objectid import ObjectId 2 | from src import constants 3 | from src.constants import inline_keys, post_status, post_types 4 | from src.data_models.base import BasePost 5 | from src.utils.keyboard import create_keyboard 6 | from telebot import types 7 | 8 | 9 | class Answer(BasePost): 10 | """ 11 | Class to handle the answers sent by the users to a question. 12 | """ 13 | @property 14 | def question(self) -> dict: 15 | """ 16 | Get the question of the answer. 17 | 18 | :return: The question of the answer. 19 | """ 20 | post = self.as_dict() 21 | return self.db.post.find_one({'_id': ObjectId(post['replied_to_post_id'])}) 22 | 23 | @property 24 | def emoji(self) -> str: 25 | """ 26 | Get the emoji of the answer. 27 | 28 | :return: The emoji of the answer. 29 | """ 30 | answer = self.as_dict() 31 | if self.question.get('accepted_answer') == answer['_id']: 32 | return ':check_mark_button:' 33 | else: 34 | return constants.EMOJI.get(answer['type']) 35 | 36 | @emoji.setter 37 | def emoji(self, value): 38 | self.emoji = value 39 | 40 | def send(self) -> dict: 41 | """ 42 | Send the answer to the right audience. 43 | 44 | :param post_id: ObjectId of the answer post. 45 | :return: The answer post. 46 | """ 47 | post = self.as_dict() 48 | 49 | # Send to the user who asked question 50 | question = self.db.post.find_one({'_id': ObjectId(post['replied_to_post_id'])}) 51 | question_owner_chat_id = question['chat']['id'] 52 | 53 | # Send to Followers 54 | followers = self.get_followers() 55 | 56 | self.send_to_many(list({self.owner_chat_id, question_owner_chat_id}) + followers) 57 | return post 58 | 59 | def get_actions_keyboard(self) -> types.InlineKeyboardMarkup: 60 | """ 61 | Get answer section actions keyboard. 62 | 63 | Keyboard changes depending on the user's role. 64 | If the user is the owner of the question, he can accept the answer. 65 | """ 66 | keys, _ = super().get_actions_keys_and_owner() 67 | 68 | answer = self.as_dict() 69 | question = self.db.post.find_one({'_id': ObjectId(answer['replied_to_post_id'])}) 70 | question_owner_chat_id = question['chat']['id'] 71 | 72 | if self.chat_id == question_owner_chat_id: 73 | if question.get('accepted_answer') == answer['_id']: 74 | keys.append(inline_keys.unaccept) 75 | else: 76 | keys.append(inline_keys.accept) 77 | 78 | # if post is closed, remove open post only actions from keyboard 79 | if self.post_status != post_status.OPEN: 80 | keys = self.remove_closed_post_actions(keys) 81 | 82 | reply_markup = create_keyboard(*keys, is_inline=True) 83 | return reply_markup 84 | 85 | def accept_answer(self): 86 | """ 87 | Accept/Unaccept the answer. 88 | 89 | :return: The answer post. 90 | """ 91 | answer = self.as_dict() 92 | question = self.db.post.find_one({'_id': ObjectId(answer['replied_to_post_id'])}) 93 | question_owner_chat_id = question['chat']['id'] 94 | 95 | # Check if it's already the accepted answer 96 | if question.get('accepted_answer') == answer['_id']: 97 | self.db.post.update_one( 98 | {'_id': question['_id']}, 99 | {'$set': {'status': post_status.OPEN}}, 100 | {'$unset': {'accepted_answer': 1}} 101 | ) 102 | self.db.post.update_one({'_id': answer['_id']}, {'$unset': {'accepted': 1}}) 103 | else: 104 | # Add accepted answer to question 105 | self.db.post.update_one( 106 | {'_id': question['_id']}, 107 | {'$set': {'status': post_status.RESOLVED, 'accepted_answer': answer['_id']}} 108 | ) 109 | 110 | # Unaccept the previous accepted answer 111 | self.db.post.update_one({'accepted': True, 'type': post_types.ANSWER}, {'$unset': {'accepted': 1}}) 112 | 113 | # Accept the new answer 114 | self.db.post.update_one({'_id': answer['_id']}, {'$set': {'accepted': True}}) 115 | 116 | # Send to the answer owner that the question is accepted 117 | answer_owner_chat_id = answer['chat']['id'] 118 | self.stackbot.send_message(answer_owner_chat_id, constants.USER_ANSWER_IS_ACCEPTED_MESSAGE) 119 | 120 | # Send to Audience: Answer and question followers 121 | answer_followers_chat_id = self.get_followers() 122 | question_followers_chat_id = question.get('followers', []) 123 | audience_chat_id = set(question_followers_chat_id).union(answer_followers_chat_id) 124 | for chat_id in audience_chat_id: 125 | self.stackbot.send_message(chat_id, constants.NEW_ACCEPTED_ANSWER) 126 | 127 | self.send_to_many(audience_chat_id.union([answer_owner_chat_id])) 128 | 129 | return answer 130 | -------------------------------------------------------------------------------- /src/constants.py: -------------------------------------------------------------------------------- 1 | from types import SimpleNamespace 2 | 3 | from src.data import DATA_DIR 4 | from src.utils.io import read_file 5 | from src.utils.keyboard import create_keyboard 6 | 7 | keys = SimpleNamespace( 8 | settings=':gear: Settings', 9 | cancel=':cross_mark: Cancel', 10 | back=':BACK_arrow: Back', 11 | next=':arrow_right: Next', 12 | add=':heavy_plus_sign: Add', 13 | save=':check_mark_button: Save', 14 | yes=':white_check_mark: Yes', 15 | no=':negative_squared_cross_mark: No', 16 | ask_question=':red_question_mark: Ask a Question', 17 | send_post=':envelope_with_arrow: Send', 18 | send_answer=':envelope_with_arrow: Send Answer', 19 | search_questions=':magnifying_glass_tilted_right: Search Questions', 20 | my_data=':thought_balloon: My Data', 21 | my_questions=':red_question_mark: My Questions', 22 | my_answers=':bright_button: My Answers', 23 | my_comments=':speech_balloon: My Comments', 24 | my_bookmarks=':pushpin: My Bookmarks', 25 | ) 26 | 27 | inline_keys = SimpleNamespace( 28 | actions='Actions »', 29 | back='« Back', 30 | answer=':bright_button: Answer', 31 | follow=':plus: Follow', 32 | unfollow=':minus: Unfollow', 33 | like=':red_heart:', 34 | unlike=':white_heart:', 35 | accept=':check_mark_button: Accept', 36 | unaccept=':red_exclamation_mark: Unaccept', 37 | comment=':speech_balloon: Comment', 38 | delete=':wastebasket: Delete', 39 | undelete=':recycling_symbol: Undelete', 40 | open=':green_circle: Open', 41 | close=':red_circle: Close', 42 | edit=':pencil: Edit', 43 | change_identity=':smiling_face_with_sunglasses: Change Identity', 44 | ananymous=':smiling_face_with_sunglasses: Ananymous', 45 | first_name=':bust_in_silhouette: First Name', 46 | username=':bust_in_silhouette: Username', 47 | alias=':smiling_face_with_sunglasses: Alias', 48 | mute=':muted_speaker: Mute Bot', 49 | unmute=':speaker_high_volume: Unmute Bot', 50 | show_comments=':right_anger_bubble:', 51 | show_answers=':dim_button:', 52 | original_post=':reverse_button: Main Post', 53 | next_post='»', 54 | page_number='Page Number', 55 | prev_post='«', 56 | 57 | # Note: there is one space at the end of this key to make it 58 | # different from first_page. If they are the same, last_page overrides first page in 59 | # inline_keys_groups dictionary and causes the keyboard keys to be out of order. 60 | last_page=':white_small_square: ', 61 | first_page=':white_small_square:', 62 | show_more=u'\u2193 Show More', 63 | show_less=u'\u2191 Show Less', 64 | export_gallery=':inbox_tray: Export', 65 | bookmark=':pushpin: Bookmark', 66 | unbookmark=':pushpin: Unbookmark', 67 | attachments=':paperclip:', 68 | ) 69 | 70 | inline_keys_groups = { 71 | # Actions inline keyboard 72 | inline_keys.back: 1, 73 | inline_keys.comment: 2, 74 | inline_keys.answer: 3, 75 | inline_keys.accept: 3, 76 | inline_keys.unaccept: 3, 77 | 78 | inline_keys.bookmark: 4 + 10, 79 | inline_keys.unbookmark: 4 + 10, 80 | inline_keys.follow: 5 + 10, 81 | inline_keys.unfollow: 5 + 10, 82 | 83 | inline_keys.delete: 6 + 20, 84 | inline_keys.undelete: 6 + 20, 85 | inline_keys.open: 7 + 20, 86 | inline_keys.close: 7 + 20, 87 | inline_keys.edit: 8 + 20, 88 | 89 | # Main inline keyboard 90 | inline_keys.original_post: 1, 91 | inline_keys.attachments: 2, 92 | inline_keys.show_answers: 3, 93 | inline_keys.show_comments: 4, 94 | inline_keys.like: 5, 95 | 96 | inline_keys.show_more: 5 + 10, 97 | inline_keys.show_less: 5 + 10, 98 | inline_keys.actions: 6 + 10, 99 | 100 | inline_keys.first_page: 7 + 20, 101 | inline_keys.prev_post: 7 + 20, 102 | inline_keys.page_number: 8 + 20, 103 | inline_keys.next_post: 9 + 20, 104 | inline_keys.last_page: 9 + 20, 105 | } 106 | 107 | 108 | keyboards = SimpleNamespace( 109 | main=create_keyboard(keys.ask_question, keys.search_questions, keys.my_data, keys.settings), 110 | send_post=create_keyboard(keys.cancel, keys.send_post), 111 | my_data=create_keyboard( 112 | keys.my_questions, keys.my_answers, keys.my_comments, keys.my_bookmarks, 113 | keys.back, reply_row_width=2), 114 | ) 115 | 116 | states = SimpleNamespace( 117 | MAIN='MAIN', 118 | ASK_QUESTION='ASK_QUESTION', 119 | ANSWER_QUESTION='ANSWER_QUESTION', 120 | COMMENT_POST='COMMENT_POST', 121 | SEARCH_QUESTIONS='SEARCH_QUESTIONS', 122 | ) 123 | 124 | post_status = SimpleNamespace( 125 | # Answer Status 126 | ACCEPTED_ANSWER=':check_mark_button:', 127 | 128 | # General Status 129 | PREP=':yellow_circle: Typing...', 130 | DRAFT=':white_circle: Draft', 131 | CLOSED=':red_circle: Closed', 132 | OPEN=':green_circle: Open', 133 | DELETED=':wastebasket: Deleted', 134 | RESOLVED=':check_mark_button: Resolved' 135 | ) 136 | 137 | post_types = SimpleNamespace( 138 | QUESTION='question', 139 | ANSWER='answer', 140 | COMMENT='comment', 141 | ) 142 | 143 | user_identity = SimpleNamespace( 144 | ANANYMOUS=':smiling_face_with_sunglasses: Ananymous', 145 | FIRST_NAME=':bust_in_silhouette: First Name', 146 | USERNAME=':bust_in_silhouette: Username', 147 | ALIAS=':smiling_face_with_sunglasses: Alias', 148 | ) 149 | 150 | post_text_length_button = SimpleNamespace( 151 | SHOW_MORE=inline_keys.show_more, 152 | SHOW_LESS=inline_keys.show_less, 153 | ) 154 | 155 | SUPPORTED_CONTENT_TYPES = ['text', 'photo', 'audio', 'document', 'video', 'voice', 'video_note'] 156 | EMOJI = { 157 | post_types.QUESTION: ':red_question_mark:', 158 | post_types.ANSWER: ':bright_button:', 159 | post_types.COMMENT: ':speech_balloon:', 160 | } 161 | 162 | HTML_ICON = { 163 | post_types.QUESTION: '❓', 164 | post_types.ANSWER: '⭐', 165 | post_types.COMMENT: '💬', 166 | } 167 | 168 | OPEN_POST_ONLY_ACITONS = [ 169 | inline_keys.comment, inline_keys.edit, inline_keys.answer, 170 | ] 171 | 172 | # Message Limits 173 | 174 | # Posts longer than this are not allowed 175 | POST_CHAR_LIMIT = { 176 | post_types.QUESTION: 2500, 177 | post_types.ANSWER: 2500, 178 | post_types.COMMENT: 500, 179 | } 180 | ATTACHMENT_LIMIT = 3 181 | MAX_NUMBER_OF_CHARACTERS_MESSAGE = ( 182 | ':cross_mark: Max number of characters reached. ' 183 | 'You made {num_extra_characters} extra characters. ' 184 | 'Last message is ignored.' 185 | ) 186 | MAX_NUMBER_OF_ATTACHMENTS_MESSAGE = ( 187 | ':cross_mark: Max number of attachments reached. ' 188 | f'You can have up to {ATTACHMENT_LIMIT} attachments only. ' 189 | 'Last attachment is ignored.' 190 | ) 191 | 192 | MIN_POST_TEXT_LENGTH = 20 193 | MIN_POST_TEXT_LENGTH_MESSAGE = f':cross_mark: Enter at least {MIN_POST_TEXT_LENGTH} characters.' 194 | 195 | # This is used for show more button 196 | MESSAGE_SPLIT_CHAR_LIMIT = 250 197 | 198 | 199 | # Auto delete user and bot messages after a period of time 200 | DELETE_BOT_MESSAGES_AFTER_TIME = 1 201 | DELETE_USER_MESSAGES_AFTER_TIME = 1 202 | DELETE_FILE_MESSAGES_AFTER_TIME = 1 * 60 * 60 203 | 204 | # Constant Text Messages 205 | # General Templates 206 | HOW_TO_ASK_QUESTION_GUIDE = read_file(DATA_DIR / 'guide.html') 207 | WELCOME_MESSAGE = "Hey {first_name}!" 208 | CANCEL_MESSAGE = ':cross_mark: Canceled.' 209 | IDENTITY_TYPE_NOT_SET_WARNING = ( 210 | ':warning: {identity_type} is not set. ' 211 | 'Please set it in your settings or Telegram.' 212 | ) 213 | MY_DATA_MESSAGE = ':thought_balloon: Select your data type from the menu:' 214 | BACK_TO_HOME_MESSAGE = ':house: Back to Home' 215 | NEW_ACCEPTED_ANSWER = ':check_mark_button: New accepted answer:' 216 | USER_ANSWER_IS_ACCEPTED_MESSAGE = ':party_popper: Awesome! Your answer is accepted now.' 217 | 218 | # Post Templates 219 | POST_OPEN_SUCCESS_MESSAGE = ":check_mark_button: {post_type} sent successfully." 220 | EMPTY_POST_MESSAGE = ':cross_mark: Empty!' 221 | POST_PREVIEW_MESSAGE = ( 222 | ':pencil: {post_type} Preview\n\n' 223 | '{post_text}\n' 224 | f'{"_" * 10}\n' 225 | f'When done, click {keys.send_post}.\n\n' 226 | ':memo: {num_characters_left} characters left.\n' 227 | ':ID_button: {post_id}' 228 | ) 229 | SEND_POST_TO_ALL_MESSAGE = ( 230 | '{emoji} New {post_type}\n' 231 | ':bust_in_silhouette: From: {from_user}\n' 232 | '{post_status}\n' 233 | '\n' 234 | '{post_text}\n' 235 | '\n\n' 236 | ':calendar: {date}\n' 237 | ':ID_button: {post_id}' 238 | ) 239 | POST_START_MESSAGE = ( 240 | ":pencil: {first_name}, send your {post_type} here.\n\n" 241 | f"When done, click {keys.send_post}." 242 | ) 243 | 244 | # Question Templates 245 | EMPTY_QUESTION_TEXT_MESSAGE = ':warning: Empty Question' 246 | 247 | # File Templates 248 | FILE_NOT_FOUND_ERROR_MESSAGE = ':cross_mark: File not found!' 249 | UNSUPPORTED_CONTENT_TYPE_MESSAGE = ( 250 | ':cross_mark: Unsupported content type.\n\n' 251 | ':safety_pin: Allowed types: {supported_contents}' 252 | ) 253 | 254 | # Settings Templates 255 | SETTINGS_START_MESSAGE = ( 256 | ':gear: Settings\n' 257 | ':bust_in_silhouette: {first_name} ({username})\n\n' 258 | ':red_question_mark: {num_questions} ({num_open_questions} Open)\n' 259 | ':bright_button: {num_answers} ({num_accepted_answers} Accepted Answer)\n' 260 | ':speech_balloon: {num_comments}\n\n' 261 | ':smiling_face_with_sunglasses: Identity: {identity}' 262 | ) 263 | 264 | # Gallery Templates 265 | GALLERY_NO_POSTS_MESSAGE = ':red_exclamation_mark: No {post_type} found.' 266 | -------------------------------------------------------------------------------- /src/user.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Union 2 | 3 | from loguru import logger 4 | from telebot import types 5 | 6 | from src import constants 7 | from src.constants import (DELETE_BOT_MESSAGES_AFTER_TIME, inline_keys, 8 | keyboards, post_status, post_types, states) 9 | from src.data_models import Answer, Comment, Question 10 | from src.data_models.base import BasePost 11 | 12 | 13 | class User: 14 | """ 15 | Class to handle telegram bot users. 16 | """ 17 | def __init__(self, chat_id: str, first_name: str, db, stackbot, post_id: str = None): 18 | """ 19 | Initialize user. 20 | 21 | :param chat_id: Telegram chat id. 22 | :param db: MongoDB connection. 23 | :param stackbot: StackBot class object. 24 | :param first_name: User first name. 25 | :param post_id: ObjectId of the post, defaults to None. 26 | """ 27 | self.chat_id = chat_id 28 | self.db = db 29 | self.stackbot = stackbot 30 | self.first_name = first_name 31 | 32 | # post handlers 33 | self.post_id = post_id 34 | self._post = None 35 | 36 | @staticmethod 37 | def get_post_handler(state, post_type): 38 | if (post_type == post_types.QUESTION) or (state == states.ASK_QUESTION): 39 | return Question 40 | elif (post_type == post_types.ANSWER) or (state == states.ANSWER_QUESTION): 41 | return Answer 42 | elif (post_type == post_types.COMMENT) or (state == states.COMMENT_POST): 43 | return Comment 44 | 45 | return BasePost 46 | 47 | @property 48 | def post(self): 49 | """ 50 | Return the right post handler based on user state or post type. 51 | """ 52 | if self._post is not None: 53 | return self._post 54 | 55 | post = self.db.post.find_one({'_id': self.post_id}) or {} 56 | args = dict(db=self.db, stackbot=self.stackbot, chat_id=self.chat_id, post_id=self.post_id) 57 | self._post = self.get_post_handler(self.state, post.get('type'))(**args) 58 | 59 | return self._post 60 | 61 | @post.setter 62 | def post(self, post_handler): 63 | if not isinstance(post_handler, BasePost): 64 | raise TypeError('Post must be an instance of BasePost (Question, Answer, Comment).') 65 | 66 | args = dict( 67 | db=post_handler.db, stackbot=post_handler.stackbot, chat_id=post_handler.chat_id, 68 | is_gallery=post_handler.is_gallery, gallery_filters=post_handler.gallery_filters, 69 | post_id=post_handler.post_id, 70 | ) 71 | 72 | post_type = self.db.post.find_one({'_id': post_handler.post_id})['type'] 73 | self._post = self.get_post_handler(self.state, post_type)(**args) 74 | 75 | @property 76 | def user(self): 77 | return self.db.users.find_one({'chat.id': self.chat_id}) or {} 78 | 79 | @property 80 | def state(self): 81 | return self.user.get('state') 82 | 83 | @property 84 | def tracker(self): 85 | return self.user.get('tracker', {}) 86 | 87 | @property 88 | def settings(self): 89 | return self.user.get('settings') 90 | 91 | @property 92 | def username(self): 93 | username = self.user['chat'].get('username') 94 | return f'@{username}' if username else None 95 | 96 | @property 97 | def identity(self): 98 | """ 99 | User can have a custom identity: 100 | - ananymous 101 | - username 102 | - first name 103 | 104 | User identity is set from settings menu. 105 | """ 106 | user = self.user 107 | username = self.username 108 | 109 | identity_type = user['settings']['identity_type'] 110 | if identity_type == inline_keys.ananymous: 111 | return self.chat_id 112 | elif (identity_type == inline_keys.username) and (username is not None): 113 | return username 114 | elif identity_type == inline_keys.first_name: 115 | return f"{user['chat']['first_name']} ({self.chat_id})" 116 | 117 | return user['chat'].get(identity_type) or self.chat_id 118 | 119 | def send_message( 120 | self, text: str, reply_markup: Union[types.InlineKeyboardMarkup, types.ReplyKeyboardMarkup] = None, 121 | emojize: bool = True, delete_after: Union[bool, int] = DELETE_BOT_MESSAGES_AFTER_TIME 122 | ): 123 | """ 124 | Send message to user. 125 | 126 | :param text: Message text. 127 | :param reply_markup: Message reply markup. 128 | :param emojize: Emojize text, defaults to True. 129 | """ 130 | message = self.stackbot.send_message( 131 | chat_id=self.chat_id, text=text, reply_markup=reply_markup, 132 | emojize=emojize, delete_after=delete_after 133 | ) 134 | 135 | return message 136 | 137 | def edit_message(self, message_id, text=None, reply_markup=None, emojize: bool = True): 138 | self.stackbot.edit_message( 139 | chat_id=self.chat_id, message_id=message_id, text=text, 140 | reply_markup=reply_markup, emojize=emojize 141 | ) 142 | 143 | def delete_message(self, message_id: str): 144 | """ 145 | Delete user message. 146 | 147 | :param message_id: Message id to delete. 148 | """ 149 | self.stackbot.delete_message(chat_id=self.chat_id, message_id=message_id) 150 | 151 | def clean_preview(self, new_preview_message_id=None): 152 | """ 153 | Preview message is used to show the user the post that is going to be created. 154 | This method deletes the previous preview message and keeps track of the new one. 155 | 156 | :param new_preview_message: New preview message to track after deleting the old one, defaults to None. 157 | """ 158 | old_preview_message_id = self.tracker.get('preview_message_id') 159 | if old_preview_message_id: 160 | self.delete_message(old_preview_message_id) 161 | self.untrack('preview_message_id') 162 | 163 | if new_preview_message_id: 164 | self.track(preview_message_id=new_preview_message_id) 165 | 166 | def update_state(self, state: str): 167 | """ 168 | Update user state. 169 | 170 | :param state: New state. 171 | """ 172 | self.db.users.update_one({'chat.id': self.chat_id}, {'$set': {'state': state}}) 173 | 174 | def reset(self): 175 | """ 176 | Reset user state and data. 177 | """ 178 | logger.info('Reset user data.') 179 | self.db.users.update_one( 180 | {'chat.id': self.chat_id}, 181 | {'$set': {'state': states.MAIN}, '$unset': {'tracker': 1}} 182 | ) 183 | 184 | self.db.post.delete_one({'chat.id': self.chat_id, 'status': constants.post_status.PREP}) 185 | 186 | def register(self, message): 187 | logger.info('Registering user...') 188 | self.send_message( 189 | constants.WELCOME_MESSAGE.format(first_name=self.first_name), 190 | reply_markup=keyboards.main, 191 | delete_after=False 192 | ) 193 | self.db.users.update_one({'chat.id': message.chat.id}, {'$set': message.json}, upsert=True) 194 | self.update_settings(identity_type=inline_keys.ananymous, muted_bot=False) 195 | self.reset() 196 | 197 | @property 198 | def is_registered(self): 199 | """ 200 | Check if user exists in database. 201 | """ 202 | if self.db.users.find_one({'chat.id': self.chat_id}) is None: 203 | return False 204 | 205 | return True 206 | 207 | def track(self, **kwargs): 208 | """ 209 | Track user actions and any other data. 210 | """ 211 | track_data = self.tracker 212 | track_data.update(kwargs) 213 | self.db.users.update_one( 214 | {'chat.id': self.chat_id}, 215 | {'$set': {'tracker': track_data}} 216 | ) 217 | 218 | def untrack(self, *args): 219 | self.db.users.update_one( 220 | {'chat.id': self.chat_id}, 221 | {'$unset': {f'tracker.{arg}': 1 for arg in args}} 222 | ) 223 | 224 | def update_settings(self, **kwargs): 225 | """ 226 | Update user settings. 227 | """ 228 | settings = {f'settings.{key}': value for key, value in kwargs.items()} 229 | self.db.users.update_one( 230 | {'chat.id': self.chat_id}, 231 | {'$set': settings} 232 | ) 233 | 234 | def stats(self): 235 | num_questions = self.db.post.count_documents({'chat.id': self.chat_id, 'type': post_types.QUESTION}) 236 | num_open_questions = self.db.post.count_documents( 237 | {'chat.id': self.chat_id, 'type': post_types.QUESTION, 'status': post_status.OPEN} 238 | ) 239 | num_answers = self.db.post.count_documents({'chat.id': self.chat_id, 'type': post_types.ANSWER}) 240 | num_accepted_answers = self.db.post.count_documents({'chat.id': self.chat_id, 'type': post_types.ANSWER, 'accepted': True}) 241 | 242 | num_comments = self.db.post.count_documents({'chat.id': self.chat_id, 'type': post_types.COMMENT}) 243 | 244 | return dict( 245 | num_questions=num_questions, num_open_questions=num_open_questions, 246 | num_answers=num_answers, num_accepted_answers=num_accepted_answers, num_comments=num_comments 247 | ) 248 | 249 | def toggle_user_field(self, field: str, field_value: Any) -> None: 250 | """ 251 | Pull/Push to the user collection a value in key field. 252 | This can be used for bookmarks for example. We push post_id (value) to the user bookmarks field. 253 | 254 | :param key: Collection key to be toggled (push/pull) 255 | """ 256 | exists_flag = self.db.users.find_one({'chat.id': self.chat_id, field: field_value}) 257 | 258 | if exists_flag: 259 | self.db.users.update_one({'chat.id': self.chat_id}, {'$pull': {field: field_value}}) 260 | else: 261 | self.db.users.update_one( 262 | {'chat.id': self.chat_id}, {'$addToSet': {field: field_value}} 263 | ) 264 | -------------------------------------------------------------------------------- /src/run.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | import time 4 | from typing import Union 5 | 6 | import emoji 7 | from bson.objectid import ObjectId 8 | from loguru import logger 9 | from telebot import custom_filters, types 10 | 11 | from src.bot import bot 12 | from src.constants import (DELETE_BOT_MESSAGES_AFTER_TIME, 13 | DELETE_FILE_MESSAGES_AFTER_TIME) 14 | from src.db import db 15 | from src.filters import IsAdmin 16 | from src.handlers import CallbackHandler, CommandHandler, MessageHandler 17 | 18 | logger.remove() 19 | logger.add(sys.stderr, format="{time} {level} {message}", level="ERROR") 20 | 21 | class StackBot: 22 | """ 23 | Stackoverflow Telegram Bot. 24 | 25 | Using the Telegram Bot API, users can interact with each other to ask questions, 26 | comment, and answer. 27 | """ 28 | def __init__(self, telebot, db): 29 | self.bot = telebot 30 | self.db = db 31 | 32 | # Add custom filters 33 | self.bot.add_custom_filter(IsAdmin()) 34 | self.bot.add_custom_filter(custom_filters.TextMatchFilter()) 35 | self.bot.add_custom_filter(custom_filters.TextStartsFilter()) 36 | 37 | # User (user can be None when bot is not used by a user, but 38 | # used for sending messages in general) 39 | self.user = None 40 | 41 | # Note: The order of handlers matters as the first 42 | # handler that matches a message will be executed. 43 | self.handlers = [ 44 | CommandHandler(stackbot=self, db=self.db), 45 | MessageHandler(stackbot=self, db=self.db), 46 | CallbackHandler(stackbot=self, db=self.db), 47 | ] 48 | self.register() 49 | 50 | def run(self): 51 | # run bot with polling 52 | logger.info('Bot is running...') 53 | self.bot.infinity_polling() 54 | 55 | def register(self): 56 | for handler in self.handlers: 57 | handler.register() 58 | 59 | def send_message( 60 | self, chat_id: int, text: str, 61 | reply_markup: Union[types.ReplyKeyboardMarkup, types.InlineKeyboardMarkup] = None, 62 | emojize: bool = True, 63 | delete_after: Union[int, bool] = DELETE_BOT_MESSAGES_AFTER_TIME, 64 | auto_update: bool = False, 65 | ): 66 | """ 67 | Send message to telegram bot having a chat_id and text_content. 68 | 69 | :param chat_id: Chat id of the user. 70 | :param text: Text content of the message. 71 | :param reply_markup: Reply markup of the message. 72 | :param emojize: Emojize the text. 73 | :param delete_after: Auto delete message in seconds. 74 | """ 75 | text = emoji.emojize(text) if emojize else text 76 | message = self.bot.send_message(chat_id, text, reply_markup=reply_markup) 77 | 78 | if auto_update: 79 | self.queue_message_update(chat_id, message.message_id) 80 | 81 | if (type(delete_after) == int) and isinstance(reply_markup, types.ReplyKeyboardMarkup): 82 | # We need to keep the message which generated main keyboard so that 83 | # it does not go away. Otherwise, the user will be confused and won't have 84 | # any keyboaard to interact with. 85 | # To indicate this message, we set its delete_after to -1. 86 | logger.warning(f'Setting delete_after to -1 for message with message_id: {message.message_id}') 87 | delete_after = -1 88 | self.db.auto_delete.update_many( 89 | {'chat_id': chat_id, 'delete_after': -1}, 90 | {'$set': {'delete_after': 1}} 91 | ) 92 | self.queue_message_deletion(chat_id, message.message_id, delete_after) 93 | elif delete_after: 94 | self.queue_message_deletion(chat_id, message.message_id, delete_after) 95 | 96 | # If user is None, we don't have to update any callback data. 97 | # The message is sent by the bot and not by the user. 98 | if self.user is not None: 99 | self.update_callback_data(chat_id, message.message_id, reply_markup) 100 | else: 101 | logger.warning("User is None, callback data won't be updated.") 102 | 103 | return message 104 | 105 | def edit_message( 106 | self, chat_id: int, message_id: int, text: str = None, 107 | reply_markup: Union[types.ReplyKeyboardMarkup, types.InlineKeyboardMarkup] = None, 108 | emojize: bool = True, 109 | ): 110 | """ 111 | Edit telegram message text and/or reply_markup. 112 | """ 113 | if emojize and text: 114 | text = emoji.emojize(text) 115 | 116 | # if message text or reply_markup is the same as before, telegram raises an invalid request error 117 | # so we are doing try/catch to avoid this. 118 | try: 119 | if text and reply_markup: 120 | self.bot.edit_message_text(text=text, reply_markup=reply_markup, chat_id=chat_id, message_id=message_id) 121 | elif reply_markup: 122 | self.bot.edit_message_reply_markup(chat_id=chat_id, message_id=message_id, reply_markup=reply_markup) 123 | elif text: 124 | self.bot.edit_message_text(text=text, chat_id=chat_id, message_id=message_id) 125 | 126 | self.update_callback_data(chat_id, message_id, reply_markup) 127 | except Exception as e: 128 | logger.debug(f'Error editing message: {e}') 129 | 130 | def delete_message(self, chat_id: int, message_id: int): 131 | """ 132 | Delete bot message. 133 | """ 134 | try: 135 | self.bot.delete_message(chat_id, message_id) 136 | 137 | # Delete message trace from all collections. 138 | self.db.callback_data.delete_many({'chat_id': chat_id, 'message_id': message_id}) 139 | self.db.auto_update.delete_many({'chat_id': chat_id, 'message_id': message_id}) 140 | self.db.auto_delete.delete_many({'chat_id': chat_id, 'message_id': message_id}) 141 | except Exception as e: 142 | logger.debug(f'Error deleting message: {e}') 143 | 144 | def send_file( 145 | self, chat_id: int, file_unique_id: str, message_id: int = None, 146 | delete_after=DELETE_FILE_MESSAGES_AFTER_TIME 147 | ): 148 | """ 149 | Send file to telegram bot having a chat_id and file_id. 150 | """ 151 | attachment = self.file_unique_id_to_metadata(file_unique_id) 152 | if not attachment: 153 | return 154 | 155 | file_id, attachment_type, mime_type = attachment['file_id'], attachment['content_type'], attachment.get('mime_type') 156 | 157 | # Send file to user with the appropriate send_file method according to the attachment_type 158 | send_method = getattr(self.bot, f'send_{attachment_type}') 159 | message = send_method( 160 | chat_id, file_id, 161 | reply_to_message_id=message_id, 162 | caption=f"{mime_type or ''}", 163 | ) 164 | 165 | self.queue_message_deletion(chat_id, message.message_id, delete_after) 166 | 167 | def file_unique_id_to_metadata(self, file_unique_id: str): 168 | """ 169 | Get file metadata having a file_id. 170 | """ 171 | query_result = self.db.post.find_one({'attachments.file_unique_id': file_unique_id}, {'attachments.$': 1}) 172 | if not query_result: 173 | return 174 | 175 | return query_result['attachments'][0] 176 | 177 | def retrive_post_id_from_message_text(self, text: str): 178 | """ 179 | Get post_id from message text. 180 | """ 181 | text = emoji.demojize(text) 182 | last_line = text.split('\n')[-1] 183 | pattern = '^:ID_button: (?P[A-Za-z0-9]+)$' 184 | match = re.match(pattern, last_line) 185 | post_id = match.group('id') if match else None 186 | return ObjectId(post_id) 187 | 188 | def queue_message_deletion(self, chat_id: int, message_id: int, delete_after: Union[int, bool]): 189 | self.db.auto_delete.insert_one({ 190 | 'chat_id': chat_id, 'message_id': message_id, 191 | 'delete_after': delete_after, 'created_at': time.time(), 192 | }) 193 | 194 | def queue_message_update(self, chat_id: int, message_id: int): 195 | self.db.auto_update.insert_one({ 196 | 'chat_id': chat_id, 'message_id': message_id, 'created_at': time.time(), 197 | }) 198 | 199 | def update_callback_data( 200 | self, chat_id: int, message_id: int, 201 | reply_markup: Union[types.ReplyKeyboardMarkup, types.InlineKeyboardMarkup] 202 | ): 203 | if reply_markup and isinstance(reply_markup, types.InlineKeyboardMarkup): 204 | 205 | # If the reply_markup is an inline keyboard with actions button, it is the main keyboard and 206 | # we update its data once in a while to keep it fresh with number of likes, etc. 207 | buttons = [] 208 | for sublist in reply_markup.keyboard: 209 | sub_buttons = map(lambda button: emoji.demojize(button.text), sublist) 210 | buttons.extend(list(sub_buttons)) 211 | 212 | self.db.callback_data.update_one( 213 | { 214 | 'chat_id': chat_id, 215 | 'message_id': message_id, 216 | 'post_id': self.user.post.post_id, 217 | }, 218 | { 219 | '$set': { 220 | 'is_gallery': self.user.post.is_gallery, 221 | 'gallery_filters': self.user.post.gallery_filters, 222 | 223 | # We need the buttons to check to not update it asynchroneously 224 | # with the wrong keys. 225 | 'buttons': buttons, 226 | 227 | # We need the date of the callback data update to get the current active post on 228 | # the gallery for refreshing post info such as likes, answers, etc. 229 | 'created_at': time.time() 230 | } 231 | }, 232 | upsert=True 233 | ) 234 | 235 | if __name__ == '__main__': 236 | logger.info('Bot started...') 237 | stackbot = StackBot(telebot=bot, db=db) 238 | stackbot.run() 239 | -------------------------------------------------------------------------------- /src/handlers/message_handler.py: -------------------------------------------------------------------------------- 1 | import bson 2 | import emoji 3 | from loguru import logger 4 | from src import constants 5 | from src.bot import bot 6 | from src.constants import keyboards, keys, post_status, post_types, states 7 | from src.data_models.base import BasePost 8 | from src.handlers.base import BaseHandler 9 | from src.user import User 10 | 11 | 12 | class MessageHandler(BaseHandler): 13 | def register(self): 14 | @self.stackbot.bot.middleware_handler(update_types=['message']) 15 | def init_message_handler(bot_instance, message): 16 | """ 17 | Initialize user to use in other message handlers. 18 | 19 | 1. Get user object (also registers user if not exists) 20 | 3. Demojize message text. 21 | 4. Send user message for auto deletion. 22 | All user messages gets deleted from bot after a period of time to keep the bot history clean. 23 | This is managed a cron job that deletes old messages periodically. 24 | """ 25 | # Getting updated user before message reaches any other handler 26 | self.stackbot.user = User( 27 | chat_id=message.chat.id, first_name=message.chat.first_name, 28 | db=self.db, stackbot=self.stackbot, 29 | ) 30 | 31 | # register if not exits already 32 | if not self.stackbot.user.is_registered: 33 | self.stackbot.user.register(message) 34 | 35 | # Demojize text 36 | if message.content_type == 'text': 37 | message.text = emoji.demojize(message.text) 38 | 39 | self.stackbot.queue_message_deletion(message.chat.id, message.message_id, constants.DELETE_USER_MESSAGES_AFTER_TIME) 40 | 41 | @self.stackbot.bot.message_handler(text=[keys.ask_question]) 42 | def ask_question(message): 43 | """ 44 | Users starts sending question. 45 | 46 | 1. Update state. 47 | 2. Send how to ask a question guide. 48 | 3. Send start typing message. 49 | """ 50 | if not self.stackbot.user.state == states.MAIN: 51 | return 52 | 53 | self.stackbot.user.update_state(states.ASK_QUESTION) 54 | self.stackbot.user.send_message(constants.HOW_TO_ASK_QUESTION_GUIDE, reply_markup=keyboards.send_post) 55 | self.stackbot.user.send_message(constants.POST_START_MESSAGE.format( 56 | first_name=self.stackbot.user.first_name, post_type='question' 57 | )) 58 | 59 | @self.stackbot.bot.message_handler(text=[keys.cancel, keys.back]) 60 | def cancel_back(message): 61 | """ 62 | User cancels sending a post. 63 | 64 | 1. Reset user state and data. 65 | 2. Send cancel message. 66 | 3. Delete previous bot messages. 67 | """ 68 | self.stackbot.user.clean_preview() 69 | self.stackbot.user.send_message(constants.BACK_TO_HOME_MESSAGE, reply_markup=keyboards.main) 70 | self.stackbot.user.reset() 71 | 72 | @self.stackbot.bot.message_handler(text=[keys.send_post]) 73 | def send_post(message): 74 | """ 75 | User sends a post. 76 | 77 | 1. Submit post to database. 78 | 2. Check if post is not empty. 79 | 3. Send post to the relevant audience. 80 | 4. Reset user state and data. 81 | 5. Delete previous bot messages. 82 | """ 83 | post_id = self.stackbot.user.post.submit() 84 | if not post_id: 85 | # Either post is empty or too short 86 | return 87 | 88 | self.stackbot.user.post.post_id = post_id 89 | self.stackbot.user.post.send() 90 | self.stackbot.user.send_message( 91 | text=constants.POST_OPEN_SUCCESS_MESSAGE.format( 92 | post_type=self.stackbot.user.post.post_type.title(), 93 | ), 94 | reply_markup=keyboards.main 95 | ) 96 | 97 | # Reset user state and data 98 | self.stackbot.user.clean_preview() 99 | self.stackbot.user.reset() 100 | 101 | @self.stackbot.bot.message_handler(text=[keys.settings]) 102 | def settings(message): 103 | """ 104 | User wants to change settings. 105 | """ 106 | self.stackbot.user.send_message(self.get_settings_text(), self.get_settings_keyboard()) 107 | 108 | @self.stackbot.bot.message_handler(text=[keys.search_questions]) 109 | def search_questions(message): 110 | """ 111 | User asks for all questions to search through. 112 | """ 113 | gallery_filters = {'type': post_types.QUESTION, 'status': post_status.OPEN} 114 | self.send_gallery(gallery_filters=gallery_filters) 115 | 116 | @self.stackbot.bot.message_handler(text=[ 117 | keys.my_questions, keys.my_answers, keys.my_comments, keys.my_bookmarks 118 | ]) 119 | def send_user_data(message): 120 | """ 121 | User asks for all questions to search through. 122 | """ 123 | if message.text == keys.my_bookmarks: 124 | # Bookmarks are stored in user collection not each post 125 | # This makes it faster to fetch all bookmarks 126 | post_ids = self.db.users.find_one({'chat.id': message.chat.id}).get('bookmarks', []) 127 | gallery_filters = {'_id': {'$in': post_ids}} 128 | else: 129 | if message.text == keys.my_questions: 130 | filter_type = post_types.QUESTION 131 | elif message.text == keys.my_answers: 132 | filter_type = post_types.ANSWER 133 | elif message.text == keys.my_comments: 134 | filter_type = post_types.COMMENT 135 | gallery_filters = {'type': filter_type, 'chat.id': message.chat.id} 136 | 137 | self.send_gallery(gallery_filters=gallery_filters) 138 | 139 | @self.stackbot.bot.message_handler(text=[keys.my_data]) 140 | def my_data(message): 141 | """ 142 | User asks for all his data (Questions, Answers, Comments, etc.) 143 | """ 144 | # we should change the post_id for the buttons 145 | self.stackbot.user.send_message(constants.MY_DATA_MESSAGE, keyboards.my_data) 146 | 147 | # Handles all other messages with the supported content_types 148 | @bot.message_handler(content_types=constants.SUPPORTED_CONTENT_TYPES) 149 | def echo(message): 150 | """ 151 | Respond to user according to the current user state. 152 | 153 | 1. Check if message content is supported by the bot for the current post type (Question, Answer, Comment). 154 | 2. Update user post data in database with the new message content. 155 | 3. Send message preview to the user. 156 | 4. Delete previous post preview. 157 | """ 158 | print(message.text) 159 | if self.stackbot.user.state in states.MAIN: 160 | post_id = message.text 161 | 162 | try: 163 | self.stackbot.user.post = BasePost( 164 | db=self.stackbot.user.db, stackbot=self.stackbot, 165 | post_id=post_id, chat_id=self.stackbot.user.chat_id, 166 | ) 167 | self.stackbot.user.post.send_to_one(message.chat.id) 168 | except bson.errors.InvalidId: 169 | logger.warning('Invalid post id: {post_id}') 170 | return 171 | 172 | elif self.stackbot.user.state in [states.ASK_QUESTION, states.ANSWER_QUESTION, states.COMMENT_POST]: 173 | # Not all types of post support all content types. For example comments do not support texts. 174 | supported_contents = self.stackbot.user.post.supported_content_types 175 | if message.content_type not in supported_contents: 176 | self.stackbot.user.send_message( 177 | constants.UNSUPPORTED_CONTENT_TYPE_MESSAGE.format(supported_contents=' '.join(supported_contents)) 178 | ) 179 | return 180 | 181 | # Update the post content with the new message content 182 | self.stackbot.user.post.update(message, replied_to_post_id=self.stackbot.user.tracker.get('replied_to_post_id')) 183 | 184 | # Send message preview to the user 185 | new_preview_message = self.stackbot.user.post.send_to_one(chat_id=message.chat.id, preview=True) 186 | 187 | # Delete previous preview message and set the new one 188 | self.stackbot.user.clean_preview(new_preview_message.message_id) 189 | return 190 | 191 | def send_gallery(self, gallery_filters=None, order_by='date'): 192 | """ 193 | Send gallery of posts starting with the post with post_id. 194 | 195 | 1. Get posts from database. 196 | 2. Send posts to the user. 197 | 3. Store callback data for the gallery. 198 | 4. Clean the preview messages as galleries are not meant to stay in bot history. 199 | We delete the galleries after a period of time to keep the bot history clean. 200 | 201 | :param chat_id: Chat id to send gallery to. 202 | :param post_id: Post id to start gallery from. 203 | :param is_gallery: If True, send gallery of posts. If False, send single post. 204 | Next and previous buttions will be added to the message if is_gallery is True. 205 | """ 206 | posts = self.db.post.find(gallery_filters) 207 | if order_by: 208 | posts = posts.sort(order_by, -1) 209 | 210 | try: 211 | next_post_id = next(posts)['_id'] 212 | except StopIteration: 213 | text = constants.GALLERY_NO_POSTS_MESSAGE.format(post_type=gallery_filters.get('type', 'post')) 214 | self.stackbot.user.send_message(text) 215 | return 216 | 217 | # Send the posts gallery 218 | num_posts = self.db.post.count_documents(gallery_filters) 219 | is_gallery = True if num_posts > 1 else False 220 | 221 | self.stackbot.user.post = BasePost( 222 | db=self.stackbot.user.db, stackbot=self.stackbot, 223 | post_id=next_post_id, chat_id=self.stackbot.user.chat_id, 224 | is_gallery=is_gallery, gallery_filters=gallery_filters 225 | ) 226 | message = self.stackbot.user.post.send_to_one(self.stackbot.user.chat_id) 227 | 228 | # if user asks for this gallery again, we delete the old one to keep the history clean. 229 | self.stackbot.user.clean_preview(message.message_id) 230 | return message 231 | -------------------------------------------------------------------------------- /src/data/posts.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Ali Hejazizo 8 | 9 | 10 | 13 | 16 | 17 | 18 | 20 | 23 | 24 | 25 | 26 | 27 | 28 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 49 | 50 | 418 | 419 | 420 | 421 | 445 | 446 | 447 |
448 |
449 |
450 | {{{POSTS-CARDS}}} 451 |
452 |
453 |
454 | 455 | 456 | 457 | 458 | -------------------------------------------------------------------------------- /src/data_models/base.py: -------------------------------------------------------------------------------- 1 | import concurrent.futures 2 | import json 3 | from typing import Any, List, Tuple 4 | 5 | from bs4 import BeautifulSoup 6 | from bson.objectid import ObjectId 7 | from src import constants 8 | from src.constants import (SUPPORTED_CONTENT_TYPES, inline_keys, post_status, 9 | post_types) 10 | from src.data import DATA_DIR 11 | from src.utils.common import (human_readable_size, human_readable_unix_time, 12 | json_encoder) 13 | from src.utils.keyboard import create_keyboard 14 | from telebot import types, util 15 | 16 | 17 | class BasePost: 18 | """ 19 | General class for all types of posts: Question, Answer, Comment, etc. 20 | """ 21 | def __init__( 22 | self, db, stackbot, post_id: str = None, chat_id: str = None, 23 | is_gallery: bool = False, gallery_filters=None 24 | ): 25 | self.db = db 26 | self.collection = self.db.post 27 | self.stackbot = stackbot 28 | self.chat_id = chat_id 29 | self.supported_content_types = SUPPORTED_CONTENT_TYPES 30 | 31 | # post_id has setter and getter to convert it to ObjectId in case it is a string 32 | self._post_id = post_id 33 | self.is_gallery = is_gallery 34 | self.gallery_filters = gallery_filters 35 | 36 | # Show more and show less buttons 37 | self.post_text_length_button = None 38 | self._emoji = constants.EMOJI.get(self.post_type) 39 | self.html_icon = constants.HTML_ICON.get(self.post_type) 40 | 41 | @property 42 | def emoji(self): 43 | return self._emoji 44 | 45 | @property 46 | def post_id(self): 47 | if isinstance(self._post_id, str): 48 | return ObjectId(self._post_id) 49 | return self._post_id 50 | 51 | @post_id.setter 52 | def post_id(self, post_id: str): 53 | if isinstance(post_id, str): 54 | self._post_id = ObjectId(post_id) 55 | else: 56 | self._post_id = post_id 57 | 58 | def as_dict(self) -> dict: 59 | if not self.post_id: 60 | return {} 61 | 62 | return self.db.post.find_one({'_id': ObjectId(self.post_id)}) or {} 63 | 64 | @property 65 | def owner_chat_id(self) -> str: 66 | return self.as_dict().get('chat', {}).get('id') 67 | 68 | @property 69 | def post_type(self) -> str: 70 | post_type = self.as_dict().get('type') 71 | return post_type or self.__class__.__name__.lower() 72 | 73 | @property 74 | def post_status(self) -> str: 75 | return self.as_dict().get('status') 76 | 77 | def check_prep_post_limits(self, current_post, new_content): 78 | # Get current content text and attachments 79 | text, attachments = self.get_post_text_and_attachments(current_post) 80 | 81 | if new_content.get('text'): 82 | text += new_content['text'] 83 | if new_content.get('attachments'): 84 | attachments.append(new_content['attachments']) 85 | 86 | characters_left = constants.POST_CHAR_LIMIT[current_post.get('type', self.post_type)] - len(text) 87 | if characters_left < 0: 88 | message_text = constants.MAX_NUMBER_OF_CHARACTERS_MESSAGE.format( 89 | num_extra_characters=abs(characters_left) 90 | ) 91 | self.stackbot.send_message(self.chat_id, message_text) 92 | return False 93 | elif len(attachments) > constants.ATTACHMENT_LIMIT: 94 | self.stackbot.send_message(self.chat_id, constants.MAX_NUMBER_OF_ATTACHMENTS_MESSAGE) 95 | return False 96 | 97 | return True 98 | 99 | def update(self, message, replied_to_post_id: str = None) -> str: 100 | """ 101 | In ask_post state, the user can send a post in multiple messages. 102 | In each message, we update the current post with the message recieved. 103 | 104 | :param message: Message recieved from the user. 105 | :param replied_to_post_id: Unique id of the post that the user replied to. 106 | This is null for questions, but is required for answers and comments. 107 | :return: Unique id of the stored post in db. 108 | """ 109 | # If content is text, we store its html version to keep styles (bold, italic, etc.) 110 | if message.content_type not in self.supported_content_types: 111 | return 112 | 113 | if message.content_type == 'text': 114 | push_data = {'text': message.html_text} 115 | else: 116 | # If content is a file, its file_id, mimetype, etc is saved in database for later use 117 | # Note that if content is a list, the last one has the highest quality 118 | content = getattr(message, message.content_type) 119 | content = vars(content[-1]) if isinstance(content, list) else vars(content) 120 | content['content_type'] = message.content_type 121 | 122 | # removing non-json-serializable data 123 | content = self.remove_non_json_data(content) 124 | push_data = {'attachments': content} 125 | 126 | # Check post limitations (number of characters, number of attachments) 127 | current_post = self.db.post.find_one({'chat.id': self.chat_id, 'status': post_status.PREP}) or {} 128 | if not self.check_prep_post_limits(current_post=current_post, new_content=push_data): 129 | self.post_id = current_post['_id'] 130 | return 131 | 132 | # Save to database 133 | set_data = {'date': message.date, 'type': self.post_type, 'replied_to_post_id': replied_to_post_id} 134 | output = self.collection.update_one({'chat.id': message.chat.id, 'status': post_status.PREP}, { 135 | '$push': push_data, '$set': set_data, 136 | }, upsert=True) 137 | 138 | self.post_id = output.upserted_id or self.collection.find_one({ 139 | 'chat.id': message.chat.id, 'status': post_status.PREP 140 | })['_id'] 141 | 142 | def submit(self) -> str: 143 | """ 144 | Save post with post_id to database. 145 | 146 | :return: Unique id of the stored post in db. 147 | """ 148 | post = self.collection.find_one({'chat.id': self.chat_id, 'status': post_status.PREP}) 149 | if not post: 150 | return 151 | 152 | # Stor raw text for search, keywords, similarity, etc. 153 | post_text = self.get_post_text(post) 154 | if len(post_text) < constants.MIN_POST_TEXT_LENGTH: 155 | self.stackbot.send_message(self.chat_id, constants.MIN_POST_TEXT_LENGTH_MESSAGE) 156 | return 157 | 158 | # Update post status to OPEN (from PREP) 159 | self.collection.update_one({'_id': post['_id']}, {'$set': { 160 | 'status': post_status.OPEN, 'raw_text': post_text, 161 | }}) 162 | return post['_id'] 163 | 164 | def send_to_one(self, chat_id: str, preview: bool = False, schedule: bool = False) -> types.Message: 165 | """ 166 | Send post to user with chat_id. 167 | 168 | :param chat_id: Unique id of the user 169 | :param preview: If True, send post in preview mode. Default is False. 170 | :return: Message sent to user. 171 | """ 172 | post_text, post_keyboard = self.get_text_and_keyboard(preview=preview) 173 | 174 | # If post is sent to a user, then we should automatically update 175 | # it once in while to keep it fresh, for example, update number of likes. 176 | auto_update = not preview 177 | 178 | # Preview to user mode or send to other users 179 | sent_message = self.stackbot.send_message( 180 | chat_id=chat_id, text=post_text, 181 | reply_markup=post_keyboard, 182 | delete_after=False, 183 | auto_update=auto_update, 184 | post_id=self.post_id, 185 | ) 186 | 187 | return sent_message 188 | 189 | def send_to_many(self, chat_ids: list) -> types.Message: 190 | """ 191 | Send post to all users. 192 | 193 | :param chat_ids: List of unique ids of the users. 194 | :return: Message sent to users. 195 | """ 196 | with concurrent.futures.ThreadPoolExecutor() as executor: 197 | for chat_id in chat_ids: 198 | sent_message = executor.submit(self.send_to_one, chat_id, schedule=True) 199 | 200 | return sent_message 201 | 202 | def send_to_all(self) -> types.Message: 203 | """ 204 | Send post with post_id to all users. 205 | 206 | :param post_id: Unique id of the post. 207 | :return: Message sent to users. 208 | """ 209 | chat_ids = list(map(lambda user: user['chat']['id'], self.db.users.find())) 210 | return self.send_to_many(chat_ids) 211 | 212 | @staticmethod 213 | def get_post_text(post): 214 | post_text = '\n'.join(post.get('text', [])) 215 | return post_text 216 | 217 | @staticmethod 218 | def get_post_attachments(post): 219 | return post.get('attachments', []) 220 | 221 | @staticmethod 222 | def get_post_text_and_attachments(post): 223 | return BasePost.get_post_text(post), BasePost.get_post_attachments(post) 224 | 225 | def get_text(self, preview: bool = False, prettify: bool = True, truncate: bool = True) -> str: 226 | """ 227 | Get post text. 228 | 229 | :param preview: If True, send post in preview mode. Default is False. 230 | - In preview mode, we add help information for user such as how to send the post. 231 | :param prettify: If True, prettify the text. Default is True. 232 | - Prettify adds extra information such as post type, from_user, date, etc. 233 | :return: Post text. 234 | """ 235 | post = self.as_dict() 236 | 237 | # prettify message with other information such as sender, post status, etc. 238 | post_text = self.get_post_text(post) 239 | untrucated_post_text = post_text 240 | 241 | # Empty post text is allowed (User can send an empty post with attachments) 242 | if not post_text: 243 | post_text = constants.EMPTY_QUESTION_TEXT_MESSAGE 244 | 245 | # Splits one string into multiple strings, with a maximum amount of `chars_per_string` (max. 4096) 246 | # Splits by last '\n', '. ' or ' ' in exactly this priority. 247 | # smart_split returns a list with the splitted text. 248 | splitted_text = util.smart_split(post_text, chars_per_string=constants.MESSAGE_SPLIT_CHAR_LIMIT) 249 | if truncate and len(splitted_text) > 1: 250 | post_text = splitted_text[0] 251 | # If we truncate the text, some html tags may become unclosed resulting in 252 | # parsing html error. We therfore use beautifulsoup to close the tags. 253 | soup = BeautifulSoup(post_text, 'html.parser') 254 | post_text = soup.prettify() 255 | 256 | self.post_text_length_button = inline_keys.show_more 257 | 258 | elif not truncate and len(splitted_text) > 1: 259 | self.post_text_length_button = inline_keys.show_less 260 | 261 | # Prettify adds extra information such as post type, from_user, date, etc. 262 | # Otherwise only raw text is returned. 263 | if prettify: 264 | post_type = post['type'].title() 265 | if preview: 266 | num_characters_left = constants.POST_CHAR_LIMIT[post['type']] - len(untrucated_post_text) 267 | post_text = constants.POST_PREVIEW_MESSAGE.format( 268 | post_text=post_text, post_type=post_type, post_id=post['_id'], 269 | num_characters_left=num_characters_left 270 | ) 271 | else: 272 | from_user = self.get_post_owner_identity() 273 | post_text = constants.SEND_POST_TO_ALL_MESSAGE.format( 274 | from_user=from_user, post_text=post_text, post_status=post['status'], post_type=post_type, 275 | emoji=self.emoji, date=human_readable_unix_time(post['date']), post_id=post['_id'], 276 | ) 277 | 278 | return post_text 279 | 280 | def get_keyboard(self, preview: bool = False, truncate: bool = True) -> types.InlineKeyboardMarkup: 281 | """ 282 | Get post keyboard that has attached files + other actions on post such as like, actions menu, etc. 283 | 284 | :param preview: If True, send post in preview mode. Default is False. 285 | - In preview mode, there is no actions button. 286 | :return: Post keyboard. 287 | """ 288 | post = self.as_dict() 289 | 290 | keys, callback_data = [], [] 291 | # Add back to original post key 292 | original_post = self.db.post.find_one({'_id': ObjectId(post['replied_to_post_id'])}) 293 | if original_post: 294 | keys.append(inline_keys.original_post) 295 | callback_data.append(inline_keys.original_post) 296 | 297 | attachments = self.get_post_attachments(post) 298 | if attachments: 299 | keys.append(f'{inline_keys.attachments} ({len(attachments)})') 300 | callback_data.append(inline_keys.attachments) 301 | 302 | # show more/less button 303 | self.get_text(preview=preview, truncate=truncate) 304 | if self.post_text_length_button: 305 | keys.append(self.post_text_length_button) 306 | callback_data.append(self.post_text_length_button) 307 | 308 | # If it's a preview message, we are done! 309 | if preview: 310 | post_keyboard = create_keyboard(*keys, callback_data=callback_data, is_inline=True) 311 | return post_keyboard 312 | 313 | # Add comments, answers, etc. 314 | num_comments = self.db.post.count_documents( 315 | {'replied_to_post_id': self.post_id, 'type': post_types.COMMENT, 'status': post_status.OPEN}) 316 | num_answers = self.db.post.count_documents( 317 | {'replied_to_post_id': self.post_id, 'type': post_types.ANSWER, 'status': post_status.OPEN}) 318 | if num_comments: 319 | keys.append(f'{inline_keys.show_comments} ({num_comments})') 320 | callback_data.append(inline_keys.show_comments) 321 | if num_answers: 322 | keys.append(f'{inline_keys.show_answers} ({num_answers})') 323 | callback_data.append(inline_keys.show_answers) 324 | 325 | # Add actions, like, etc. keys 326 | liked_by_user = self.collection.find_one({'_id': ObjectId(self.post_id), 'likes': self.chat_id}) 327 | like_key = inline_keys.like if liked_by_user else inline_keys.unlike 328 | num_likes = len(post.get('likes', [])) 329 | new_like_key = f'{like_key} ({num_likes})' if num_likes else like_key 330 | 331 | keys.extend([new_like_key, inline_keys.actions]) 332 | callback_data.extend([inline_keys.like, inline_keys.actions]) 333 | 334 | if not self.is_gallery: 335 | post_keyboard = create_keyboard(*keys, callback_data=callback_data, is_inline=True) 336 | return post_keyboard 337 | 338 | # A gallery post is a post that has more than one post and user 339 | # can choose to go to next or previous post. 340 | 341 | # Find current page number 342 | conditions = self.gallery_filters.copy() 343 | num_posts = self.db.post.count_documents(conditions) 344 | 345 | conditions.update({'date': {'$lt': post['date']}}) 346 | post_position = self.db.post.count_documents(conditions) + 1 347 | 348 | # Previous page key 349 | prev_key = inline_keys.prev_post if post_position > 1 else inline_keys.first_page 350 | keys.append(prev_key) 351 | callback_data.append(prev_key) 352 | 353 | # Page number key 354 | post_position_key = f'-- {post_position}/{num_posts} --' 355 | keys.append(post_position_key) 356 | callback_data.append(inline_keys.page_number) 357 | 358 | # Next page key 359 | next_key = inline_keys.next_post if post_position < num_posts else inline_keys.last_page 360 | keys.append(next_key) 361 | callback_data.append(next_key) 362 | 363 | # add gallery export key 364 | keys.append(inline_keys.export_gallery) 365 | callback_data.append(inline_keys.export_gallery) 366 | 367 | post_keyboard = create_keyboard(*keys, callback_data=callback_data, is_inline=True) 368 | return post_keyboard 369 | 370 | def get_text_and_keyboard(self, preview=False, prettify: bool = True, truncate: bool = True): 371 | return self.get_text(preview, prettify, truncate), self.get_keyboard(preview, truncate) 372 | 373 | def get_followers(self) -> list: 374 | """ 375 | Get all followers of the current post. 376 | 377 | :return: List of unique ids of the followers. 378 | """ 379 | return self.as_dict().get('followers', []) 380 | 381 | def toggle_post_field(self, field: str, field_value: Any) -> None: 382 | """ 383 | Pull/Push to the collection field of the post. 384 | 385 | :param field: Collection field to be toggled (push/pull) 386 | """ 387 | exists_flag = self.collection.find_one({'_id': ObjectId(self.post_id), field: field_value}) 388 | 389 | if exists_flag: 390 | self.collection.update_one({'_id': ObjectId(self.post_id)}, {'$pull': {field: field_value}}) 391 | else: 392 | self.collection.update_one( 393 | {'_id': ObjectId(self.post_id)}, {'$addToSet': {field: field_value}} 394 | ) 395 | 396 | def follow(self): 397 | """ 398 | Follow/Unfollow post with post_id. 399 | 400 | :param post_id: Unique id of the post 401 | """ 402 | self.toggle_post_field('followers', self.chat_id) 403 | 404 | def like(self): 405 | """ 406 | Like post with post_id or unlike post if already liked. 407 | 408 | :param post_id: Unique id of the post 409 | """ 410 | self.toggle_post_field('likes', self.chat_id) 411 | 412 | def bookmark(self): 413 | """ 414 | Like post with post_id or unlike post if already liked. 415 | 416 | :param post_id: Unique id of the post 417 | """ 418 | self.toggle_post_field('bookmarked_by', self.chat_id) 419 | 420 | def get_actions_keys_and_owner(self) -> Tuple[List, str]: 421 | """ 422 | Get general actions keys and owner of the post. Every post has: 423 | - back 424 | - comment 425 | - edit (for owner only) 426 | - follow/unfollow (for non-owner users only) 427 | - open/close (for owner only) 428 | 429 | :param post_id: Unique id of the post 430 | :param chat_id: Unique id of the user 431 | :return: List of actions keys and owner of the post. 432 | """ 433 | post = self.as_dict() 434 | owner_chat_id = post['chat']['id'] 435 | 436 | # every user can comment 437 | keys = [inline_keys.back] 438 | 439 | # comment is allowed only on open questions 440 | if post['status'] == post_status.OPEN: 441 | keys.append(inline_keys.comment) 442 | 443 | # non-owner users can follow/unfollow post 444 | if self.chat_id != owner_chat_id: 445 | if self.chat_id in post.get('followers', []): 446 | keys.append(inline_keys.unfollow) 447 | else: 448 | keys.append(inline_keys.follow) 449 | 450 | # post owners can edit, delete, open/close post. 451 | if self.chat_id == owner_chat_id: 452 | keys.append(inline_keys.edit) 453 | 454 | current_status = post['status'] 455 | if current_status == post_status.DELETED: 456 | keys.append(inline_keys.undelete) 457 | else: 458 | keys.append(inline_keys.delete) 459 | if current_status == post_status.OPEN: 460 | keys.append(inline_keys.close) 461 | elif current_status == post_status.CLOSED: 462 | keys.append(inline_keys.open) 463 | 464 | # Check if post is bookmarked by the user 465 | bookmarked = self.db.users.find_one({'chat.id': self.chat_id, 'bookmarks': self.post_id}) 466 | if bookmarked: 467 | keys.append(inline_keys.unbookmark) 468 | else: 469 | keys.append(inline_keys.bookmark) 470 | 471 | return keys, owner_chat_id 472 | 473 | def get_attachments_keyboard(self): 474 | post = self.as_dict() 475 | 476 | keys = [inline_keys.back] 477 | callback_data = [inline_keys.back] 478 | 479 | # Add attachments 480 | for attachment in self.get_post_attachments(post): 481 | # Attachments may have or may not have a file_name attribute. 482 | # But they always have content_type 483 | file_name = attachment.get('file_name') or attachment['content_type'] 484 | file_size = human_readable_size(attachment['file_size']) 485 | keys.append(f"{file_name} - {file_size}") 486 | callback_data.append(attachment['file_unique_id']) 487 | 488 | return create_keyboard(*keys, callback_data=callback_data, is_inline=True) 489 | 490 | def remove_closed_post_actions(self, keys) -> List: 491 | """ 492 | Remove actions keys if post is closed. 493 | 494 | :param keys: List of actions keys 495 | :return: List of actions keys 496 | """ 497 | new_keys = [] 498 | for key in keys: 499 | if key in constants.OPEN_POST_ONLY_ACITONS: 500 | continue 501 | new_keys.append(key) 502 | 503 | return new_keys 504 | 505 | def switch_field_between_multiple_values(self, field: str, values: List): 506 | """ 507 | Close/Open post. 508 | Nobody can comment/answer to a closed post. 509 | """ 510 | current_field_value = self.as_dict()[field] 511 | new_index = values.index(current_field_value) - 1 512 | 513 | self.collection.update_one( 514 | {'_id': ObjectId(self.post_id)}, 515 | {'$set': {field: values[new_index]}} 516 | ) 517 | 518 | def get_post_owner_identity(self) -> str: 519 | """ 520 | Return user identity. 521 | User identity can be 'anonymous', 'usrname', 'first_name'. 522 | 523 | :param chat_id: Unique id of the user 524 | """ 525 | from src.user import User 526 | user = User(chat_id=self.owner_chat_id, first_name=None, db=self.db, stackbot=self.stackbot) 527 | return user.identity 528 | 529 | @staticmethod 530 | def remove_non_json_data(json_data): 531 | return json.loads(json.dumps(json_data, default=json_encoder)) 532 | 533 | def export(self, format='html'): 534 | """ 535 | Export post as html 536 | """ 537 | post = self.as_dict() 538 | if format == 'html': 539 | with open(DATA_DIR / 'post_card.html', 'r') as f: 540 | template_html = f.read() 541 | 542 | replace_map = { 543 | 'emoji': self.html_icon, 544 | 'post_id': post['_id'], 545 | 'post_type': post['type'].title(), 546 | 'text': self.get_text(prettify=False, truncate=False, preview=False), 547 | 'date': human_readable_unix_time(post['date']), 548 | } 549 | for key, value in replace_map.items(): 550 | template_html = template_html.replace(r'{{{' + key + r'}}}', str(value)) 551 | 552 | return template_html 553 | -------------------------------------------------------------------------------- /src/handlers/callback_handler.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import emoji 4 | from bson.objectid import ObjectId 5 | from loguru import logger 6 | from src import constants 7 | from src.bot import bot 8 | from src.constants import (inline_keys, keyboards, post_status, post_types, 9 | states) 10 | from src.data import DATA_DIR 11 | from src.data_models.base import BasePost 12 | from src.handlers.base import BaseHandler 13 | from src.user import User 14 | from src.utils.keyboard import create_keyboard 15 | 16 | 17 | class CallbackHandler(BaseHandler): 18 | def register(self): 19 | @self.stackbot.bot.middleware_handler(update_types=['callback_query']) 20 | def init_callback_handler(bot_instance, call): 21 | """ 22 | Initialize user to use in other callback handlers. 23 | 24 | 1. Get user object. 25 | 2. Demojize call data and call message text. 26 | """ 27 | # Every message sent with inline keyboard is stored in database with callback_data and 28 | # post_type (question, answer, comment, ...). When user clicks on an inline keyboard button, 29 | # we get the post type to know what kind of post we are dealing with. 30 | call_info = self.get_call_info(call) 31 | post_id = call_info.get('post_id') 32 | 33 | print(call_info) 34 | 35 | 36 | if post_id is None: 37 | logger.warning('post_id is None!') 38 | 39 | self.stackbot.user = User( 40 | chat_id=call.message.chat.id, first_name=call.message.chat.first_name, 41 | db=self.db, stackbot=self.stackbot, post_id=post_id 42 | ) 43 | # register user if not exists 44 | if not self.stackbot.user.is_registered: 45 | self.stackbot.user.register(call.message) 46 | 47 | # update post info 48 | gallery_filters = self.get_gallery_filters( 49 | call.message.chat.id, call.message.message_id, 50 | self.stackbot.user.post.post_id 51 | ) 52 | self.stackbot.user.post.is_gallery = call_info.get('is_gallery', False) 53 | self.stackbot.user.post.gallery_filters = gallery_filters 54 | 55 | # Demojize text 56 | call.data = emoji.demojize(call.data) 57 | call.message.text = emoji.demojize(call.message.text) 58 | 59 | @bot.callback_query_handler(func=lambda call: call.data == inline_keys.actions) 60 | def actions_callback(call): 61 | """Actions >> inline key callback. 62 | Post actions include follow/unfollow, answer, comment, open/close, edit, ... 63 | 64 | 1. Create actions keyboard according to the current post type. 65 | 2. Get post text content. 66 | 3. Edit message with post text and actions keyboard. 67 | """ 68 | self.answer_callback_query(call.id, text=call.data) 69 | keyboard = self.stackbot.user.post.get_actions_keyboard() 70 | self.stackbot.user.edit_message(call.message.message_id, reply_markup=keyboard) 71 | 72 | @bot.callback_query_handler(func=lambda call: call.data in [inline_keys.answer, inline_keys.comment]) 73 | def answer_comment_callback(call): 74 | """ 75 | Answer/Comment inline key callback. 76 | 77 | 1. Update user state. 78 | 2. Store replied to post_id in user tracker for storing the answer/comment when user is done. 79 | When user sends a reply to a post, there will be replied_to_post_id key stored in the user tracker 80 | to store the post_id of the post that the user replied to in the answer/comment or any other reply type. 81 | 3. Send start typing message. 82 | """ 83 | self.answer_callback_query(call.id, text=call.data) 84 | current_post_type = post_types.COMMENT if call.data == inline_keys.comment else post_types.ANSWER 85 | 86 | self.stackbot.user.update_state(states.ANSWER_QUESTION if call.data == inline_keys.answer else states.COMMENT_POST) 87 | self.stackbot.user.track(replied_to_post_id=self.stackbot.user.post.post_id) 88 | 89 | self.stackbot.user.send_message( 90 | constants.POST_START_MESSAGE.format( 91 | first_name=self.stackbot.user.first_name, 92 | post_type=current_post_type 93 | ), 94 | reply_markup=keyboards.send_post, 95 | ) 96 | 97 | @bot.callback_query_handler(func=lambda call: call.data == inline_keys.back) 98 | def back_callback(call): 99 | """ 100 | Back inline key callback. 101 | 102 | 1. Check if back is called on a post or on settings. 103 | - For a post: Edit message with post keyboard. 104 | - For settings: Edit message with settings keyboard. 105 | """ 106 | self.answer_callback_query(call.id, text=call.data) 107 | 108 | # main menu keyboard 109 | if self.stackbot.user.post.post_id is not None: 110 | # back is called on a post (question, answer or comment 111 | self.stackbot.user.edit_message(call.message.message_id, reply_markup=self.stackbot.user.post.get_keyboard()) 112 | else: 113 | # back is called in settings 114 | self.stackbot.user.edit_message(call.message.message_id, reply_markup=self.stackbot.get_settings_keyboard()) 115 | 116 | @bot.callback_query_handler( 117 | func=lambda call: call.data in [ 118 | inline_keys.like, 119 | inline_keys.follow, inline_keys.unfollow, 120 | ] 121 | ) 122 | def toggle_callback(call): 123 | """ 124 | Toggle callback is used for actions that toggle between pull and push data, such as like, follow, ... 125 | 126 | 1. Process callback according to the toggle type. 127 | - Like: Push/Pull user chat_id from post likes. 128 | - Follow: Push/Pull user chat_id from post followers. 129 | - ... 130 | 2. Edit message with new keyboard that toggles based on pull/push. 131 | """ 132 | self.answer_callback_query(call.id, text=call.data) 133 | 134 | if call.data == inline_keys.like: 135 | self.stackbot.user.post.like() 136 | keyboard = self.stackbot.user.post.get_keyboard() 137 | 138 | elif call.data in [inline_keys.follow, inline_keys.unfollow]: 139 | self.stackbot.user.post.follow() 140 | keyboard = self.stackbot.user.post.get_actions_keyboard() 141 | 142 | # update main menu keyboard 143 | self.stackbot.user.edit_message(call.message.message_id, reply_markup=keyboard) 144 | 145 | @bot.callback_query_handler( 146 | func=lambda call: call.data in [inline_keys.open, inline_keys.close, inline_keys.delete, inline_keys.undelete] 147 | ) 148 | def toggle_post_field_values_callback(call): 149 | """ 150 | Open/Close Delete/Undelete or any other toggling between two values. 151 | Open means that the post is open for new answers, comments, ... 152 | 153 | 1. Open/Close Delete/Undelete post with post_id. 154 | 2. Edit message with new keyboard and text 155 | - New post text reflects the new open/close status. 156 | """ 157 | self.answer_callback_query(call.id, text=call.data) 158 | 159 | if call.data in [inline_keys.open, inline_keys.close]: 160 | field = 'status' 161 | values = [post_status.OPEN, post_status.CLOSED] 162 | elif call.data in [inline_keys.delete, inline_keys.undelete]: 163 | field = 'status' 164 | 165 | # toggle between deleted and current post status 166 | other_status = self.stackbot.user.post.post_status 167 | if other_status == post_status.DELETED: 168 | other_status = post_status.OPEN 169 | values = list({post_status.DELETED, other_status}) 170 | 171 | self.stackbot.user.post.switch_field_between_multiple_values(field=field, values=values) 172 | self.stackbot.user.edit_message( 173 | call.message.message_id, 174 | text=self.stackbot.user.post.get_text(), 175 | reply_markup=self.stackbot.user.post.get_actions_keyboard() 176 | ) 177 | 178 | @bot.callback_query_handler( 179 | func=lambda call: call.data in [inline_keys.bookmark, inline_keys.unbookmark] 180 | ) 181 | def toggle_user_field_values_callback(call): 182 | """ 183 | """ 184 | self.answer_callback_query(call.id, text=call.data) 185 | self.stackbot.user.toggle_user_field(field='bookmarks', field_value=self.stackbot.user.post.post_id) 186 | self.stackbot.user.edit_message( 187 | call.message.message_id, 188 | text=self.stackbot.user.post.get_text(), 189 | reply_markup=self.stackbot.user.post.get_actions_keyboard() 190 | ) 191 | 192 | @bot.callback_query_handler( 193 | func=lambda call: call.data in [inline_keys.accept, inline_keys.unaccept] 194 | ) 195 | def accept_answer(call): 196 | """ 197 | Accept/Unaccept answer callback. 198 | """ 199 | self.answer_callback_query(call.id, text=call.data) 200 | 201 | self.stackbot.user.post.accept_answer() 202 | self.stackbot.user.edit_message( 203 | call.message.message_id, 204 | text=self.stackbot.user.post.get_text(), 205 | reply_markup=self.stackbot.user.post.get_actions_keyboard() 206 | ) 207 | 208 | @bot.callback_query_handler(func=lambda call: call.data == inline_keys.change_identity) 209 | def change_identity_callback(call): 210 | """ 211 | Change identity inline key callback. 212 | 213 | 1. Update settings with change identity keys. 214 | - User can choose identity between: 215 | - Anonymous 216 | - Username 217 | - First name 218 | """ 219 | self.answer_callback_query(call.id, text=call.data) 220 | 221 | keyboard = create_keyboard( 222 | inline_keys.ananymous, inline_keys.first_name, inline_keys.username, 223 | is_inline=True 224 | ) 225 | self.stackbot.user.edit_message(call.message.message_id, reply_markup=keyboard) 226 | 227 | @bot.callback_query_handler( 228 | func=lambda call: call.data in [inline_keys.ananymous, inline_keys.first_name, inline_keys.username] 229 | ) 230 | def set_identity_callback(call): 231 | """ 232 | Set new user identity. 233 | 234 | 1. Update settings with new identity. 235 | 2. Edit message with new settings text and main keyboard. 236 | """ 237 | self.answer_callback_query(call.id, text=call.data) 238 | 239 | self.stackbot.user.update_settings(identity_type=call.data) 240 | self.stackbot.user.edit_message( 241 | call.message.message_id, 242 | text=self.get_settings_text(), reply_markup=self.get_settings_keyboard() 243 | ) 244 | 245 | @bot.callback_query_handler(func=lambda call: call.data == inline_keys.original_post) 246 | def original_post(call): 247 | """ 248 | Original post inline key callback. 249 | 250 | Get the original post from a reply. 251 | 252 | 1. Get the current post. 253 | 2. Get the original post from replied_to_post_id. 254 | 3. Edit message with original post keyboard and text. 255 | 4. Update callback data with original post_id. 256 | """ 257 | self.answer_callback_query(call.id, text=call.data) 258 | 259 | post = self.stackbot.user.post.as_dict() 260 | original_post_id = self.db.post.find_one({'_id': post['replied_to_post_id']})['_id'] 261 | 262 | original_post_info = self.db.callback_data.find_one( 263 | {'chat_id': call.message.chat.id, 'message_id': call.message.message_id, 'post_id': original_post_id} 264 | ) or {} 265 | 266 | is_gallery = original_post_info.get('is_gallery') 267 | gallery_filters = original_post_info.get('gallery_filters') 268 | 269 | self.stackbot.user.post = BasePost( 270 | db=self.stackbot.user.db, stackbot=self.stackbot, 271 | post_id=original_post_id, chat_id=self.stackbot.user.chat_id, 272 | gallery_filters=gallery_filters, is_gallery=is_gallery 273 | ) 274 | # Edit message with new gallery 275 | post_text, post_keyboard = self.stackbot.user.post.get_text_and_keyboard() 276 | self.stackbot.user.edit_message( 277 | call.message.message_id, 278 | text=post_text, 279 | reply_markup=post_keyboard 280 | ) 281 | 282 | @bot.callback_query_handler( 283 | func=lambda call: call.data in [inline_keys.show_comments, inline_keys.show_answers] 284 | ) 285 | def show_posts(call): 286 | """ 287 | Show comments and answers of a post. 288 | """ 289 | self.answer_callback_query(call.id, text=call.data) 290 | 291 | post = self.stackbot.user.post.as_dict() 292 | 293 | gallery_post_type = post_types.ANSWER if call.data == inline_keys.show_answers else post_types.COMMENT 294 | gallery_filters = {'replied_to_post_id': post['_id'], 'type': gallery_post_type, 'status': post_status.OPEN} 295 | posts = self.db.post.find(gallery_filters).sort('date', -1) 296 | 297 | num_posts = self.db.post.count_documents(gallery_filters) 298 | next_post = next(posts) 299 | 300 | is_gallery = True if num_posts > 1 else False 301 | self.edit_gallery(call, next_post['_id'], is_gallery, gallery_filters) 302 | 303 | @bot.callback_query_handler(func=lambda call: call.data in [inline_keys.next_post, inline_keys.prev_post]) 304 | def next_prev_callback(call): 305 | """ 306 | Next/Prev post inline key callback. 307 | """ 308 | self.answer_callback_query(call.id, text=call.data) 309 | 310 | post = self.stackbot.user.post.as_dict() 311 | operator = '$gt' if call.data == inline_keys.next_post else '$lt' 312 | asc_desc = 1 if call.data == inline_keys.next_post else -1 313 | 314 | # Get basic filters and gallery filters 315 | filters = {'date': {operator: post['date']}} 316 | gallery_filters = self.db.callback_data.find_one( 317 | {'chat_id': call.message.chat.id, 'message_id': call.message.message_id, 'post_id': ObjectId(post['_id'])} 318 | )['gallery_filters'] 319 | filters.update(gallery_filters) 320 | 321 | # Get relevant posts 322 | posts = self.db.post.find(filters).sort('date', asc_desc) 323 | 324 | try: 325 | next_post = next(posts) 326 | except StopIteration: 327 | self.answer_callback_query( 328 | call.id, 329 | constants.GALLERY_NO_POSTS_MESSAGE.format(post_type=gallery_filters.get('type', 'post')) 330 | ) 331 | return 332 | 333 | is_gallery = True 334 | self.edit_gallery(call, next_post['_id'], is_gallery, gallery_filters) 335 | 336 | @bot.callback_query_handler(func=lambda call: call.data in [inline_keys.first_page, inline_keys.last_page]) 337 | def gallery_first_last_page(call): 338 | """ 339 | First and last page of a gallery button. 340 | """ 341 | self.answer_callback_query(call.id, text=constants.GALLERY_NO_POSTS_MESSAGE.format(post_type='post')) 342 | 343 | @bot.callback_query_handler(func=lambda call: call.data in [inline_keys.show_more, inline_keys.show_less]) 344 | def show_more_less(call): 345 | """ 346 | Show more or less text for a long post. 347 | """ 348 | self.answer_callback_query(call.id, text=call.data) 349 | 350 | if call.data == inline_keys.show_more: 351 | truncate = False 352 | elif call.data == inline_keys.show_less: 353 | truncate = True 354 | 355 | # check if it's a preview or a full post 356 | preview = False 357 | if self.stackbot.user.state == states.ASK_QUESTION: 358 | preview = True 359 | 360 | # update text and keyboard 361 | text, keyboard = self.stackbot.user.post.get_text_and_keyboard(truncate=truncate, preview=preview) 362 | self.stackbot.user.edit_message(call.message.message_id, text=text, reply_markup=keyboard) 363 | 364 | @bot.callback_query_handler(func=lambda call: call.data in [inline_keys.export_gallery]) 365 | def export_gallery(call): 366 | """ 367 | Show more or less text for a long post. 368 | """ 369 | self.answer_callback_query(call.id, text=call.data) 370 | chat_id = self.stackbot.user.chat_id 371 | gallery_filters = self.get_call_info(call)['gallery_filters'] 372 | 373 | # Send html file to user 374 | file_content = self.export_gallery(gallery_filters=gallery_filters, format='html') 375 | with open(DATA_DIR / 'export' / f'{chat_id}.html', 'w') as f: 376 | f.write(file_content) 377 | 378 | with open(DATA_DIR / 'export' / f'{chat_id}.html', 'r') as f: 379 | self.stackbot.bot.send_document( 380 | self.stackbot.user.chat_id, f 381 | ) 382 | 383 | @bot.callback_query_handler(func=lambda call: call.data == inline_keys.attachments) 384 | def show_attachments(call): 385 | """ 386 | Show attached files. 387 | """ 388 | self.answer_callback_query(call.id, text=call.data) 389 | keyboard = self.stackbot.user.post.get_attachments_keyboard() 390 | self.stackbot.user.edit_message(call.message.message_id, reply_markup=keyboard) 391 | 392 | @bot.callback_query_handler(func=lambda call: re.match(r'[a-zA-Z0-9-]+', call.data)) 393 | def send_file(call): 394 | """ 395 | Send file callback. Callback data is file_unique_id. We use this to get file from telegram database. 396 | """ 397 | self.answer_callback_query(call.id, text=f'Sending file: {call.data}...') 398 | self.stackbot.send_file(call.message.chat.id, call.data, message_id=call.message.message_id) 399 | 400 | @bot.callback_query_handler(func=lambda call: True) 401 | def not_implemented_callback(call): 402 | """ 403 | Raises not implemented callback answer for buttons that are not working yet. 404 | """ 405 | self.answer_callback_query(call.id, text=f':cross_mark: {call.data} not implemented.') 406 | 407 | def answer_callback_query(self, call_id, text, emojize=True): 408 | """ 409 | Answer to a callback query. 410 | """ 411 | if emojize: 412 | text = emoji.emojize(text) 413 | self.stackbot.bot.answer_callback_query(call_id, text=text) 414 | 415 | def get_call_info(self, call): 416 | """ 417 | Get call info from call data. 418 | 419 | Every message with inline keyboard has information stored in database, particularly the post_id. 420 | 421 | We store the post_id in the database to use it later when user click on any inline button. 422 | For example, if user click on 'answer' button, we know which post_id to store answer for. 423 | This post_id is stored in the database as 'replied_to_post_id' field. 424 | 425 | We also store post_type in the database to use the right handler in user object (Question, Answer, Comment). 426 | """ 427 | post_id = self.stackbot.retrive_post_id_from_message_text(call.message.text) 428 | 429 | print(post_id) 430 | callback_data = self.db.callback_data.find_one( 431 | {'chat_id': call.message.chat.id, 'message_id': call.message.message_id, 'post_id': ObjectId(post_id)} 432 | ) 433 | 434 | print(list(self.db.callback_data.find( 435 | {'chat_id': call.message.chat.id, 'message_id': call.message.message_id} 436 | ))) 437 | 438 | return callback_data or {} 439 | 440 | def get_gallery_filters(self, chat_id, message_id, post_id): 441 | result = self.db.callback_data.find_one({'chat_id': chat_id, 'message_id': message_id, 'post_id': post_id}) or {} 442 | return result.get('gallery_filters', {}) 443 | 444 | def edit_gallery(self, call, next_post_id, is_gallery=False, gallery_fiters=None): 445 | """ 446 | Edit gallery of posts to show next or previous post. Next post to show is the one 447 | with post_id=next_post_id. 448 | 449 | :param chat_id: Chat id to send gallery to. 450 | :param next_post_id: post_id of the next post to show. 451 | :param is_gallery: If True, send gallery of posts. If False, send single post. 452 | Next and previous buttions will be added to the message if is_gallery is True. 453 | """ 454 | self.stackbot.user.post = BasePost( 455 | db=self.stackbot.user.db, stackbot=self.stackbot, 456 | post_id=next_post_id, chat_id=self.stackbot.user.chat_id, 457 | is_gallery=is_gallery, gallery_filters=gallery_fiters 458 | ) 459 | 460 | # Edit message with new gallery 461 | post_text, post_keyboard = self.stackbot.user.post.get_text_and_keyboard() 462 | self.stackbot.user.edit_message( 463 | call.message.message_id, 464 | text=post_text, 465 | reply_markup=post_keyboard 466 | ) 467 | 468 | def export_gallery(self, gallery_filters, format='html'): 469 | """ 470 | Export gallery data. 471 | """ 472 | user_identity = self.stackbot.user.identity 473 | if format != 'html': 474 | return 475 | 476 | with open(DATA_DIR / 'posts.html') as f: 477 | template_html = f.read() 478 | 479 | BODY = '' 480 | num_posts = self.db.post.count_documents(gallery_filters) 481 | posts = self.db.post.find(gallery_filters).sort('date', -1) 482 | for post_ind, post in enumerate(posts): 483 | post_ind = num_posts - post_ind 484 | BODY += self.post_to_html(post['_id'], post_ind, user_identity) 485 | 486 | # Add replies 487 | replies_filter = {'replied_to_post_id': post['_id'], 'type': post_types.ANSWER} 488 | replies = self.db.post.find(replies_filter).sort('date', -1) 489 | num_replies = self.db.post.count_documents(replies_filter) 490 | 491 | if num_replies > 0: 492 | BODY += ( 493 | ''.replace(r'{{{collapse_id}}}', f'collapse_{post["_id"]}') 495 | ) 496 | BODY += '
'.replace(r'{{{collapse_id}}}', f'collapse_{post["_id"]}') 497 | for reply_ind, reply in enumerate(replies): 498 | reply_ind = num_replies - reply_ind 499 | BODY += self.post_to_html(reply['_id'], reply_ind, user_identity) 500 | 501 | if num_replies > 0: 502 | BODY += '
' 503 | 504 | return template_html.replace(r'{{{POSTS-CARDS}}}', BODY) 505 | 506 | def post_to_html(self, post_id, post_number, user_identity): 507 | post = BasePost( 508 | db=self.stackbot.user.db, stackbot=self.stackbot, 509 | post_id=post_id, chat_id=self.stackbot.user.chat_id 510 | ) 511 | post_html = post.export(format='html') 512 | post_html = post_html.replace(r'{{{user_identity}}}', str(user_identity)) 513 | post_html = post_html.replace(r'{{{post_number}}}', str(post_number)) 514 | 515 | return post_html 516 | --------------------------------------------------------------------------------