История об изучении использовании python для написания бота библиотеки для ботов в telegram.

Код, описанием и историей которого является эта статья доступен по ссылке.

Предыстория

Как-то в разговоре со знакомым, на тему проф ориентации и общей перспективности в смысле бабла, прозвучал вопрос: «Пишешь ли ты на питоне?»

Я ответил в том смысле, что на любых животных писать неудобно. Они либо кусаются, либо убегают, а часто одновременно и от этого адски страдает каллиграфия. А на круглых, к которым относится питон, как самоходный шланг, и пытаться-то глупо.

Оказалось, что речь шла не о хладнокровной, а об электронной сущности, в смысле языка программирования: «Знаешь о таком?»

Разумеется, я знал и, за следующие 15 секунд, я вывалил на собеседника все мои познания о том, что это очередной Н-плюс-первый птичий язык, на котором пишет довольно много народу, правда не особо понятно что именно и зачем, однако в моей жизни его необходимость в нем равна приблизительно нулю. «Хот нет, вру. Пацан для колледжа делал какую-то домашку именно на питоне и просил помочь там с чем-то. Писал какого-то «бота» для телеграмма.»

В ответ я узнал, что именно питон и именно телеграмм - это крайне модно, молодёжно, современно и вообще: бабы кончают, дети смеются.

- Ты хотя бы пробовал?

- Ну, это же телеграмм ставить надо… Это же какой-то чат в телефоне, правильно?

- Ясно с тобой все.

Рюмка чая, вместе с нашим с ним разговором, подошли к концу, но идея запала. Тем более, что у меня как раз была неделя, в которую я был практически абсолютно свободен аж до следующего понедельника. «Аж кончают… да ну нафик, брехня. Но глянуть можно… Вай бы и не нот, так сказать?».

Начало

Чайник вскипел, чай налит, комп гудит: «Ну-с, и чего там оно этот питон?… Эй Влад, та на чем писал на этой фигне? Бесплатная? Ага качается. А писал чего? Бота? Ясно».

О самом питоне мне сказать особо нечего. В процессе поиска я наткнулся на фразу: «На python написано самое большое количество кода в мире», с которой теперь, спустя пару недель я бы поспорил.

Про "накрутку" для статистики

Если выкинуть из текста программ на питоне начальные пробелы и клепанный "self.", то рейтинг количества кода можно смело делить вдвое.

Ну да пусть она остается на совести создателя статьи, к делу это не относится.

В этой статье я не буду описывать ни как скачать PyCharm или десктопную версию телеграмма (зарегистрироваться в которой оказалось той еще проблемой, кстати), ни как создать своего бота и получить API Key. Этой 30-секундной информацией интернет просто завален. Будем подразумевать, что потенциальному читателю это либо вообще до фени, либо он способен эти операции произвести самостоятельно.

Переходим к сути

А суть в том, что после кое-какой настройки среда для написания кода готова, найдена и скачана библиотека для написания ботов в телеграмм, бегло просмотрены ее примеры, пастнуты в редактор кода и вот первый бот уже умеет говорить: «Я увидел: ‘ваш текст’».

В качестве библиотеки я выбрал «aiogram». Просто потому, что первые две попавшиеся были какие-то вообще крайне печально документированные. Ну да «опенсорс–же—блин», так что удивлен я не был, просто искал что-то следующее, где хотя бы примеры были на часть функционала. О своем выборе библиотеки не жалею. Она успешно работает, все свои функции выполняет без нареканий. Другое дело, что 60-80% ее функционала мне не пригодилось, но об этом будет чуть позже.

В принципе, можно использовать вообще любую библиотеку, реализующую АПИ телеграмма. Все они идентичны до синтаксиса, отличаясь нюансами наполнения.

Кратко о функционировании ботов для телеграмм

Бот – это http клиент, который посылает серверу телеграмм команды, получает в ответ результат их выполнения и, параллельно, события, которые произошли в области интереса бота с момента последнего запроса. Сам бот может быть запущен где угодно, лишь бы с того утюга, на котором он функционирует, был доступ к сайту телеграмма. Больше требований к месту его расположения никаких нет.

Это удобство.

Неудобство заключается в клиент-серверной идеологии самого АПИ. Реализация АПИ нас не волнует вообще, т.к. мы общаемся уже через библиотеку прокладку, но нас волнует событийность модели. Все библиотеки для работы с телеграмом которые я видел реализованы абсолютно одинаково:

  1. есть бесконечный цикл запросов на получение новых событий от сервера

  2. при получении событий дергаются куски пользовательского кода

Пользовательский код можно описывать с разной степенью «задекорированности», но так или иначе это всегда должен быть какой-то конечный кусок, реализующий действия по каждому элементарному событию. Учитывая то, что основных события всего два: текст сообщения и нажатие на кнопку в сообщении, то в пользовательской части весь код, по сути, состоит из вызова всего двух функций.

В этом заключается основная проблема т.к. реализовать сколько-нибудь сложную логику на двух асинхронно вызываемых функциях, дергаемых по каждому событию крайне сложно. Разумеется, я ознакомился с «типовыми» подходами по написанию кода в этих условиях. Я знаю о наличии декораторов с кучей условий, а так же хорошо знаком с концепцией конечного автомата.

Возможно из-за того, что я некоторые решения представляю себе довольно хорошо, я еще на берегу понял, что это тупиковый путь. Реализовать бота на событийной модели можно, но для этого нужно четкое описание логики «высеченное в граните». У меня же нет четко поставленной задачи по написанию чего-то конкретного, а титанизм усилий при попытке заменить на половине дороги логику обработки или используемый инструментарий я представлял замечательно, поэтому я даже пробовать не стал делать что-то сложнее примеров для ознакомления.

Второй «печальный» момент, с которым придется столкнуться при написании ботов – это, хм… невменяемость протокола. Прямо по нему видно, что его писали кусками в разное время и при добавлении чего-то нового ничего из старого никогда не менялось. В результате в протоколе нет практически ни одной сходной вещи, которые бы функционировали одинаково. Пара примеров. Есть два типа клавиатур, которые можно привязать к сообщению: INLINE и KEYBOARD.

Кроме слова «клавиатура» них нет ничего общего. Кнопки у них описываются по разному, имеют разный набор полей и даже сообщения с этими клавиатурами ведут себя различно и несовместимо. Сообщения с картинкой и типом «photo» – это отдельная сущность, которая с другими сообщениями имеет мало общего. У него даже текст подписи называется «caption» вместо «text». Сообщения с другими «вложениями» - это опять отдельные сущности.

В общем, попытка унифицировать этот винегрет обещает длительную и плодотворную работу методом тыка. Не очень удивительно то, что основные библиотеки для ботов даже не пытаются эту работу делать. Впрочем, к текущей статье это имеет теоретическое отношение.

Итого, задача

После того, как мой этап знакомства с АПИ телеграмма и используемой библиотеки привел меня к знаниям, описанным в паре прошлых абзацев, я отложил попытки написать что-то цельное и начал искать готовые решения по «очеловечиванию» реализации «бизнес логики» бота.

К огромному моему удивлению я не нашел вообще ничего. Я сейчас имею в виду не вебовские «конструкторы» ботов, коих миллион (ну или приблизительно столько), у меня все же стояла задача по изучению питона, а не по тренировке мелкой моторики правой руки компьютерной мышью. Я имею в виду какие-то библиотеки, которые бы позволяли реализовывать логику работы конечного продукта (бота) в привычной линейной форме, полностью убирая все особенности модели общения с сервером с глаз пользователя. Не нашел.

В результате, после довольно краткого знакомства с «полем боя» задача плавно трансформировалась из «пишем бота» в «пишем средство для написания бота». Мне, в сущности, это было даже гораздо интереснее и ближе т.к., повторюсь, основной задачей было именно освоение питона, а не реализация конкретной задачи, но все же было бы интересно узнать, если такие решения существуют.

Итого: необходимо реализовать средство, которое бы позволяло писать ботов для телеграмма в привычной линейной схеме реализации алгоритма. Т.е. чтобы можно было писать что-то типа такого (питоно–подобный псевдокод):

Желаемый синтаксис логики бота
def logic( chat ):
    cfg = getSomeChatPersistentConfig()
    name = chat.user.name

    say( 'Hi, im a bot' )
    if ( ask( 'I see you name as {name}\nCan I use it talking with you?' ) )
      cfg.name = name
    else
    while True:
      name = input('Please enter you name')
      if name:
        cfg.name = name
      else
        match choise('Your cancelled, what do you nant to do?'):
          case 'QUIT':
             say( 'Well, its sad, but hope you back soon.'
             return

          case 'NONEW': 
             cfg.name = user.name
             say( 'Ok, let it be, {cfg.name}' )
             break

          case 'RETRY': continue

    chat.clear()
    say( 'Lets start to work {cfg.name}!' )

    while True:
      match menu('What do you wana do?'):
        case 'QUIT':     break
        case 'DEVICES':  CallDeviceDisplayProc()
        case 'SETTINGS': CallSettingsProc()

     say('Bye, see you')

Т.е. решать задачу, а не воевать с ограничениями, накладываемыми на способ ее реализации.

Для решения этой задачи нам нужно сначала понять пару простых вещей:

  1. Логика: Какие интерфейсы нам нужны для реализации интерактивных задач.

  2. Функции бота: Какие способы взаимодействия с пользователем существуют, как и какие нужно использовать, чтобы реализовать нужное для нашей логики.

  3. Работа бота: Как и где эксплуатировать нашего бота. Как он будет получать управление.

  4. Запуск: Как запустить нашу логику параллельно «бесконечному циклу опроса сервера».

Работа бота

Я не буду вдаваться в различия способов эксплуатации бота и возможных мест откуда он может быть вызван, что и где можно или нельзя использовать. В конце концов у нас не бесконечность, а всего лишь одна неделя на то, чтобы ознакомиться с питоном и создать какой-то конечный продукт, поэтому сосредоточимся только на персональных чатах (тех, когда человек пишет чату явно, запуская его кнопкой «запуск»). По сходной же логике работают и боты, которые были добавлены в групповые каналы, но там есть свои особенности. Будем писать код, не обращая внимания на эти различия. По крайней мере пока.

Итого: пишем библиотеку для ботов, ориентированных на работу в персональном канале.

Функции бота

При работе телеграмма существуют следующие способы взаимодействия бот-человек:

  • Написание текста как с одной, так и с другой стороны – это сообщение. В этом важно то, что бот ничего не делает до тех пор, пока ему не придет первое сообщение. Именно при получении сообщения определяется, откуда его написали (из какого канала), и кто это сделал. Т.к. события могут происходить абсолютно не связанно друг с другом, то сообщения будут приходить для разных каналов, от разных людей, которые находятся на разном этапе общения с ботом. Учитывая это нужно понимать что одна линейная бизнес-логика должна работать только для одного канала. Или так: для каждого канала должна быть запущена своя уникальная копия бизнес-логики. Сохранение как промежуточных, так и финальных данных происходит так же на «по–канальной» основе.

  • В сообщении формируемом ботом, помимо «контента» можно использовать кнопки. Кнопки бывают двух видов: привязанные к сообщению и к чату (хотя тоже к сообщению, но зрительно… блин, тьфу на тебя АПИ телеграмма!). В детальное описание кнопок и их различий я вдаваться не буду по описанному уже выше принципу: «кому пофиг, а кто и так в курсе». В контексте статьи важен сам факт и различия в использовании этих кнопок. Кнопки у чата просто спамят текст в канал, а те, которые у сообщений, вызывают отправку специального обновления.

Подводим итог.

Бот может получить управление в любой момент, когда пользователь напишет какое-то сообщение или нажмет на кнопку. Это может случиться через секунду или через неделю. Не то, чтобы это было как-то важно для нашего недельного марафона, но учитывать все же стоит.

Нарисуем что-то типа:

class ISettings(typing.Sized):
    """Interface for ``Settings`` class"""
    _cfg: 'ISettings'

    def __init__(self,cfg:'ISettings'):
        self._cfg = cfg

    def gopt(self, path: str, default: TSettingsOption_t) -> TSettingsOption_t:
        """Get data from settings or set it to default if data not found"""
        return self._cfg.gopt(path,default)

    def sopt(self, path: str, value: TSettingsOption_t) -> TSettingsOption_t:
        """Set settings option to value """
        return self._cfg.sopt(path,value)

    def sub_cfg(self, nm: str) ->'ISettings':
        """Get settings sub-key by full path in form of 'key.key'... """
        return self._cfg.sub_cfg(nm)

и

class SettingsIStorage:
    """Interface for load\save storage"""
    def load(self, settings: ISettings):
        pass

    def save(self, settings: ISettings):
        pass

и забудем об этом вопросе.

Общение с ботом происходит в каждом канале независимо и уникально. Учитывая еще и тот факт, что боту может поступать сообщение с произвольными интервалами, то логика обработки сообщений должны быть такой: при получении сообщения находим канал, который его обслуживает.  Если канала нет, то создаем новый. Передаем каналу сообщение. Если в канале уже работает логика, то распределяем сообщение в соответствии с ее потребностями, если ее нет, то запускаем ее, передав сообщение как стартовое. Код приводить не буду т.к. в реализации это все крайне просто, но очень много пробелов , «self.» и прочего не важного текста.

Желающие могут посмотреть реализацию самостоятельно.

Логика

Накидаем пример того, как нам хотелось бы, чтобы наша программа выглядела.

Например, так:

Пример рабочей программы
class Logic(ILogic):
    async def main(self, chat: BotChat, params: str) -> None:
        chat.user().name = chat.last.from_user.full_name
        name = chat.user().name

        if params:
            pstr = f'\nYou started me with parameters *"{escape_md(params)}"*, but I dont support any ????\n\n'
        else:
            pstr = ''

        titleMsg = await chat.reply(
            f'Hi, *{name}*.\n'
            f'{pstr}'
            f'You are at examples section',
            media='data/Icon-Hi.png'
        )

        while True:
            rc = await chat.menu(
                'Choose test group to go',
                [[('➡ Menu tests...', 'menu')],
                 [('❓ Some asking', 'ask'), ('✌ Funny one :)', 'wait'), ('????', 'calc')],
                 [('❌ Close', 0), ('❌ Cancel', 0), ('❎ Abandon!', 0), ('➰ F* off!!', 0)],
                 ],
                remove_unused=True
            )
            if not rc.known: break
            if rc.data == 'menu':
                await logic_MENU(chat, name)
            elif rc.data == 'ask':
                await logic_ASK(chat, name)
            elif rc.data == 'wait':
                await logic_WAIT(chat, name)
            elif rc.data == 'calc':
                await logic_CALC(chat, name)
            else:
                break

        await titleMsg.delete()
        await chat.say(f'Calm down mate!\nIts all done already.\nSee you ????', wait_delay=1)
        await chat.say(f'...btw, if you wanna reply you can use "/start" command.', wait_delay=2)
        await chat.say(f'Just saying...')

На примере этого кода видно, что нам нужно реализовать:

  • Передачу сообщений (в идеале с картинками).

  • Удаление/Изменение сообщений

  • Все вышеперечисленное, с учетом того, что нужны кнопки управления, которых бывает 2 вида.

  • Реализовать «модальность» для сообщений, т.е. когда все события обрабатываются только одним элементом монопольно не давая выполняться обработчикам из предыдущих элементов до тех пор, пока модальный элемент не будет закрыт. Это нужно для реализации ввода, ответов на вопросы, меню и прочего.

  • Реализовать возможность обрабатывать нажатия на кнопки в немодальных сообщениях.

Запуск

Любые библиотеки для работы ботов представляют из себя, в конце, концов такое:

executor.start_polling(dp)

Т.е. запуск некоего бесконечного цикла опроса и реакций на события, в то время как пользовательский код вызывается разными сортами такого:

@dp.channel_post_handler()
async def post_handler(message: Message_t):
    …
@dp.message_handler()
async def message_handler(message: Message_t):
    …
@dp.callback_query_handler()
async def callback_handler(cbd: types.CallbackQuery):
    …

Т.е. наш код будет вызван при получении различных событий. Их есть некоторое разнообразие, но мы сосредоточимся на простейших: получение нового сообщения в канале и получения уведомления при нажатии кнопки.

Внедриться внутрь цикла с линейной логикой не представляется возможным, поэтому нужно запустить нашу программу параллельно ей и обеспечить взаимодействие из нее с данными, получаемыми «мелкими порциями» асинхронно из основного цикла.

Мне, как человеку далекому от птичьих языков более привычным был бы способ создания отдельного процессорного потока для выполнения логики, но раз подвернулась такая оказия, то заодно разберемся с co-routines (я без понятия как это называется по русски), в windows эти сущности называются Fibers, где-то еще могут быть другие названия.

Суть подхода в том, что множество логически не связанного кода выполняется в одном потоке, передавая друг другу управления в тот момент, когда этому коду нечего делать.

Мы пишем http клиента и «нечего делать» - это его основное занятие, поэтому в топку «взрослые» потоки, пишем на «резиновых женщинах».

Добавить свой код в очередь выполнения очень просто:

        async def _wrapper():
            self.log.error(f'Start bot logic task')
            try:
                await self.logic.main(self, params if params else '')
                self.logicTask = None
								…
        self.logicTask = asyncio.get_event_loop().create_task(_wrapper())

где logicTask хранит объект добавленного кода, а self.logic.main запускает на выполнение процедуру с реализацией линейной логики. С учетом разных нужд пусть интерфейс для нашей логики будет выглядеть так:

Код интерфейса логики
class ILogic(ISettings):
    """Interface for linear logic implementation for each ``BotChat`` channel.

    Each ``BotChat`` object uses this interface to call user logic
    functions. Each channel will have separate, unique logic object.
    """
    chat: 'BotChat'

    def __init__(self, chat: 'BotChat', cfg: ISettings):
        super().__init__(cfg)
        self.session = chat

    async def main(self,chat:'BotChat',params:typing.List[str]) -> None:
        """Main user logic function. Called after '/start' or '/restart' command and will run until
        exception happen or finished.

        Executed in parallel with bot task.

        Note: MUST NOT block on operations called from outside (like callbacks).

        :param chat: parent chat executing logic
        :param params: string with parameters passed to '/start' or '/restart' commands.
        """
        pass

    def OnExit(self,chat:'BotChat',isAlive:bool) -> None:
        """Called after logic procedure finished.
        Used just for notification. Can be used f.i. to free resources.

        :param chat: parent chat executing logic
        :param isAlive: True is chat object is alive (can be used to communicate with channel), False if chat is closed.
        """
        pass

    # ret false to disable bot_ilogic restart
    async def OnDownDecide(self, chat: 'BotChat', message: Message_t) -> bool:
        """Called to decide what to do if some notifications received in channel but logic is down (finished or terminated by error)

        :param chat: parent chat executing logic
        :param message: message object which "wake up" channel
        :return: True to restart logic task or False to stay dead.
        """
        return True

Пользователь нашей библиотеки будет наследоваться от этого интерфейса и реализовывать то, что ему нужно. По крайней мере main.

Код

Разбираемся с АПИ телеграмма (в синтаксисе выбранной библиотеки) и выясняем что все кроме модальности реализуемо довольно просто:

Создаем новые сообщения (тип зависит от того с картинкой оно нужно или нет) со всеми возможными наворотами:

    async def _createMessage(self) -> MessageId_t:
        reply_to_message_id = self.reply_to_message_id
        if not reply_to_message_id: reply_to_message_id = None
        if self.media:
            msg = await self.chat.bot.send_photo(
                self.chat.chat_id, parse_mode=self.chat.bot.parse_mode,
                photo=self._loadMedia(self.media), caption=self.chat.escape_soft(self.text),
                reply_markup=self.keyboard.markup,
                reply_to_message_id=reply_to_message_id)
        else:
            msg = await self.chat.bot.send_message(
                self.chat.chat_id,
                text=self.chat.escape_soft(self.text), reply_markup=self.keyboard.markup,
                reply_to_message_id=reply_to_message_id)
        return msg.message_id

Редактирование существующего сообщения будет чуть сложнее (спасибо такому однозначному и «единообразному» протоколу телеграмма), но тоже ничего выдающегося:

async def _updateMessage(self) -> None:
  if self.media:
    if self._media.changed:
      await self.chat.bot.edit_message_media(
        media=types.InputMedia(
          type='photo',
          media=self._loadMedia(self.media),
          caption=self.chat.escape_soft(self.text)
        ),
        chat_id=self.chat.chat_id, message_id=self.message_id,
        reply_markup=self.keyboard.markup)
		elif self._text.changed:
        await self.chat.bot.edit_message_caption(
          chat_id=self.chat.chat_id, message_id=self.message_id,
          caption=self.chat.escape_soft(self.text), reply_markup=self.keyboard.markup
        )
	else:
		if self._text.changed:
      await self.chat.bot.edit_message_text(
        text=self.chat.escape_soft(self.text),
        chat_id=self.chat.chat_id, message_id=self.message_id,
        reply_markup=self.keyboard.markup
      )
    elif self.keyboard.changed:
      try:
        await self.chat.bot.edit_message_reply_markup(
          chat_id=self.chat.chat_id, message_id=self.message_id,
          reply_markup=self.keyboard.markup
        )
        # just mask unchanged error instead complex keyboard comparison
      except aiogram.utils.exceptions.MessageNotModified:
        pass

Это все, разумеется, надо обернуть кучей логики и проверок на то, в каких условиях и с какими «бубнами» вызывать то или другое. Желающие могут посмотреть реализацию самостоятельно.

Удаление сообщений существует и, слава богу, простейшее без каких-либо ветвлений в истории развития протокола:

try:
  if await self.bot.delete_message(chat_id=self.chat_id, message_id=message_id):
    return True
  except BadRequest as e:
    return False

Вроде все.

Кнопки в протоколе описываются по разному, но пофик, унифицируем все до:

BotUserKey_t = typing.Union[typing.Tuple[str, typing.Any], str]

для любой клавиатуры. Это лишняя страница кода, зато не нужно париться с типами при использовании. Стоит того.

Модальность к самому телеграмму отношения уже не имеет - это чисто логический наворот, который реализуем простой очередью сущностей, которые ждут получения событий. Т.к. событий всего два, то и интерфейс для такой сущности будет иметь всего две полезные функции. Так же эти «ждуны» должны уметь быть посредниками между кодом логики, который будет ожидать каких-то событий и получаемыми мелкими событиями. Принцип простой: с одной стороны, если логике что-то надо, то она заводит «ждуна» и ждет, пока поставленное условие не выполнится. С другой стороны, при получении мелких событий от телеграмма, проверяется очередь «ждунов» и, начиная с последнего, полученные данные применяются до тех пор, пока не дойдем до модального «ждуна» или до такого, который ровно этого события и ждал. Реализуется почти так же просто как и звучит.

Интерфейс ждуна, грубо говоря, такой:

Интерфейс класса ожидания
class Waiter:
    """Class which is used to filter received messages and callbacks and pass execution to
    user logic.
    """
    chat: 'BotChat'
    isModal: bool = False

    def __init__(self, chat: 'BotChat', messge_id: MessageId_t, /,
                 on_message: typing.Optional[OnMessageEvent] = None,
                 on_callback: typing.Optional[OnCallbackEvent] = None):
        """ Create base waiter class

        :param chat: parent chat waiter will be attached to
        :param messge_id: message id this waiter attacked to if applicable (NoMessageId if waiter not attached to single message)
        :param on_message: Callback to call on new messages
        :param on_callback: Callback called on new INLINE buttons data
        """
        self.chat = chat
        self.messge_id = messge_id
        self._completed = asyncio.Event()
        self._completed.clear()
        self._on_message = on_message
        self._on_callback = on_callback

    async def isWaitingThisMessage(self, chat: 'BotChat', message: Message_t) -> bool:
        """Check if this waiter process specified message"""
        if self._on_message and await self._on_message(self.chat, message):
            return True
        return False

    async def isWaitingThisCallback(self, chat: 'BotChat', cbd: Callback_t) -> bool:
        """Check if this waiter process specified callback data"""
        if self._on_callback and await self._on_callback(self.chat, cbd):
            return True
        return False

    def notify_complete(self):
        """Used to notify waiting user logic, what wait is complete. Called from bot loop to inform user logic"""
        self._completed.set()

    async def wait(self, timeout: float = None) -> bool:
        """Wait until complete. Called from user logic to wait waiter condition."""
        if timeout and timeout >= 0:
            try:
                await asyncio.wait_for(self._completed.wait(), timeout)
                return True
            except asyncio.TimeoutError:
                self.chat.waiterRemove(self)
                return False
        else:
            await self._completed.wait()
            return True

Ну и в классе нужно не забыть создать для него небольшую инфраструктуру:

waitersLock: threading.RLock
waiters: typing.List[typing.Optional[Waiter]]

В этом месте есть небольшой момент, на который стоит обратить внимание. Локер, на котором синхронизируется список «ждунов» должен быть: а) реентерабельным и б) не отпускающим очередь «детских потоков».

Первое понятно для чего, а вот второе важно в единственном моменте. В телеграмме, при получении события от нажатой кнопки на это событие можно ответить и при ответе у пользователя всплывет либо модальное окошко с текстом, либо пропадающий хинт. Так вот, если использовать блокировки из набора асинхронной библиотеки, то в момент блокировки будет передано управление первому попавшему ждущему своей очереди коду. Если окажется так, что этот код отправит серверу телеграмм, какие-то данные , то ответ на событие уже можно и не отправлять – оно не сработает, пользователь не увидит реакции.

Если не хочется лишать себя такой функции как эти «быстрые ответы прям сразу при нажатии», то асинхронные локеры использовать нельзя. В примере выше используется многопоточный реентерабельный локер, хотя учитывая структуру самого приложения его функция чисто декоративная и не более интеллектуальная чем:

While not stateVariable:
	pass

только что использовать удобнее и писать меньше.

По функционалу вроде бы все.

Итог

Если не лукавить, то времени ушло чуть более недели. Впрочем, меня извиняет поездка на шашлыки и пара катаний на дачу.

Зачем я все это тут описал?

Целей у меня, на самом деле, несколько.

Во-первых, хотелось бы узнать мнение тех, кто пишет на питоне давно и серьезно. Все же этот код я писал как знакомство я языком и буду рад любым отзывам о его качестве. Однако просьба не забывать, что 2 недели назад я путал питона с ужом :)

Во-вторых, мне интересно было бы узнать о подобных альтернативах. Все же я не верю, что такое никто не реализовывал ранее.

Ну и, в третьих, на тот случай, если мой код или его описание окажется для кого-то в чем-то полезным. Буду рад.

Код, описанием и историей которого является эта статья доступен по ссылке.

Комментарии (25)


  1. yesworldd
    17.07.2022 12:04
    +2

    Посмотрел исходный код, либо здесь я ничего не понял. Все равно не понял. Непонятные названия переменных, лишние абстракции, овер дофига уровни вложенности. не соблюдение pep8(понимаю, автор пишет всего лишь 2 недели, но пусть хотя бы с snake_case можно же писать код), странные тесты.


    1. JouriM Автор
      17.07.2022 12:07

      Спасибо за отзыв.
      С кейсом переменных понятно. В разных средах требования и стандарты разные. Я привык по другому и, на мой взгляд роли особой это в данном случае не играет.
      А по остальным пунктам, если можно, подробнее.
      Интересует: что за лишние абстракции и уровни вложенности. О чем речь?
      Если можно, то с примером как оно "должно быть".
      Интересно.


    1. Andrey_Solomatin
      17.07.2022 22:25

      не соблюдение pep8(понимаю, автор пишет всего лишь 2 недели, но пусть хотя бы с snake_case можно же писать код)

      Время знакомства здесь не сильно влияет. Все популярные языки которые дожили до нынешних дней уже обросли инструментами автоматизации. Можно соблюдать большинство стандартов, даже не зная их, просто следую предупреждением инструментов.


  1. Andrey_Solomatin
    17.07.2022 12:17
    +4

    > Все же этот код я писал как знакомство я языком и буду рад любым отзывам о его качестве.

    В статье великий мастер божественного языка снизошёл для того, чтобы посмотреть языки простых смертных.

    Я уж было думал, что угадал, этот божественный язык по именованию классов, но там префикс I используется для интерфейсов, а не для абстрактных классов.

    До Питона я не добрался, сломался на общих вещах.

    Много лишней сложности. Класс (ISettings) принимает в конструктор экземпляр своего типа и проксирует его методы. Это можно решить с помощью наследования.

    Класс Settings в котором почти в каждом методе стоит if по типу, это странное использование ООП.

    main.py 500 строк кода, где логика размазана по разным местам.

    Цикломатическая сложность зашкаливает.

    Я иногда помогаю знакомым с Питоном, поэтому дочитал статью до конца. Не думаю, что она будет полезна новичкам.

    Если использовать форматирование для С подобных языков, то оказывается, что пробелов там не сильно меньше и еще куча фигуруных скобок. Ваши претензии к пробелам мне не понятны. Привычка приходить в новый язык со своим самоваром мешает его изучению.


    1. JouriM Автор
      17.07.2022 17:53

      В статье великий мастер божественного языка снизошёл для того, чтобы посмотреть языки простых смертных.

      Откуда такой сарказм?
      Впрочем, я наверное догадываюсь, Вы восприняли выражение "птичий" как оскорбление?
      Дико извиняюсь, автопилот.
      Просто это выражение мне стало привычным очень много лет назад. Оно не несет никакой негативной нагрузки и является своеобразным "техническим термином", обозначающим не компилируемый язык, как правило узко-нишевый. В 80х, когда начали активно развиваться скриптовые языки, в том числе мы писали их сами, часто звучало: "Как ты это сделал?" - "Написал свой птичий язык". Это просто было кратчайшее пояснение.
      В общем, как-то так.
      Еще раз извиняюсь, если оскорбил.

      По остальному тексту многое непонятно.
      Что такое "цикломатическая сложность" я прочитал, но не уверен что понял к какой именно части относится претензия. Вы не могли бы ткнуть пальцем?

      Класc ISettings я использовал не как прокси (хотя он именно это), а в попытке хоть как-то разделить реализацию и описание т.к. я так и не понял какими методами это делается. Описаний в питоне нет (собственно я знаю только паскаль и С с ними), поэтому мне приходилось либо тащить весь код в 1 файл, либо это не собиралось из-за циклических зависимостей.

      Претензию про main.py я тоже не понял.
      Этот файл - единый сборник примеров использования не особо связанных друг с другом.
      Можно было бы написать с десяток разных файлов с раздельными примерами, в этом проблема?
      Мне показалось, что будет проще запустить одного бота и сразу посмотреть все (+-) что может либа, чем запускать кучу тестов.

      Про количество кода это была шутка.
      Жаль что опять оказалась, видимо, оскорблением.
      Хотя все претя писать объект класса - это раздражало. Я еще не использовал языков, в которых были бы объекты, но в их методах нужно было бы всегда явно его писать.


      1. Andrey_Solomatin
        17.07.2022 22:52

        Откуда такой сарказм?

        Да и сама статья написана в свободном стиле.


      1. Andrey_Solomatin
        17.07.2022 23:18

        Чтобы говорить о сложности её нужно измерить. https://pypi.org/project/radon/
        Уровень С и D уже считается избыточно сложным.

        В данном коде вы очень злоупотребляете вложенными условиями, за счет этого его сложно читать.

        PS J:\projects\LinearBotLib> radon cc . -s -n C
        bot.py
            M 468:4 BotChat.waitProcess - D (21)
            M 573:4 BotChat.logicStart - C (11)
        bot_imessage.py
            M 135:4 BotIMessage._display - C (15)
        main.py
            F 26:0 logic_MENU_Form - C (12)
            F 149:0 logic_MENU_Animation - C (12)


        1. Andrey_Solomatin
          17.07.2022 23:59

          Класc ISettings я использовал не как прокси (хотя он именно это), а в попытке хоть как-то разделить реализацию и описание т.к. я так и не понял какими методами это делается.

          Не очень понимаю, что есть описание и зачем оно нужно.

          Если хочется сделать, что то типа интерфейса, то листайте результаты поиска гугла на эту тему. Рано или поздно наткнетесь на абстрактные классы.

          Хотя в данном случае можно и без интерфейсов обойтись. Одна из фишек Питона, это можно сделать потом, не меняя код в других файлах.

          либо это не собиралось из-за циклических зависимостей

          В файле три сущности. Вроде нет там циклических зависимостей.

          И ситуация когда класс наследует интерфейс и его же экземпляр получает в конструктор мне кажется странной.


          1. JouriM Автор
            18.07.2022 12:34

            И ситуация когда класс наследует интерфейс и его же экземпляр получает в конструктор мне кажется странной.

            Это класс, который может быть как полноценным независимым хранилищем, так и ссылкой на ветку в уже существующем.

            # независимое хранилище
            s = Settings()
            
             # Ссылка на ветку my.internal.object в другом хранилище
            sub = s.sub_cfg('my.internal.object')
            

            В первом случае объект сам манипулирует данными, во втором делегирует все действия по цепочке владельцев оригинальному хранилищу.
            Это сделано для того, чтобы в разных объектах ветви могли пересекаться.
            К примеру некий "UsersArray" работает с веткой "users", каждый отдельный "User" с ветками внутри нее, а сессия все это хранит целиком.
            Любые изменения вносятся в единое место.
            Скорее всего это уже 100 раз реализовано и, возможно, более "правильно".
            Я это писал в первый день, разбираясь с rtti питона т.к. было интересно.


        1. JouriM Автор
          18.07.2022 11:54

          Ясно.
          В этих примерах функции с вложенными функциями, что я использовал как своего рода private.
          Кроме BotChat.waitProcess, в которой просто вложенность для отладочного логирования в виде:

          D:\MYTESTS\LinearBotLib\bot.py[930]::ask( ask ) {
            D:\MYTESTS\LinearBotLib\bot.py[895]::popup( popup ) {
              new msg 3642 text Which version do you want to test?
              SM: add KBD waiter <bot.Waiter object at 0x000000F1E8F923E0>
              CH: add waiter[0, m: False] :  <bot.Waiter object at 0x000000F1E8F923E0>
              CH: waiter added[1]
              D:\MYTESTS\LinearBotLib\bot.py[303]::_OnPopupMessage( msg, 3642 ) {
                M: del waiter
                CH: del waiter 1 m: False w: <bot.Waiter object at 0x000000F1E8F923E0>
                CH: waiter deleted 0
                PM: add waiter
                CH: add waiter[0, m: True] :  <bot.ModalWaiter object at 0x000000F1E8F923E0>
                CH: waiter added[1]
                waiting modal True
                D:\MYTESTS\LinearBotLib\bot.py[472]::waitProcess( logic, True ) {
                  WP waiters 1
                  PM: msg OK:  <bot_types.BotKeyboardResult object at 0x000000F1E8F91780>
                  thisMsg
                  WP 0 modal True rc True
                  notify_complete
                  WP ret 0
                }
                PM: lrc:  <bot_types.BotKeyboardResult object at 0x000000F1E8F91780>
              }
              D:\MYTESTS\LinearBotLib\bot.py[768]::delete( msg, <bot.BotMessage object at 0x000000F1E8F92050> ) {
                del= 3642
              }
            }
          }
          

          Если выкинуть вложенность, то радон становится почти счастлив:

          D:\MYTESTS\LinearBotLib>radon cc . -s -n C
          bot.py
              M 468:4 BotChat.waitProcess - C (12)
              M 573:4 BotChat.logicStart - C (11)
          bot_imessage.py
              M 135:4 BotIMessage._display - C (15)
          main.py
              F 26:0 logic_MENU_Form - C (12)
              F 149:0 logic_MENU_Animation - C (12)
          settings.py
              F 540:0 pSINGLE - C (13)
          

          Вообще, разумеется, разодрать ее на куски для удовлетворения радона, конечно, можно.
          Просто я обычно в коде исхожу из следующего, раздирать функции надо если: функция сильно не влезает на экран, имеет длинную сложную логику, длинный куски кода в ветках логики.
          В данном случае, функция у меня помещается на экран, а если "сфолдить" ее 3 независимые части, то будет вообще 4 строчки (как это видно в IDEA).

              async def waitProcess(self, message: Message_t = None, data: types.CallbackQuery = None):
                  """Process received data or message thru waiters queue"""
                  if not message and not data: return False
          
                  with PROC('logic', self.logicWorking):
                      # start/restart bot logic
                      if message and message.text[0] == '/': ...
          
                      # bot logic is down
                      if not self.logicWorking: ...
          
                      # have no bot logic, nothing to do
                      if not self.logicWorking: return
          
                      # dispatch events
                      LOG('WP', 'waiters', len(self.waiters))
                      if len(self.waiters): ...
          

          На мой вкус не заслуживает упрощения.
          Это я не ради спора, просто поясняю.


          1. Andrey_Solomatin
            19.07.2022 00:40

            Вообще, разумеется, разодрать ее на куски для удовлетворения радона, конечно, можно. Просто я обычно в коде исхожу из следующего, раздирать функции надо если: функция сильно не влезает на экран, имеет длинную сложную логику, длинный куски кода в ветках логики.

            Радон более портируемая метрика чем размер экрана и шрифта.

            Я разделяю по функциям по тому же признаку и еще когда хочу отделить разные уровни логики, какие-нибудь манипуляция с индексами или библиотеками и бизнес логику. Например вынести `message and message.text[0] == '/'` в is_chat_message(message)

            Отличие в том, что у меня более жёсткие критерии.

            Для меня ваш код выглядит слишком сложным, для его чтения требуется больше усилий чем можно было бы.

            Я не знаю, что вы понимаете под длинными путями в ветках логики. Вот самая глубокая вложенность. Человеческая кратковременная память может держать в себе 7 +- 2 элементов.
            Здесь уже 7 уровней вложенности.

            with ... if  ... with ... while ... try ... if ... if

            В этих примерах функции с вложенными функциями

            Это называется контекстный менеджер. Посмотрите в документации для него есть простой синтаксис, без класса.


      1. Andrey_Solomatin
        18.07.2022 01:47

        Претензию про main.py я тоже не понял. Этот файл - единый сборник примеров использования не особо связанных друг с другом.

        Не сразу понял задумку с несвязанных объектах.

        Размазывание в том каждая функция, например logic_MENU состоит из двух частей, которые должны быть согласованны между собой.

        Зачем заставлять пользователя писать эти `while True` блоки, и давать ему возможность делать несогласованные части? Это всё от пользователя можно спрятать внутри библиотеки.

        Весь main может состоять из создание экземпляров Menu и вызова строчки run_bot(menu).

        class Item:
          def __init__(self, user_text, handler): ...
        
        class Menu:
          def __init__(self, title, items: list[Item]): ...
        
        ---- main.py ---
        from ... import Menu, Item
        from ... import run_bot
        
        menu = Menu(.....)
        
        run_bot(menu)




        1. JouriM Автор
          18.07.2022 11:31

          Зачем заставлять пользователя писать эти while True блоки, и давать ему возможность делать несогласованные части? Это всё от пользователя можно спрятать внутри библиотеки.

          Вы вообще смысла моего кода не поняли.
          Смысл как раз в том, что пользователь общается с функционалом бота полностью в линейном режиме, как если бы он использовал консоль для ввода input-ом, и выводом print-ом.
          Никакие дополнительные классы не нужны.
          await chat.menu() - это уже "класс" меню. Законченный. В этой строчке он отрисует свой интерфейс в телеграме, дождется реакции пользователя и вернет то, что он там выбрал. Сюда же.
          Пользователя никто циклы писать не заставляет - он может реализовывать свою логику как ему угодно, хоть создавая классы, хоть делая циклы.
          Весь main.py - это сплошной код "пользователя".
          О телеграме, его протоколе, способах взаимодействия с ним и особенностях пользователь не обязан знать практически ничего.
          Для реализации всего что ему надо, по сути, достаточно BotChat.say, BotChat.menu, BotChat.ask и BotChat.delete. Для чуть более сложных "интерфейсов" еще и BotIMessage. По сути все.


          1. Andrey_Solomatin
            19.07.2022 00:58

            Вы вообще смысла моего кода не поняли.

            Звучит двусмысленно :)

            Пользователя никто циклы писать не заставляет - он может реализовывать свою логику как ему угодно, хоть создавая классы, хоть делая циклы.

            Вложенное меню так часто используется, и всегда с циклами.

            Мне паттерн `while true; rc = await chat.menu(...); if rc.data == ...` не нравится, слишком много кода. Это можно упрятать в библиотеку. Да, я ожидаю чтобы вложенные меню были внутри, библиотеки, а не собирались по кусочкам снаружи.


      1. Andrey_Solomatin
        18.07.2022 02:04

        Хотя все претя писать объект класса - это раздражало. Я еще не использовал языков, в которых были бы объекты, но в их методах нужно было бы всегда явно его писать

        Этот подход соответствует философии языка. Explicit is better than implicit.

        Привыкните перестанет раздражать.


  1. makar_crypt
    17.07.2022 17:38

    Вопрос

    1) я хочу сделать все тоже самое , но не от бота , а от своего акка, со свои логином паролем. можно ли такое сделать? на сколько мне надо всё переделывать? Ваш "захваленный АПИ телеграмма", он тот же если работать от своего акка?

    2) вы много работали с media , если у телеги CDN? можно ли как то взять url медия? Должен же быть, как то же веб версия телеги работает с этим.


    1. HemulGM
      17.07.2022 23:49

      Телеграм не имеет такого апи для работы от имени пользователя.
      Для этого, есть специальная библиотека (бинарная) от телеграма, которая скромно предлагает некоторые вещи. Пример из этой статьи вообще не подойдёт. И переделывать придётся абсолютно всё.

      Не говоря уже о том, что пример из статьи сомнительный.


      1. JouriM Автор
        18.07.2022 12:04

        Первый абзац - это неправда.

        С вторым спорить не могу.


        1. HemulGM
          18.07.2022 14:46

          Это правда. Телеграмм не представляет прямого апи, как с ботом, для работы с пользователем. Это факт. Попробуйте изучить вопрос получше.

          Для работы от имени пользователя, телеграмм представляет бинарную библиотеку-клиента. Где сам реализовывает всё, что считает нужным. И только эту библиотеку можно использовать для работы от имени пользователя


          1. JouriM Автор
            18.07.2022 20:07

            Я бы даже и ничего не писал, но вдруг кто поверит.
            Поизучай на досуге прям первый абзац вот отсюда: https://core.telegram.org
            Я знакомому уже даж болванку клиента на этой "бинарной" либе написал....


            1. HemulGM
              18.07.2022 20:20
              +1

              *рука лицо*.

              Попробуйте чуть глубже почитать, а не только первый абзац. Посмотрите примеры использования этой библиотеки. И наконец, дойдите до момента, когда эта библиотека подключается к вашему языку.

              Или напишите, свое классное приложение на питоне, а потом запустите этот скрипт на чужом компе, где рядом не будет tdjson.dll библиотеки (если речь про win).

              Или, хотя бы, вот это откройте https://github.com/alexander-akhmetov/python-telegram/blob/master/telegram/tdjson.py


    1. JouriM Автор
      18.07.2022 12:02

      я хочу сделать все тоже самое

      Учитывая остальное сообщение: это как?
      Это либа для реализации какой-то логики.
      Как это применимо "от своего аккаунта"? Что именно вы хотите сделать?

      но не от бота , а от своего акка, со свои логином паролем. можно ли такое сделать?

      Для работы с телеграмом есть 2 возможности: создать бота, который делает какие-то действия автоматически и создать клиента телеграма (т.е. аналог того клиента, которым Вы пользуетесь).
      Во втором случае, если делать что-то узко-специализированное свое, то да, можно сделать софт, который будет реализовывать клиента с автоматическим подключением именно Вашего телефона, и выполнять какие-то действия от Вашего имени.
      Если не секрет: зачем?


  1. HemulGM
    17.07.2022 23:55
    +1

    Я не понимаю, чем вам не угодила система пулинга, от которой, к слову, вы ни куда и не ушли.
    Дополню ваши полученные знания:
    В пуллинге есть бесконечный цикл, но запросы там не шлются один за другим несколько раз в секунду. Пулинг использует "долгие запросы". Это значит, что сервер, при получении запроса не отвечает длительное время (тянет запрос), пока не появятся данные. Это может продолжаться до 30 секунд. Т.е. если сервер не имеет данных, запрос будет выполняться 30 секунд, если данные есть на момент запроса, он выполнится сразу. Так что нечего бояться пулинга.

    Пулинг, по сути это та же система событий, стой лишь разницей, что она не разделяет события по разным сущностям (пользователь, канал). Разделение должен реализовать разработчик. И это делается достаточно просто.

    Кажется, что вы изобрели велосипед, но с октагональными колесами.


    1. JouriM Автор
      18.07.2022 12:21

      Вы вообще не поняли ни о чем статья, ни для чего весь написанный код.
      Скорее всего, увидев первое же слово, которое Вы посчитали оскорблением, остальное не читали вообще, либо сквозь кровавую пелену агрессии.
      Пролистайте чуть выше, я уже в комментарии пояснил, что цели оскорбить кого-то у меня не было.

      Если по делу, то у меня не было цели "уйти от пулинга". Это не реализуемо в принципе.
      Моя цель была избавить пользователя даже от мысли что он есть.
      Код пользователя в моем примере - это ТОЛЬКО файл main.py до строчки:
      botSession = BotSession(dp, Logic)


      1. HemulGM
        18.07.2022 14:44

        Какое оскорбление? О чем вы? И какая агрессия, простите?

        Зачем избавлять разработчика от пуллинга? Ещё и просто более его запутывая. С пуллингом он все четко понимает. Вот мы забираем событие, вот обрабатываем. Всё линейно и просто. В вашем же коде что-то происходит где попало. Что и почему не понятно. Вот смысл моего комментария.