Желая странного или переносим DTF в telegram

Августовский вечер. За окном "громко", но на этот раз это раскаты грома, а не что-то другое. Под мерную и ритмичную дробь капель, самое то заняться чем-нибудь отвлеченным. Например, созданием поста на DTF с "переносом", того самого DTF в чат telegram'а. Звучит странно и не нужно. На самом деле так и есть. Все затевалось с целью изучить питоновские пакеты для работы с http запросами(к osnova api), немного посмотреть на socketio, а также пощупать aiogram - фреймвор несколько упрощающий работу с telegram bot api.

Мой прошлый материал и код этого проекта

Еще совсем немного офтопа

Начав изучать вопрос возможности реализации физически корректного взаимодействия объектов в python я пришел к нескольким вариантам. Pymunk который в связке с pygame, позволяет легко и быстро написать "что-то отдаленно похожее" на игру с моделью физической симуляции. И Project Chrono - пакет(обертка написанная на python для работы с написанным на C++ проектом chrono) направленный на решение инженерных задач. Поигравшись с первым, я понял, что это мало кому будет интересно, в первую очередь мне. А второй требует серьезного и вдумчивого изучения, даже просто для того, чтобы просто начать что-то делать. Значит реализация "физики на пайтон", будет сильно позже.

Берем и переносим DTF

Наконец-то, к сути это статьи. Что этот кусок кода умеет?

  • Получать новые записи из ленты свежего
  • Отправлять сообщения к статьям/комментариям
  • Получать новые оценки ваших материалов и ответы на ваши комментарии
  • Оценивать чужие материалы

Не густо, но и код очень простой, примитивный. Сам проект разделен на два модуля. Первый - api_part в которой сосредоточена вся логика работы с запросами к osnova api. Второй - bot_part отвечающая за взаимодействие с telegram. Это позволяет, если не улучшить читаемость кода, который по хорошему требует основательного рефакторинга, то проще добавлять/изменять функционал, а в случае изменения в api все интерфейсы взаимодействия бота и запросов к серверам osnova потребуют минимум модификаций.

Так как мы создаем пакет а в нем два модуля, для удобства создадим main.py модуль который и будем стартовать с импортом одной функции запускающей нашего бота.

from ochoba_project.bot_part import main_func if __name__ == '__main__': main_func()

Если вам вдруг захочется странного, попробовать код в действии, то придется сделать еще одну вещь, получить токены к вашему телеграм боту, токен osnova api - для взаимодействия с серверами dtf и ваш telegram_id, через проверку которого я реализовал простую защиту от того, чтобы любой желающий мог управлять ботом. В скачанных исходниках ./ochoba_project создаем файл tokens.py, со следующим содержимым:

telegram_token = '' dtf_token = '' telegram_id = int()

Начнем с последнего ваш id в телеграм легко узнать вбив в поиске @getmyid_bot и отправить боту комманду /start. С токеном для вашего бота, тоже просто находим @BotFather и следуем его инструкциям. С токеном для api DTF, тоже никаких проблем, заходим в профиль:

Желая странного или переносим DTF в telegram

Теперь все должно работать, а мы вернемся к коду.

Api = namedtuple('Api', ['v1', 'v2', 'v3']) Sorting = namedtuple( 'Sorting', ['hotness', 'date', 'day', 'week', 'month', 'year', 'all']) Event_ids = namedtuple("Event", ['new_vote', 'comment_answer']) api = Api('v1.9.0', 'v2.1.0', 'v2.31') sorting = Sorting('hotness', 'date', 'day', 'week', 'month', 'year', 'all') event_id = Event_ids(2, 4) allSite = True answer_msg = '' session_events_id = []

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

api_tuple = ('1.9.0', '2.3.1') Api = namedtuple('Api', ['v1', 'v2']) api = Api('v1.9.0', 'v2.31') print(f'Мы исползуем: {api_tuple[0]}') print(f'Мы исползуем: {api.v1}') # Тут также доступно обращение по индексу '''Второй вариант воспринимается намного лучше'''

answer_msg, session_events_id - две глобальные переменные которые мы используем для хранения сообщения которое будет отправлено как комментарий/ответ на комментарий и список id событий полученных от сервера dtf, чтобы не повторять их пользователю. Костыльные и плохие решения, так делать не стоит, но в небольшом коде и в рамках задачи это работает. Например, для отправки сообщений, куда предпочтительнее было бы использовать конечные автоматы это намного более надежное и элегантное решение, чем считать любой введенный текст пользователем как сообщение готовое к отправке. Насчет же необходимости хранения id ивентов(уведомления, которые вы получаете на сайте: ответы на сообщения, лайки, упоминания и т.п.) получаемых от сервера, этот костыль нужен потому, что я так и не смог заставить работать WebSocket io. У меня огромные подозрения, что проблема в том, что я что-то не то передавал в поле auth='' запроса на конект к серверу и там должны быть не пара ключ-значение {XDeviceToken: api_token}. Но в целом и напротив изначально очерченных целей "познакомится с socketio" можно поставить плюсик. Скрупулезно прочитанная документация, поднятие тестового сервера и клиента, но DTF так и не поддался. Тем не менее, я вышел из ситуации и просто в цикле отправляю запрос на список событий, каждые десять секунд. Прыгнем сразу к части в которой мы описываем логику работы с ботом для наглядности

@dp.message_handler(commands=['start', 'stop']) @aut async def bot_upd(message: types.Message): global session_events_id if not bool(session_events_id): response = user_updates(dtf_token) for one_event in response: session_events_id.append(str(one_event['id'])) while True: if message.get_command() == '/stop': break await asyncio.sleep(10) response = user_updates(dtf_token) for one_event in response: if str(one_event['id']) in session_events_id: continue else: session_events_id.append(str(one_event['id'])) if one_event['id'] not in tmp_id_from_csv(): tmp_data = {'post_id': str(one_event['id']), 'event_type': str(one_event['type']), 'date': str(one_event['date']), 'dateRFC': str(one_event['dateRFC']), 'text': str(one_event['text']), 'url': str(one_event['url']) } save_to_archive(tmp_data) if one_event['type'] == event_id.new_vote: await votes(one_event=one_event, message=message) elif one_event['type'] == event_id.comment_answer: await comment(one_event=one_event, message=message)

Перед функцией нас встречают декораторы @dp.message_handler - метод диспетчера из пакета aiogram, в данном случае он позволяяяет "ловить" сообщения от телеграма и исполнять декорируемую функцию при условии наличия в сообщении от телеграма комманд /start и /stop.

aut() простая функция которая принимает на входе декорируемую функцию и выполняет ее если, в поле id нашего запроса присланного серверами телеграм указан наш id, в противном случае, мы отсылаем пользователю сообщение "Отказано в доступе" и функцию не выполняем.

def aut(func): async def checker(message: types.Message): if message['from']['id'] != telegram_id: return await message.reply('Отказано в доступе!', reply=False) return await func(message) return checker

Вернемся к функции bot_upd() при получении любой из комманд start или stop мы выполним первичное заполнение переменной session_events_id значениями id событий. Сама переменная нужна, чтобы не вываливать на пользователя все прошлые события, в каждой итерации цикла, а выдавать только новые, после каждого запроса к серверу. Но у такого метода есть большой нюанс, запись получает id в момент создания, а не публикации. А значит провисевший пару часов в черновиках материал к нам в ленту, с такой реализацией, не попадет. В основном цикле, если в сообщении была команда stop мы сразу же прерываем его выполнение, в противном случае идем дальше и... ждем 10 секунд. Еще один костыль) Если вы знакомы с работой python, то вероятно слышали про глобальную блокировку интерпретатора GIL. Если очень упрощенно, то код в python выполняется строчка за строчкой последовательно.

'''В python, функция печатающая 'Hello', в каждой итерации цикла, будет выполнена раньше, чем функция печатающая 'Word'. И при этом print('End') не будет выполнена никогда. ''' while True: print('Hello', end=' ') print('Word!') print('End')

Но это огромная тема сама по себе, как работать с GIL в python, для нас главное, что благодаря asyncio, даже не имея понятия о корутинах, не нужно задумываться о GIL. Мы просто пишем код также, как писали бы его для последовательного выполнения, а asyncio делает незаметно для нас, "магию" позволяющую передавать управление потоком выполнения в разные участки кода. Тем не менее корутины - не панацея и многопоточность и мультипроцессорность широко используются для обхода GIL и ускорения работы кода написанного на python.

Вот мы и дошли к запросам к серверу dtf, после ожидания 10 сек(нужного чтобы иметь возможность выполнять в этот момент другой код и не бомбардировать сервера слишком частым запросами, лимит для api - 3 запроса в секунду) мы вызываем функцию user_updates

def user_updates(token): updates = requests.get( url=f'https://api.dtf.ru/{api.v1}/user/me/updates', headers={'X-Device-Token': token}) if updates.status_code != 200: print('Somthing wrong with server response') tmp_response = updates.json()['result'] return tmp_response

С помощью GET запроса, в headers которого отправляем ключ-значение токена api, получаем список последних событий для аккаунта. Проверяем не было ли у нас раньше этих событий, пробегая их в цикле и сравнивая их id с хранящимися в session_events_id. Если находим новые, проходим дальше по потоку выполнения, записываем эту часть ответа сервера в архив - csv файл. На этом подробно останавливаться не буду, просто удобный вариант хранения текстовый данных. В данно случае, хранение холодных данных нам не требуется, но я всегда использовал json или sqlite для хранения результатов работы программы, а в этот раз решил познакомится с csv и реализовать запись/чтение данных в этом формате.

И наконец, мы смотрим на тип события вызывая функцию votes(), оценка нашего комментария или comment(), комментарий к нашему контенту.

async def comment(one_event, message: types.Message): base_id = one_event['url'].split('/')[-1].split('-')[0] rep_to_id = one_event['url'].split('/')[-1].split('=')[-1] buttons = [types.InlineKeyboardButton(text=emoji.emojize(":thumbs_up:"), callback_data=f'{rep_to_id}:just:like'), types.InlineKeyboardButton(text='reset', callback_data=f'{rep_to_id}:just:reset'), types.InlineKeyboardButton(text=emoji.emojize(":thumbs_down:"), callback_data=f'{rep_to_id}:just:dislike'), types.InlineKeyboardButton(text='Send answer', callback_data=f'{base_id}:{rep_to_id}') ] keyboard = types.InlineKeyboardMarkup(row_width=3) keyboard.add(*buttons) await message.answer(one_event['comment_text'], reply_markup=keyboard)

Для ответа на комментарий или статью нам необходимо знать id этих сущностей. Но в ответе сервера с ивентами нам отдельно передается только id этого самого события. Ничего страшного, у нас в по ключу ['url'] есть ссылка, из которой мы получаем id статьи и в случае комментария id этого комментария. Сохраняем их в base_id и rep_to_id. Тут эффективнее и быстрее было бы использовать регулярные выражения для поиска id, но это та тема над которой мне нужно серьезно поработать, так что пока просто используем функцию split() для вычленеия id. Для удобства взаимодействия пользователя с ботом создадим и выведем InlineKeyboard клавиатуру появляющуюся сразу после сообщения. В списке buttons прописываем желаемые кнопки, а переменной keyboard присваиваем результат обращения к классу конструктору InlineKeyboardMarkup, параметр row_width - позволяет нам задать максимальное количество элементов в ряду нашей клавиатуры(клавиатура из трех элементов будет состоять из одного ряда, при четырех элементах из двух 3+1 и т.п.).

Полученный комментарий другого пользователя
Полученный комментарий другого пользователя

Чтобы выполнять какие-нибудь действия по нажатию кнопок мы в параметре callback_data передаем аргументы, при перехвате которых можем понять что должна делать кнопка. Из-за того, что callbak мы генерируем "на лету" и данные из него используем для формирования запроса к серверам DTF, то для разделения данных я просто использую разное количество элементов в callback_data. Для оценок f'{rep_to_id}:just:like'.split(':') даст нам список из трех элементов, а для комментариев f'{base_id}:{rep_to_id}' из двух.

Отправка комментария, отправляем текст боту и нажимаем на кнопку у любого комментария или статьи на которую хотим отправить ответ.
Отправка комментария, отправляем текст боту и нажимаем на кнопку у любого комментария или статьи на которую хотим отправить ответ.

Реализация реакции на нажатие кнопки

@dp.callback_query_handler() async def answer(call: types.CallbackQuery): global answer_msg ids = call['data'].split(':') if len(ids) == 2: add_comment = add_comment_request(api_version=api.v1, token=dtf_token, content_id=ids[0], answer_message=answer_msg, reply_to_id=ids[-1] ) if str(add_comment.status_code) == '200': await call.message.answer(text=f'Ваш комментарий отправлен: {answer_msg[:80]}') await call.answer() else: await call.message.answer(text=f'Что-то не так, самое время прикрутить логи и покопаться в них. ' f'Ответ сервера: {add_comment.status_code}' ) await call.answer() elif len(ids) == 3: sign = {'like': 1, 'dislike': -1, 'reset': 0} create_vote = vote_send(api_version=api.v1, token=dtf_token, comment_id=ids[0], sing_id=sign[ids[-1]]) if str(create_vote.status_code) == '200': if sign[ids[-1]] == 0: await call.message.answer(text='Вы обнулили оценку комментарию') await call.answer() elif sign[ids[-1]] == 1: await call.message.answer(text='Вы понизили на 1 оценку комментарию') await call.answer() else: await call.message.answer(text='Вы повысили на 1 оценку комментарию') await call.answer() else: await call.message.answer(text=f'Что-то не так, самое время прикрутить логи и покопаться в них. ' f'Ответ сервера: {create_vote.status_code}' ) await call.answer()

Так как callback_data у нас генерируется "на ходу", то мы просто ловим все данные от наших кнопок с помощью декоратора @dp.callback_query_handler() и фильтруя их постфактум по длине получившегося списка ids. Если нам нужно отправить комментарий вызываем функцию add_comment_request

def add_comment_request(api_version, token, content_id, answer_message, reply_to_id): add_comment = requests.post(url=f'https://api.dtf.ru/{api_version}/comment/add', headers={'X-Device-Token': token}, files={'id': (None, content_id), 'text': (None, answer_message), 'reply_to': (None, reply_to_id)}) return add_comment

Тут ничего особенного простой POST запрос в котором передаем аргументы(id - записи и комментария, если это ответ на чей-то комментарий, иначе 0 и текст ответа) в параметр files. Затем проверяем ответ сервера и сообщаем о статусе нашего запроса пользователю. Также реализован запрос по изменению оценки материала в функции vote_send.

Желая странного или переносим DTF в telegram

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

В качестве заключения или результат

Некомфортно, что пока я так и не смог прикрутить работу с WebSocket IO не в рамках собственного тестового сервер-клиента. Есть к чему стремится. Добавил в свой арсенал csv как структуру данных для хранения "холодной информации". Немного больше узнал о сетевых запросах и работе с пакетом requests(попутно изучив документацию httpx, обратно совместимого с requests пакета, но умеющего в асинхронные запросы). Познакомился с aiogram - удобной оболочкой над telegram bot api.

TODO: Разобраться с большими, сложно воспринимаемыми/читаемыми функциями в модуле api_part, разобратся в том, почему я "накодил" так, что это нужно переписывать и как не допускать этих ошибок в будущем. Также сделать получение ивентов через socket io, а не в циклической отправке запроса и получении списка всех недавних событий.

Спасибо за чтение. До встречи в постах на DTF.

2121
6 комментариев

Очоба — неплохой экземпляр, чтобы разобраться с web api и написать своё маленькое приложение.
Я писал скрипты, чтобы найти свои старые комменты и закладки, потому что UI тупит страшно :(

1

Я занялся этим, во многом, для того чтобы вечером получать уведомления об ответах в комментах, помимо практики, и при этом не сжигать себе глаза, чтобы с ними ознакомиться и ответить. И хотя, документация для api в основе своей сгенерированна автоматически, насколько я понимаю, все довольно просто и понятно, с примерами запросов. Только зоопарк api немного напрягает. Часть запросов в 1.9.0, часть в 2.3.1, что из 2.1.0 все еще работает и т.п. Правда, пока по назначению этим пользоваться сложно, но зато есть стимул допилить для себя.

1

Интересно есть ли такой же бот только с твиттером

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