├── 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 |
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 |
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 |
--------------------------------------------------------------------------------