TEST = "{} Test".format(Emoji.ANCHOR)
BACK_TO_MENU = "{} Back to Menu".format(Emoji.LEFTWARDS_BLACK_ARROW)
EXIT = "🔙 Exit"
REFRESH = "🔄 Refresh"
ADD_BOT = "➕ Add new bot"
EDIT_BOT = "🛠 Edit Bot"
SEND_BOTLIST = "☑ Update BotList"
SEND_ACTIVITY_LOGS = "Activity Logs"
BACK = "{} Back".format(Emoji.BACK_WITH_LEFTWARDS_ARROW_ABOVE)
BACK_TO_CATEGORY = "{} to Category".format(Emoji.BACK_WITH_LEFTWARDS_ARROW_ABOVE)
APPROVE_BOTS = "Approve Bots"
SEND_CONFIG_FILES = "Runtime Files"
FIND_OFFLINE = "Find Offline Bots"
APPROVE_SUGGESTIONS = "Approve Suggestions"
PENDING_UPDATE = "Pending Bots"
SUGGESTION_PENDING_EMOJI = "👓"
CHANGE_SUGGESTION = "📝 Make Changes"
DONE = "🔚 Done"
SHARE = "Share"
CATEGORIES = "📚 Categories"
EXPLORE = "🔄 Explore"
NEW_BOTS = "🆕 New Bots"
SEARCH = "🔎 Search"
CONTRIBUTING = "📤 Contributing"
EXAMPLES = "📝 Examples"
HELP = "❔ Help"
ADMIN_MENU = "🛃 Admin Menu"
SWITCH_PRIVATE = "📖️ Continue in private"
FAVORITES = "💖 My Favorites"
ADD_FAVORITE = "➕ Add"
REMOVE_FAVORITE = "➖ Remove"
REMOVE_FAVORITE_VERBOSE = "➖ Remove from 💖 Favorites"
ADD_TO_FAVORITES = "Add to 💖 Favorites"
PIN = "📍 Pin"
def random_done_delete(): def slang_datetime(dt) -> str:
def find_bots_in_text(text: str, first=False):
def format_name(entity):
def validate_username(username: str):
def get_commands():
def get_channel():
def botlist_url_for_category(category):
def format_keyword(kw):
def reroute_private_chat(
    bot, update, quote, action, message, redirect_message=None, reply_markup=None
):
def make_sticker(filename, out_file, max_height=512, transparent=True):
def try_delete_after(
    job_queue: JobQueue,
    messages: Union[List[Union[Message, int]], Union[Message, int]],
    delay: Union[float, int],
):
    def delete_messages(*args, **kwargs): MAX_LINE_CHARACTERS = 31
    SMALLCAPS_CHARS = 'ᴀʙᴄᴅᴇғɢʜɪᴊᴋʟᴍɴᴏᴘǫʀsᴛᴜᴠᴡxʏᴢ'
    SPEC = '̶'
    EMOJI_NUMBERS = '0️⃣1️⃣2️⃣3️⃣4️⃣5️⃣6️⃣7️⃣8️⃣9️⃣'
def smallcaps(text):
def strikethrough(text: str):
def results_list(args, prefix=''):
def number_as_emoji(n):
def centered(text):
def success(text):
def love(text):
def failure(text):
def action_hint(text):
def none_action(text): DEV = config("DEV", default=False, cast=bool)
PORT = config("PORT", default=8443, cast=int)
BOT_TOKEN = config("BOT_TOKEN", cast=lambda v: str(v) if v else None, default=None) or (
    str(sys.argv[1]) if len(sys.argv) > 1 else None
)
LOG_DIR = config("LOG_DIR", default=os.path.dirname(os.path.abspath(__file__)))
BOT_THUMBNAIL_DIR = config(
    "BOT_THUMBNAIL_DIR",
    default="./bot-profile-pictures",
)
ADMINS = [
    62056065,  # JosXa
    918962,  # T3CHNO
    522605269,  # n9ghtLY
]
MODERATORS = [
    7679610,  # Fabian Pastor
    278941742,  # riccardo
    317434635,  # jfowl
    691609650,  # Lulzx
    200344026,  # the scientist
    234480941,  # the one and only twitface
] + ADMINS
DEVELOPER_ID = config("DEVELOPER_ID", default=62056065)
BOT_CONSIDERED_NEW = 1  # Revision difference
WORKER_COUNT = 5 if DEV else 40
TEST_BOT_NAME = "gottesgebot"
LIVE_BOT_NAME = "botlistbot"
SELF_BOT_NAME = TEST_BOT_NAME if DEV else LIVE_BOT_NAME
SELF_BOT_ID = "182355371" if DEV else "265482650"
TEST_GROUP_ID = -1001118582923  # Area 51
BOTLIST_NOTIFICATIONS_ID = -1001175567094 if DEV else -1001074366879
BOTLISTCHAT_ID = TEST_GROUP_ID if DEV else -1001067163791
BLSF_ID = TEST_GROUP_ID if DEV else -1001098339113
SELF_CHANNEL_USERNAME = "botlist_testchannel" if DEV else "botlist"
REGEX_BOT_IN_TEXT = r".*(@[a-zA-Z0-9_]{3,31}).*"
REGEX_BOT_ONLY = r"(@[a-zA-Z0-9_]{3,31})"
PAGE_SIZE_SUGGESTIONS_LIST = 5
PAGE_SIZE_BOT_APPROVAL = 5
MAX_SEARCH_RESULTS = 25
MAX_BOTS_PER_MESSAGE = 140
BOT_ACCEPTED_IDLE_TIME = 2  # minutes
SUGGESTION_LIMIT = 25
API_URL = "localhost" if DEV else "josxa.jumpingcrab.com"
API_PORT = 6060
RUN_BOTCHECKER = config("RUN_BOTCHECKER", True, cast=bool)
USE_USERBOT = RUN_BOTCHECKER
API_ID = config("API_ID", cast=lambda v: int(v) if v else None, default=None)
API_HASH = config("API_HASH", default=None)
USERBOT_SESSION = config("USERBOT_SESSION", default=None)
USERBOT_PHONE = config("USERBOT_PHONE", default=None)
PING_MESSAGES = ["/start", "/help"]
PING_INLINEQUERIES = ["", "abc", "/test"]
BOTCHECKER_CONCURRENT_COUNT = 20
BOTCHECKER_INTERVAL = 3600 * 3
DELETE_CONVERSATION_AFTER_PING = config(
    "DELETE_CONVERSATIONS_AFTER_PING", True, cast=bool
)
NOTIFY_NEW_PROFILE_PICTURE = not DEV
DOWNLOAD_PROFILE_PICTURES = config("DOWNLOAD_PROFILE_PICTURES", True, cast=bool)
DISABLE_BOT_INACTIVITY_DELTA = timedelta(days=15)
OFFLINE_DETERMINERS = [
    "under maintenance",
    "bot turned off",
    "bot parked",
    "offline for maintenance",
]
BOTBUILDER_DETERMINERS = [
    "use /off to pause your subscription",
    "use /stop to unsubscribe",
    "manybot",
    "chatfuelbot",
]
FORBIDDEN_KEYWORDS = config("FORBIDDEN_KEYWORDS", cast=Csv(), default=[])
SENTRY_URL = config("SENTRY_URL", default=None)
SENTRY_ENVIRONMENT = config("SENTRY_ENVIRONMENT", default=None)
DEBUG_LOG_FILE = "botlistbot.log"
BOTLIST_REQUESTS_CHANNEL = None
BOT_UNDER_TEST = TEST_BOT_NAME if DEV else LIVE_BOT_NAME
TEST_USERBOT_PHONE = config("TEST_USERBOT_PHONE", default=None)
TEST_USERBOT_SESSION = config("TEST_USERBOT_SESSION", default=None)
TEST_GROUP_ID = 1118582923
def is_sentry_enabled() -> bool: def stop_banned(update, user):
def track_groups(func):
    def wrapped(bot, update, *args, **kwargs):
def restricted(func=None, strict=False, silent=False):
    def wrapped(bot, update, *args, **kwargs):
def private_chat_only(func):
    def wrapped(bot, update, *args, **kwargs):
def timeit(method):
    def timed(*args, **kw):
def build_menu(buttons: List,
               n_cols,
               header_buttons: List = None,
               footer_buttons: List = None):
def cid_from_update(update):
def uid_from_update(update):
def encode_base64(query):
def callback_for_action(action, params=None):
def callback_data_from_update(update):
def is_group_message(update):
def is_private_message(update):
def original_reply_id(update):
def is_inline_message(update):
def message_text_from_update(update):
def mid_from_update(update):
def escape_markdown(text):
def callback_str_from_dict(d):
def wait(bot, update, t=1.5):
def order_dict_lexi(d):
def private_or_else_group_message(bot, chat_id, text):
def send_or_edit_md_message(bot, chat_id, text, to_edit=None, **kwargs):
def send_md_message(bot, chat_id, text: str, **kwargs):
def send_message_success(bot, chat_id, text: str, add_punctuation=True, reply_markup=None, **kwargs):
def send_message_failure(bot, chat_id, text: str, **kwargs):
def send_action_hint(bot, chat_id, text: str, **kwargs):
def success(text):
def failure(text):
def action_hint(text): HINTS = {
    "#inline": {
        "message": "*Consider using me in inline-mode* 😎\n`@BotListBot {query}`",
        "default": "Your search terms",
        "buttons": [{"text": "🔎 Try it out", "switch_inline_query": "{query}"}],
        "help": "Give a query that will be used for a `switch_to_inline`-button",
        "should_reply": False,
    },
    "#rules": {
        "message": messages.BOTLISTCHAT_RULES,
        "help": "Send the rules of @BotListChat",
        "should_reply": False,
    },
    "#manybot": {
        "message": "The @BotList moderators aim to set a *high standard for bots on the* @BotList. "
        "Bots built with bot builders like @Manybot or @Chatfuelbot certainly have "
        "their place, but usually *lack the quality* we impose for inclusion to the "
        "list. We prefer when bot makers actually take their time and effort to "
        "program bots without using a sandbox kindergarten tool as the ones mentioned "
        "above. Don't get us wrong, there are great tools out there built with these "
        "bot builders, and if you feel like the BotList is lacking some feature that "
        "this bot brings, feel free to submit it. But as a rule of thumb, Manybots are "
        "*generally too spammy, not very useful and not worth another look*. "
        "\nThank you 🙏🏻",
        "help": "Send our Manybot policy",
        "should_reply": True,
    },
    "#private": {
        "message": "Please don't spam the group with {query}, and go to a private "
        "chat with me instead. Thanks a lot, the other members will appreciate it 😊",
        "default": "searches or commands",
        "buttons": [
            {
                "text": captions.SWITCH_PRIVATE,
                "url": "https://t.me/{}".format(settings.SELF_BOT_NAME),
            }
        ],
        "help": "Tell a member to stop spamming and switch to a private chat",
        "should_reply": True,
    },
    "#userbot": {
        "message": "Refer to [this article](http://telegra.ph/How-a-"
        "Userbot-superacharges-your-Telegram-Bot-07-09) to learn more about *Userbots*.",
        "help": "@JosXa's article about Userbots",
        "should_reply": True,
    },
    "#devlist": {
        "message": "There exists a list of developers at @Devlist where you can surely find someone to build your "
        "bot.\nNote that most of them expect some amount of payment for their services, but if your idea "
        "is good enough, maybe you can convince them that they're going to make life easier for the "
        "whole Telegram community. Good luck with your project!",
        "help": "Where to find a bot developer?",
        "should_reply": True,
    },
}
BROADCAST_REPLACEMENTS = {"categories": "📚 ᴄᴀᴛɢᴏʀɪᴇs", "bots": "🤖 *bots*", "- ": "👉 "}
def append_restricted_delete_button(
    update, chat_data, reply_markup
) -> Tuple[Optional[ReplyMarkup], Callable[[Message], None]]:
    def append_callback(message):
def append_free_delete_button(update, reply_markup) -> Optional[ReplyMarkup]:
def delete_conversation(bot, update, chat_data):
def _delete_multiple_delayed(bot, chat_id, immediately=None, delayed=None):
def show_available_hints(bot, update):
def get_hint_data(text):
def hint_handler(bot, update, job_queue: JobQueue):
    def _send_hint(hint_text):
def notify_group_submission_accepted(bot, job, accepted_bot):
def text_message_logger(bot, update, logger): def rand_call_to_action():
def rand_thank_you_slang():
PROMOTION_MESSAGE = "*ᴊᴏɪɴ ᴛʜᴇ* @BotList 💙\n*sʜᴀʀᴇ* ʏᴏᴜʀ ʙᴏᴛs ɪɴ @BotListChat"
HELP_MESSAGE_ENGLISH = """I'm the bot in charge of maintaining the @BotList channel, the *most reliable and unbiased bot catalog* out there. I was built to simplify navigation and to automate the process of submitting, reviewing and publishing bots by the @BotListChat community.

▶️ *First steps:*
1️⃣ Start off by using the /category command and use the available buttons from there on.
2️⃣ Send individual @BotList categories to your friends via inline search (i.e. type `@botlistbot music` in any chat).
3️⃣ Add me to your groups and /subscribe to BotList updates.
4️⃣ Join the @BotListChat community and /contribute to the BotList: `#new @newbot🔎 - description`

You can send or forward any bot `@username` to me, and I will tell you if it exists in the @BotList.

ᴏɴᴇ sᴛᴇᴘ ᴄʟᴏsᴇʀ ᴛᴏ ᴡᴏʀʟᴅ ᴅᴏᴍɪɴᴀᴛɪᴏɴ 🤖"""
CONTRIBUTING = """You can use the following `#tags` with a bot `@username` to contribute to the BotList:

• #new — Submit a fresh bot. Use 🔎 if it supports inline queries and flag emojis to denote the language. Everything after the `-` character can be your description of the bot.
• #offline — Mark a bot as offline.
• #spam — Tell us that a bot spams too much.

There are also the corresponding /new, /offline and /spam commands.
The moderators will approve your submission as soon as possible.

*Next step*: Have a look at the /examples!
"""
EXAMPLES = """*Examples for contributing to the BotList:*

• "Wow! I found this nice #new bot: @coolbot 🔎🇮🇹 - Cools your drinks in the fridge."
• /new @coolbot 🔎🇮🇹 - Cools your drinks in the fridge.

• "Oh no... guys?! @unresponsive\_bot is #offline 😞"
• /offline @unresponsive\_bot

• "Aaaargh, @spambot's #spam is too crazy!"
• /spam @spambot
"""
REJECTION_WITH_REASON = """Sorry, but your bot submission {} was rejected.

Reason: {reason}

Please adhere to the quality standards we impose for inclusion to the @BotList.
For further information, please ask in the @BotListChat."""
REJECTION_PRIVATE_MESSAGE = """Sorry, but your bot submission {} was rejected.

It does not suffice the standards we impose for inclusion in the @BotList for one of the following reasons:

▫️A better bot with the same functionality is already in the @BotList.
▫️The user interface is bad in terms of usability and/or simplicity.
▫The bot is still in an early development stage
▫️Contains ads or exclusively adult content
▫️English language not supported per default (exceptions are possible)
▫NO MANYBOTS!!! 👺

For further information, please ask in the @BotListChat."""
ACCEPTANCE_PRIVATE_MESSAGE = """Congratulations, your bot submission {} has been accepted for the @BotList. You will be able to see it shortly by using the /category command, and it is going to be in the @BotList in the next two weeks.\n\nCategory: {}"""
BOTLIST_UPDATE_NOTIFICATION = """⚠️@BotList *update!*
There are {n_bots} new bots:

{new_bots}

Share your bots in @BotListChat"""
SEARCH_MESSAGE = mdformat.action_hint("What would you like to search for?")
SEARCH_RESULTS = """I found *{num_results} bot{plural}* in the @BotList for "{query}":\n
{bots}
"""
KEYWORD_BEST_PRACTICES = """The following rules for keywords apply:
▫️Keep the keywords as short as possible
▫️Use singular where applicable (#̶v̶i̶d̶e̶o̶s̶ video)
▫️Try to tag every supported platform (e.g. #vimeo, #youtube, #twitch, ...)
▫Try to tag every supported action (#search, #upload, #download, ...)
▫Try to tag every supported format (#mp3, #webm, #mp4, ...)
▫Keep it specific (only tag #share if the bot has a dedicated 'Share' button)
▫Tag bots made with _bot creators_ (e.g. #manybot, #chatfuelbot)
▫Use #related if the bot is not standalone, but needs another application to work properly, e.g. an Android App
▫Always think in the perspective of a user in need of a bot. What query might he be putting in the search field?
"""
NEW_BOTS_INLINEQUERY = "New Bots"
SELECT_CATEGORY = "Please select a category"
SHOW_IN_CATEGORY = "Show category"
REROUTE_PRIVATE_CHAT = mdformat.action_hint("Please use this command in a private chat or make use of inlinequeries.")
BOTLISTCHAT_RULES = """*Here are the rules for @BotListChat:*\n\nShare your bots, comment, test and have fun😜👍

Rules: Speak in English, Don't spam/advertise channels or groups that aren't bot related, respect other members, use common sense. 🤖

⭐️⭐️⭐️⭐️⭐️
[Give @BotList a rating](https://goo.gl/rtSs5B)"""
BAN_MESSAGE = mdformat.action_hint("Please send me the username to ban and remove all bot submissions")
UNBAN_MESSAGE = mdformat.action_hint("Please send me the username of the user to revoke ban state for")
FAVORITES_HEADLINE = "*{}* 🔽\n_┌ from_ @BotList".format(captions.FAVORITES)
ADD_FAVORITE = mdformat.action_hint("Please send me the @username of a bot to add to your favorites")
BOTPROPERTY_STARTSWITH = "Please send me a "
SET_BOTPROPERTY = "Please send me a {} for {} ― `{}` to clear"
SELECT_BOT_TO_ACCEPT = f"""Please select a bot you want to accept for the BotList.
👍 Accept with notification
👎 Reject with notification
🗑 Drop without notice
{emojis.RECOMMEND_MODERATOR} Recommend another moderator for this submission
""" class InlineCallbackButton(InlineKeyboardButton):
    def __init__(self, text, callback_action=None, params=None, **kwargs):
        if not isinstance(callback_action, int):
            raise AttributeError('Parameter callback_action must be an integer. '
                                 'Define constants to reference the actions.')
        if params and ACTION_DICT_KEY in params:
            raise AttributeError('Key "{}" can not be used in `params` dict.'.format(ACTION_DICT_KEY))

        # format callback action and parameters to uglified json-string
        json_callback = self._callback_for_action(callback_action, params)

        super(InlineCallbackButton, self).__init__(
            text,
            callback_data=json_callback,
        )

        self.text = text
        self.callback_data = json_callback

    @staticmethod
    def _callback_for_action(action, params=None):
        """
        Generate the merged representation of a callback action and its parameters
        :param action: The action to be used
        :param params: Arbitrary Parameters
        :return: A dict containing callback data
        """
        callback_data = {'a': action}
        if params:
            for key, value in params.items():
                callback_data[key] = value

        # dump dict as uglified json
        dumped = json.dumps(callback_data, separators=(',', ':'))
        if len(dumped) > 64:
            raise ValueError('Your callback_data is getting too long. Telegram allows a maximum of 64 bytes, '
                             'try to pick shorter dictionary keys.')
        return dumped class Bot(BaseModel):
    class DisabledReason(IntEnum):
        # In prioritized order, cannot go from banned to offline
        banned = 10
        offline = 20

        @classmethod
        def to_str(cls, value):
            if value == cls.banned:
                return "is banned"
            elif value == cls.offline:
                return "has been offline for too long"

    id = PrimaryKeyField()
    revision = IntegerField()
    category = ForeignKeyField(Category, null=True)
    name = CharField(null=True)
    username = CharField(unique=True)
    description = TextField(null=True)
    date_added = DateField()
    country = ForeignKeyField(Country, null=True)
    inlinequeries = BooleanField(default=False)
    official = BooleanField(default=False)
    extra = CharField(null=True)
    spam = BooleanField(default=False)
    bot_info_version = CharField(null=True)
    restriction_reason = CharField(null=True)

    last_ping = DateTimeField(null=True)
    last_response = DateTimeField(null=True)
    disabled = BooleanField(default=False)
    disabled_reason = EnumField(DisabledReason, null=True)

    userbot = BooleanField(default=False)
    botbuilder = BooleanField(default=False)
    chat_id = IntegerField(null=True)

    approved = BooleanField(default=True)
    submitted_by = ForeignKeyField(User, null=True, related_name='submitted_by')
    approved_by = ForeignKeyField(User, null=True, related_name='approved_by')

    @hybrid_property
    def offline(self) -> bool:
        if not self.last_ping:
            return False
        return self.last_response != self.last_ping

    @hybrid_property
    def online(self) -> bool:
        return not self.offline

    @property
    def offline_for(self) -> timedelta:
        if not self.last_response:
            return timedelta(days=365 * 2)
        return self.last_ping - self.last_response if self.offline else None

    @staticmethod
    def select_approved():
        return Bot.select().where(
            Bot.approved == True,
            Bot.revision <= Revision.get_instance().nr,
            Bot.disabled == False
        )

    @staticmethod
    def select_unapproved():
        return Bot.select().where(Bot.approved == False, Bot.disabled == False)

    @staticmethod
    def select_pending_update():
        return Bot.select().where(
            Bot.approved == True,
            Bot.revision == Revision.get_instance().next,
            Bot.disabled == False
        )

    @property
    def serialize(self):
        return {
            'id': self.id,
            'category_id': self.category.id,
            # 'name': self.name,
            'username': self.username,
            'description': self.description,
            'date_added': self.date_added,
            'inlinequeries': self.inlinequeries,
            'official': self.official,
            'extra_text': self.extra,
            'offline': self.offline,
            'spam': self.spam,
            'botlist_url': helpers.botlist_url_for_category(self.category),
        }

    def disable(self, reason: DisabledReason):
        if self.disabled:
            if self.disabled_reason == reason:
                return False  # if value unchanged
            if reason.value > self.disabled_reason:
                raise ValueError("Invalid reason, cannot go from {} to {}.".format(
                    self.disabled_reason.name,
                    reason.name
                ))

        self.disabled = True
        self.disabled_reason = reason
        return True  # if value changed

    def enable(self):
        if not self.disabled:
            return False  # if value unchanged
        self.disabled = False
        self.disabled_reason = None
        return True  # if value changed

    @hybrid_property
    def is_new(self):
        # today = datetime.date.today()
        # delta = datetime.timedelta(days=settings.BOT_CONSIDERED_NEW)
        # result = today - self.date_added <= delta
        # return result
        return self.revision >= Revision.get_instance().nr - settings.BOT_CONSIDERED_NEW + 1

    def __str__(self):
        return util.escape_markdown(self.str_no_md).encode('utf-8').decode('utf-8')

    @property
    def detail_text(self):
        from botlistbot.models import Keyword
        keywords = Keyword.select().where(Keyword.entity == self)
        txt = '{}'.format(self.__str__())
        txt += '\n_{}_'.format(util.escape_markdown(self.name)) if self.name else ''
        txt += '\n\n{}'.format(self.description) if self.description else ''
        txt += util.escape_markdown(
            '\n\nKeywords: {}'.format(
                ', '.join([str(k) for k in keywords])
            ) if keywords else ''
        )
        return txt

    @property
    def str_no_md(self):
        return ('💤 ' if self.offline else '') + \
               ('🚮 ' if self.spam else '') + \
               ('🆕 ' if self.is_new else '') + \
               self.username + \
               (' ' if any([self.inlinequeries, self.official, self.country]) else '') + \
               ('🔎' if self.inlinequeries else '') + \
               ('🔹' if self.official else '') + \
               (self.country.emoji if self.country else '') + \
               (' ' + self.extra if self.extra else '')

    @staticmethod
    def by_username(username: str, include_disabled=False):
        if include_disabled:
            result = Bot.select().where(
                fn.lower(Bot.username) == username.lower()
            )
        else:
            result = Bot.select().where(
                fn.lower(Bot.username) == username.lower(),
                Bot.disabled == False
            )
        if len(result) > 0:
            return result[0]
        else:
            raise Bot.DoesNotExist()

    @staticmethod
    def explorable_bots():
        results = Bot.select().where(
            ~(Bot.description.is_null()),
            (Bot.approved == True),
            (Bot.revision <= Revision.get_instance().nr),
            (Bot.offline == False),
            (Bot.disabled == False)
        )
        return list(results)

    @staticmethod
    def many_by_usernames(names: List):
        results = Bot.select().where(
            (fn.lower(Bot.username) << [n.lower() for n in names]) &
            (Bot.revision <= Revision.get_instance().nr) &
            (Bot.approved == True) &
            (Bot.disabled == False)
        )
        if results:
            return results
        raise Bot.DoesNotExist

    @staticmethod
    def of_category_without_new(category):
        return Bot.select().where(
            (Bot.category == category),
            (Bot.approved == True),
            (Bot.revision <= Revision.get_instance().nr),
            (Bot.disabled == False)
        ).order_by(fn.Lower(Bot.username))

    @staticmethod
    def select_official_bots():
        return Bot.select().where(Bot.approved == True, Bot.official == True,
                                  Bot.disabled == False)

    @staticmethod
    def select_new_bots():
        return Bot.select().where(
            Bot.is_new == True,
            Bot.revision < Revision.get_instance().next,
            Bot.approved == True,
            Bot.disabled == False
        )

    @staticmethod
    def get_official_bots_markdown():
        return '\n'.join(['     {}'.format(str(b)) for b in Bot.select_official_bots()])

    @staticmethod
    def get_new_bots_markdown():
        return '\n'.join(['     {}'.format(str(b)) for b in Bot.select_new_bots()])

    @staticmethod
    def get_pending_update_bots_markdown():
        return '\n'.join(['     {}'.format(str(b)) for b in Bot.select_pending_update()])

    @property
    def keywords(self):
        from botlistbot.models.keywordmodel import Keyword
        return Keyword.select().where(Keyword.entity == self)

    @property
    def thumbnail_file(self):
        path = os.path.join(settings.BOT_THUMBNAIL_DIR, self.username[1:].lower() + '.jpg')
        return path class Category(BaseModel):
    id = PrimaryKeyField()
    order = IntegerField(unique=True)
    emojis = CharField()
    name = CharField(unique=True)
    extra = CharField(null=True)
    current_message_id = IntegerField(null=True)

    @staticmethod
    def select_all():
        return Category.select().order_by(Category.order)

    @property
    def serialize(self):
        return {
            'id': self.id,
            'name': self.name,
            'emojis': self.emojis,
            'extra_text': self.extra,
            'botlist_url': helpers.botlist_url_for_category(self)
        }

    def __str__(self):
        return '•' + emoji.emojize(self.emojis, use_aliases=True) + self.name + \
               (' - ' + self.extra if self.extra else '') class Keyword(BaseModel):
    id = PrimaryKeyField()
    name = CharField()
    entity = ForeignKeyField(Bot)

    class Meta:
        indexes = (
            (('entity', 'name'), True),  # Note the trailing comma!
        )

    def __str__(self):
        return '#' + self.name

    @classmethod
    def get_distinct_names(cls, exclude_from_bot: Bot, exclude_suggestions=True) -> Set[str]:
        exclude_kw = {x.name for x in exclude_from_bot.keywords}
        return {x.name for x in
                cls.select(cls.name).distinct().where(Keyword.name.not_in(exclude_kw))} class User(BaseModel):
    id = PrimaryKeyField()
    chat_id = IntegerField()
    username = CharField(null=True)
    first_name = CharField(null=True)
    last_name = CharField(null=True)
    photo = CharField(null=True)
    banned = BooleanField(default=False)
    favorites_layout = CharField(choices=Layouts.choices(), default=Layouts.default())


    @staticmethod
    def from_telegram_object(user: TelegramUser):
        try:
            u = User.get(User.chat_id == user.id)
            u.first_name = user.first_name
            u.last_name = user.last_name
            u.username = user.username
        except User.DoesNotExist:
            u = User(chat_id=user.id, username=user.username, first_name=user.first_name, last_name=user.last_name)

        u.save()
        return u

    @staticmethod
    def from_update(update):
        return User.from_telegram_object(update.effective_user)

    @property
    def has_favorites(self):
        from botlistbot.models import Favorite
        return Favorite.select().where(Favorite.user == self).count() > 0

    @property
    def num_contributions(self):
        from botlistbot.models import Bot
        return Bot.select().where(Bot.submitted_by == self).count()

    @property
    def contributions_ordinal(self):
        p = inflect.engine()
        return p.ordinal(self.num_contributions)

    def __str__(self):
        text = '👤 '  # emoji
        full_name = ' '.join([
            self.first_name if self.first_name else '',
            self.last_name if self.last_name else ''
        ])
        if self.username:
            text += '[{}](https://t.me/{})'.format(full_name, self.username)
        else:
            text += full_name
        return text.encode('utf-8').decode('utf-8')

    @property
    def markdown_short(self):
        displayname = ''
        if self.first_name:
            displayname = util.escape_markdown(self.first_name.encode('utf-8').decode('utf-8'))
        if self.username:
            text = '[👤 {}](https://t.me/{})'.format(displayname, util.escape_markdown(self.username))
        else:
            text = displayname
        return text.encode('utf-8').decode('utf-8')

    @property
    def plaintext(self):
        text = '👤 '  # emoji
        if self.first_name:
            text += self.first_name
        if self.last_name:
            text += " " + self.last_name
        return text.encode('utf-8').decode('utf-8')


    @staticmethod
    def by_username(username: str):
        if username[0] == '@':
            username = username[1:]
        result = User.select().where(
            (fn.lower(User.username) == username.lower())
        )
        if len(result) > 0:
            return result[0]
        else:
            raise User.DoesNotExist()

    @classmethod
    def botlist_user_instance(cls):
        if not hasattr(cls, '_botlist_user'):
            bl_user, created = User.get_or_create(id=100000, defaults={
                'chat_id' : settings.SELF_BOT_ID,
                'username' : 'BotListBot',
                'first_name' : 'BotListBot',
            })
            cls._botlist_user = bl_user
        return cls._botlist_user class Favorite(BaseModel):
    id = PrimaryKeyField()
    user = ForeignKeyField(User)
    bot = ForeignKeyField(Bot, null=True)
    custom_bot = CharField(null=True)
    date_added = DateField()

    CUSTOM_CATEGORY = Category(id=1000, order=1000, emojis='👤', name='Others')

    @staticmethod
    def add(user, item: Bot):
        """
        :return: Tuple of (Favorite, created: Boolean)
        """
        try:
            fav = Favorite.get(Favorite.bot == item, Favorite.user == user)
            return fav, False
        except Favorite.DoesNotExist:
            fav = Favorite(user=user, bot=item, date_added=datetime.date.today())
            fav.save()
            return fav, True

    @staticmethod
    def select_all(user):
        user_favs = list(Favorite.select().where(Favorite.user == user))
        for n, f in enumerate(user_favs):
            try:
                if not fn.exists(f.bot):
                    bot = Bot(category=Favorite.CUSTOM_CATEGORY, username=f.custom_bot, approved=True,
                              date_added=datetime.date.today())
                    f.bot = bot
                    user_favs[n] = f
                if not fn.exists(f.bot.category):
                    f.bot.category = Favorite.CUSTOM_CATEGORY
            except (Bot.DoesNotExist, AttributeError):
                f.delete_instance()
        return user_favs

    @staticmethod
    def get_oldest(user):
        return Favorite.select().where(Favorite.user == user).order_by(Favorite.date_added).first()

    @staticmethod
    def search_by_bot(user, bot):
        fav = Favorite.select().where(
            Favorite.user == user,
            (Favorite.bot == bot or (Favorite.custom_bot != None and Favorite.custom_bot == bot))
        ).first()
        return fav class Statistic(BaseModel):
    IMPORTANT = 30
    WARN = logging.WARNING
    INFO = logging.INFO
    ANALYSIS = logging.DEBUG
    DETAILED = 9

    ACTIONS = {
        'search': 'searched for',
        'button-press': 'clicked on',
        'command': 'executed command',
        'chosen-inlinequery-result': 'sent an inline query',
        'inlinequery': 'did an inline query: ',
        'menu': 'entered menu',
        'error': 'received error',
        'share': 'shared',
        'accept': 'accepted',
        'request': 'requested',
        'reject': 'rejected',
        'ban': 'banned',
        'unban': 'unbanned',
        'apply': 'applied changes to',
        'remove': 'removed',
        'send': 'sent',
        'disable': 'deleted',
        'suggestion': 'made a suggestion to',
        'easteregg': 'tried out the easteregg',
        'explore': 'explored',
        'view-details': 'viewed details of',
        'add-favorite': 'added a new favorite:',
        'view-favorites': 'viewed their favorites',
    }
    user = ForeignKeyField(User)
    date = DateTimeField()
    action = CharField()
    entity = CharField(null=True)
    level = SmallIntegerField(default=logging.INFO)

    EMOJIS = {
        IMPORTANT: '🔴',
        WARN: '🔺',
        INFO: '⚪️',
        ANALYSIS: '▫️',
        DETAILED: '▪️'
    }

    @staticmethod
    def collect_recent(limit=400, min_level=logging.INFO):
        return Statistic.select().where(Statistic.level >= min_level).limit(limit)

    @staticmethod
    def collect_all_as_file():
        pass  # TODO

    def __get_action_text(self):
        text = self.ACTIONS.get(self.action)
        return text if text else self.action

    def __format_entity(self):
        if self.action == 'command':
            return '/' + self.entity
        if self.action == 'menu':
            return util.escape_markdown(self.entity.title())
        return util.escape_markdown(self.entity)


    @classmethod
    def of(cls, issuer, action: str, entity: str = None, level=logging.INFO):
        # if action not in Statistic.ACTIONS.keys():
        #     raise ValueError('"{}" is not a valid action. Refer to Statistic.ACTIONS for available keys.')

        if isinstance(issuer, User):
            user = issuer
        elif isinstance(issuer, Update):
            user = User.from_update(issuer)
        else:
            raise AttributeError("The issuer argument needs to be an object of type User or Update.")
        obj = cls(user=user, date=datetime.datetime.now(), action=action, entity=entity, level=level)
        obj.save()
        return obj

    def md_str(self, no_date=False):
        return '{} {}{} _{}_{}.'.format(
            self.EMOJIS[self.level],
            '' if no_date else '{}: '.format(helpers.slang_datetime(self.date)),
            self.user.markdown_short,
            self.__get_action_text(),
            ' ' + self.__format_entity() if self.entity else ''
        ) def track_activity(action: str, entity: str = None, level: int = logging.INFO):
    def decorator(func):
        def wrapped(bot, update, *args, **kwargs):
            result = func(bot, update, *args, **kwargs)
            Statistic.of(update, action, entity, level)
            return result

        return wrapped

    return decorator def track_groups(func):
    from botlistbot.models.group import Group
    """
    Decorator that stores all groups that the bot has been added to
    """

    @wraps(func)
    def wrapped(bot, update, *args, **kwargs):
        try:
            if update.effective_chat.type == 'group':
                Group.from_telegram_object(update.effective_chat)
        except (NameError, AttributeError):
            try:
                if update.message.new_chat_members[0].id == settings.SELF_BOT_ID:
                    Group.from_telegram_object(update.callback_query.message.chat)
            except (NameError, AttributeError):
                logging.error("No chat_id available in update for track_groups.")
        return func(bot, update, *args, **kwargs)

    return wrapped
import emoji
import logging
import random
import re
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ConversationHandler
from botlistbot import captions
from botlistbot import helpers
from botlistbot import mdformat
from botlistbot import settings
from botlistbot import util
from botlistbot.components import botlistchat
from botlistbot.const import CallbackActions, CallbackStates
from botlistbot.dialog import messages
from botlistbot.lib import InlineCallbackButton
from botlistbot.models import Bot, Category, Favorite, Keyword, Statistic, User, track_activity
from botlistbot.util import track_groups
from typing import *
        )
    reply_markup = InlineKeyboardMarkup(buttons)
    reply_markup, callback = botlistchat.append_restricted_delete_button(
        update, chat_data, reply_markup
    )
    msg = bot.formatter.send_or_edit(
        chat_id,
        _new_bots_text(),
        to_edit=util.mid_from_update(update),
        reply_markup=reply_markup,
        reply_to_message_id=util.mid_from_update(update),
    )
    callback(msg)
    return ConversationHandler.END


def send_category(bot, update, chat_data, category):
    uid = util.uid_from_update(update)
    cid = update.effective_chat.id
    bots = Bot.of_category_without_new(category)[: settings.MAX_BOTS_PER_MESSAGE]
    bots_with_description = [b for b in bots if b.description is not None]
    detailed_buttons_enabled = len(
        bots_with_description
    ) > 0 and util.is_private_message(update)

    callback = CallbackActions.SEND_BOT_DETAILS

    if detailed_buttons_enabled:
        buttons = [
            InlineKeyboardButton(
                x.username,
                callback_data=util.callback_for_action(callback, {"id": x.id}),
            )
            for x in bots_with_description
        ]
    else:
        buttons = []
    menu = util.build_menu(buttons, 2)
    menu.insert(
        0,
        [
            InlineKeyboardButton(
                captions.BACK,
                callback_data=util.callback_for_action(CallbackActions.SELECT_CATEGORY),
            ),
            InlineKeyboardButton(
                "Show in BotList",
                url="http://t.me/botlist/{}".format(category.current_message_id),
            ),
            InlineKeyboardButton("Share", switch_inline_query=category.name),
        ],
    )
    txt = "There are *{}* bots in the category *{}*:\n\n".format(
        len(bots), str(category)
    )

    if uid in settings.MODERATORS and util.is_private_message(update):
        # append admin edit buttons
        txt += "\n".join(["{} — /edit{} 🛃".format(b, b.id) for b in bots])
    else:
        txt += "\n".join([str(b) for b in bots])

    if detailed_buttons_enabled:
        txt += "\n\n" + util.action_hint(
            "Press a button below to get a detailed description."
        )

    reply_markup = InlineKeyboardMarkup(menu)
    reply_markup, callback = botlistchat.append_restricted_delete_button(
        update, chat_data, reply_markup
    )
    msg = bot.formatter.send_or_edit(
        cid, txt, to_edit=util.mid_from_update(update), reply_markup=reply_markup
    )
    callback(msg)
    Statistic.of(
        update, "menu", "of category {}".format(str(category)), Statistic.ANALYSIS
    )


def send_bot_details(
    bot, update, chat_data, item=None, header_msg: Optional[str] = None
):
    is_group = util.is_group_message(update)
    cid = update.effective_chat.id
    user = User.from_update(update)

    if item is None:
        if is_group:
            return

        try:
            text = update.message.text
            bot_in_text = re.findall(settings.REGEX_BOT_IN_TEXT, text)[0]
            item = Bot.by_username(bot_in_text, include_disabled=True)

        except Bot.DoesNotExist:
            update.message.reply_text(
                util.failure(
                    "This bot is not in the @BotList. If you think this is a "
                    "mistake, see the /examples for /contributing."
                )
            )
            return

    header_buttons = []
    buttons = []
    if item.disabled:
        txt = "{} {} and thus removed from the @BotList.".format(
            item, Bot.DisabledReason.to_str(item.disabled_reason)
        )
    elif item.approved:
        # bot is already in the botlist => show information
        txt = f"{header_msg}\n\n{item.detail_text}" if header_msg else item.detail_text

        if (
            item.description is None
            and not Keyword.select().where(Keyword.entity == item).exists()
        ):
            txt += " is in the @BotList, but has no description or keywords yet."

        btn = InlineCallbackButton(