Так в чём же лукавство заголовка? Во-первых, кода не 50 строк, а всего 39, а во-вторых, и бот не такой сложный, просто эхо-бот. Но, как мне кажется, этого достаточно, чтобы поверить в то, что сделать свой собственный бот-сервис не столь сложно, как может показаться.
import asyncio
import aiohttp
from aiohttp import web
import json
TOKEN = '111111111:AAHKeYAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
API_URL = 'https://api.telegram.org/bot%s/sendMessage' % TOKEN
async def handler(request):
data = await request.json()
headers = {
'Content-Type': 'application/json'
}
message = {
'chat_id': data['message']['chat']['id'],
'text': data['message']['text']
}
async with aiohttp.ClientSession(loop=loop) as session:
async with session.post(API_URL,
data=json.dumps(message),
headers=headers) as resp:
try:
assert resp.status == 200
except:
return web.Response(status=500)
return web.Response(status=200)
async def init_app(loop):
app = web.Application(loop=loop, middlewares=[])
app.router.add_post('/api/v1', handler)
return app
if __name__ == '__main__':
loop = asyncio.get_event_loop()
try:
app = loop.run_until_complete(init_app(loop))
web.run_app(app, host='0.0.0.0', port=23456)
except Exception as e:
print('Error create server: %r' % e)
finally:
pass
loop.close()
Далее, в нескольких словах, что для чего и как сделать лучше из того, что уже есть.
Содержание:
1. Что используем
- во-первых, Python 3.5+. Почему именно 3.5+, потому что asyncio [2] и потому что сахарные async, await etc;
- во-вторых, aiohttp. Так как сервис на вебхуках, то он одновременно и HTTP-сервер и HTTP-клиент, а что для этого использовать, как не aiohttp [3];
- в-третьих, почему webhook, а не long polling? Если не планируется изначально бот-рассыльщик, то интерактивность является его основной функцией. Выскажу своё мнение, что для этой задачи, бот в роли HTTP-сервера подходит лучше, чем в роли клиента. Да, и отдадим часть работы (доставку сообщений) сервисам Telegram.
И ещё, у вас должно быть подконтрольное доменное имя, валидный или самоподписанный сертификат. Доступ к серверу на который указывает доменное имя для настройки реверс-прокси на адрес сервиса.
К содержанию
2. Как используем
Сервер
Состояние библиотеки aiohttp на текущий момент таково, что с её использованием можно построить полноценный web-сервер в Джанго-стиле [4].
Для standalone-сервиса вся мощь не пригодится, поэтому создание сервера ограничивается несколькими строками.
Инициализируем веб-приложение:
async def init_app(loop):
app = web.Application(loop=loop, middlewares=[])
app.router.add_post('/api/v1', handler)
return app
N.B. Обратите внимание, что здесь мы определяем роутинг и задаём обработчик входящих сообщений handler.
И стартуем веб-сервер:
app = loop.run_until_complete(init_app(loop))
web.run_app(app, host='0.0.0.0', port=23456)
Клиент
Для отправки сообщения используем метод sendMessage из Telegram API, для этого необходимо отправить на оформленный должным образом URL POST-запрос с параметрами в виде JSON-объекта. И это мы делаем с помощью aiohttp:
TOKEN = '111111111:AAHKeYAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
API_URL = 'https://api.telegram.org/bot%s/sendMessage' % TOKEN
...
async def handler(request):
data = await request.json()
headers = {
'Content-Type': 'application/json'
}
message = {
'chat_id': data['message']['chat']['id'],
'text': data['message']['text']
}
async with aiohttp.ClientSession(loop=loop) as session:
async with session.post(API_URL,
data=json.dumps(message),
headers=headers) as resp:
try:
assert resp.status == 200
except:
return web.Response(status=500)
return web.Response(status=200)
N.B. Обратите внимание, что в случае успешной обработки входящего сообщения и удачной отправки «эха», обработчик возвращает пустой ответ со статусом HTTP 200. Если этого не сделать, сервисы Telegram продолжат в течение какого-то времени «дёргать» запросами хук, либо пока не получат в ответ 200, либо пока не истечёт определённое для сообщения время.
К содержанию
3. Что можно улучшить
Совершенству нет предела, пара идей, как сделать сервис функциональней.
Используем middleware
Допустим, возникла необходимость фильтровать входящие сообщения. Препроцессинг сообщений можно сделать на специальных веб-обработчиках, в терминах aiohtttp — это middlewares [5].
Пример, определяем мидлварь для игнора сообщений от пользователей из черного списка:
async def middleware_factory(app, handler):
async def middleware_handler(request):
data = await request.json()
if data['message']['from']['id'] in black_list:
return web.Response(status=200)
return await handler(request)
return middleware_handler
И добавляем обработчик при инициализации web-приложения:
async def init_app(loop):
app = web.Application(loop=loop, middlewares=[])
app.router.add_post('/api/v1', handler)
app.middlewares.append(middleware_factory)
return app
Мысли по поводу обработки входящих сообщений
Если бот будет сложнее, чем репитер-попугай, то можно предложить следующую иерархию объектов Api > Conversation > CustomConversation.
Псевдокод:
class Api(object):
URL = 'https://api.telegram.org/bot%s/%s'
def __init__(self, token, loop):
self._token = token
self._loop = loop
async def _request(self, method, message):
headers = {
'Content-Type': 'application/json'
}
async with aiohttp.ClientSession(loop=self._loop) as session:
async with session.post(self.URL % (self._token, method),
data=json.dumps(message),
headers=headers) as resp:
try:
assert resp.status == 200
except:
pass
async def sendMessage(self, chatId, text):
message = {
'chat_id': chatId,
'text': text
}
await self._request('sendMessage', message)
class Conversation(Api):
def __init__(self, token, loop):
super().__init__(token, loop)
async def _handler(self, message):
pass
async def handler(self, request):
message = await request.json()
asyncio.ensure_future(self._handler(message['message']))
return aiohttp.web.Response(status=200)
class EchoConversation(Conversation):
def __init__(self, token, loop):
super().__init__(token, loop)
async def _handler(self, message):
await self.sendMessage(message['chat']['id'],
message['text'])
Наследуя от Conversation и переопределяя _handler получаем кастомные обработчики, в зависимости от функциональности бота — погодный, финансовый etc.
И наш сервис превращается в ферму:
echobot = EchoConversation(TOKEN1, loop)
weatherbot = WeatherConversation(TOKEN2, loop)
finbot = FinanceConversation(TOKEN3, loop)
...
app.router.add_post('/api/v1/echo', echobot.handler)
app.router.add_post('/api/v1/weather', weatherbot.handler)
app.router.add_post('/api/v1/finance', finbot.handler)
К содержанию
4. Реальный мир
Регистрация webhook
Создаём data.json:
{
"url": "https://bots.domain.tld/api/v1/echo"
}
И вызываем соответствующий метод API любым доступным способом, например:
curl -X POST -d @data.json -H "Content-Type: application/json" "https://api.telegram.org/botYOURBOTTOKEN/setWebhook"
N.B. Ваш домен, хук на который вы устанавливаете, должен резолвится, иначе метод setWebhook не отработает.
Используем прокси-сервер
Как говорит документация: ports currently supported for Webhooks: 443, 80, 88, 8443.
Как же быть в случае self-hosted, когда необходимые порты уже скорее всего заняты веб-сервером, да и соединение по HTTPS мы в нашем сервисе не настроили?
Ответ простой, запуск сервиса на любом доступном локальном интерфейсе и использование реверс-прокси, и лучше nginx здесь сложно найти что-то другое, пусть он возьмёт на себя задачу организации HTTPS-соединения и переадресацию запросов нашему сервису.
К содержанию
Заключение
Надеюсь, что работа с ботом через вебхуки не показалась сильно сложнее long polling, как по мне так даже проще, гибче и прозрачнее. Дополнительные расходы на организацию сервера не должны пугать настоящего ботовода.
Пусть ваши идеи находят достойный инструмент для реализации.
Полезное:
Комментарии (14)
funnybanana
18.02.2017 12:49+1Мой пример на php (чтобы разобраться в апи телеграмм потратил 5 минут):
$api = "https://api.telegram.org/bot111111111:AAHKeYAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; // получаем данные $update = json_decode(file_get_contents("php://input"), TRUE); $message = $update["message"]; // все команды $commands = array( "всем привет" => "Дратути", "кто самый" => "Без сомнения {$message['from']['first_name']} {$message['from']['last_name']}", ); // не учитываем регистр $text_lower = mb_convert_case($message["text"], MB_CASE_LOWER); // искать команду будем по первым 200 символам. if (strlen($text_lower) > 200) $text_lower = substr($text_lower, 0, 200); // крутим текст сообщения от конца до 1го символа в поисках существующего ключа в $commands for ($i=0; $i < strlen($text_lower); $i++) { $text_lower_new = substr($text_lower, 0, strlen($text_lower)-$i); if (isset($commands[$text_lower_new])) { $text_lower = $text_lower_new; break; } } // сам ответ if (isset($commands[$text_lower])) $answer = $commands[$text_lower]; // отправляем ответ в чат if (!empty($answer)) file_get_contents("{$api}/sendmessage?chat_id={$message["chat"]["id"]}&text={$answer}");
— при желании этот код можно сократить до 5 строк, но тогда его будет совсем сложно читать, поэтому не пойму тех кто хвастается кол-вом строк в коде.
На основе этого кода собрал бота который сидит у меня в рабочем чате и делает заметки, посмотреть на его работу можно добавив его: @jReminderBotGrogina
19.02.2017 00:07+1все ещё пытаюсь понять логику перебора строки посимвольно, да еще и с конца, если
а) одна из команд «здравствуйте» и базируясь на общепринятых правилах можно ожидать ее в начале строки, а не перебирать 200 символов.
б) существует strpos($text, $command)funnybanana
19.02.2017 08:52ну тут просто не очевидно на примере для чего это…
ну вот в @jReminderBot я пишу "/save очень длинный текст" и вместо того что бы поставить условие:
if (strlen($message['text']) >= 5 && substr($message['text'], 0, 5) == '/save') { // выполняем запись текста после "/save" в бд }
— я ищу этот ключ в массиве перебирая строку с конца до начала:
/save очень длинный текст
/save очень длинный текс
/save очень длинный тек
/save очень длинный те
…
/save оч
/save о
/save
/save — бинго! этот ключ есть и дальше идёт перебрасывание на функцию.
Таким способом после самой команды может быть любой длинны текст… Тот же FatherBot в телеграмм запрашивает параметр для команды отдельно — пишешь /setname, после он просит придумать новое имя и отправить следующим сообщением, в моём примере это бы выглядело следующим образом: "/setname Новое имя"
При этом имя функции я так же вписываю в сам массив, примерно так это выглядит:
$cmd = new Commands($message); // тут все функции $commands = array( "всем привет" => "Дратути", "/save" => "fun_saveText" ); // проверки тут разные, в цикле перебор строки и т.д всё копипастить не буду.. в предыдущем комментарии это есть. if (strlen($commands[$text_lower]) > 3 && substr($commands[$text_lower], 0, 4) == 'fun_') { $fun_name = substr($commands[$text_lower], 4); if (method_exists($cmd, $fun_name)) { $answer = call_user_func(array($cmd, $fun_name)); }else{ $answer = "Функция {$fun_name} не найдена =("; } }else{ $answer = $commands[$text_lower]; }
ну и согласитесь писать ключи в массиве гораздо удобнее и компактнее нежели всегда писать условие под каждую команду… Видимо нужно было сразу написать что не ставил цели писать обычный ехо-бот… Ну а сам массив у меня уже перекочевал в json формат, вынесенный отдельно в файл и там уже более 40 функций по мониторингу основного сайта и ещё пару шуточных функций и ответов…
Я даже думал на хабре пост написать как на php этот бот пишется закрытыми глазами, но такие статьи уже не встречают восторгом тут…Grogina
19.02.2017 10:19+1коллега, давайте еще раз,
— в тексте "/save очень длинный текст" команда идет первой,
— strpos() по религиозным причинам отпадает,
но что мешает начать перебор не с конца Войны и Мир, а с начала строки? каким образом на это влияет хранение команд в массиве?funnybanana
19.02.2017 15:37прошу простить, я с утра не совсем уловил суть вашего вопроса…
что же, действительно тут вы правы:
for ($i=1; $i < strlen($text_lower); $i++) { $text_lower_new = substr($text_lower, 0, $i); if (isset($commands[$text_lower_new])) { $text_lower = $text_lower_new; break; } }
— так мы из "/save очень длинный текст" будем двигаться от 1го символа до последнего (предварительно ограничив длину до 200 символов)
/
/s
/sa
/sav
/save — бинго, ключ найден. И вместо того что бы проверять ключ 21 раз, мы проверили его 5 раз… Что в 4 раза быстрее на данном примере.
Но если целиться на расширение команд на будущее то может произойти так что команда будет содержать в себе первым словом другую команду, на примере:
статистика — покажет общую статистику
статистика вчера — показывает статистику за вчерашний день
пример конечно с потолка, но если мне нужно будет выполнить команду "статистика вчера" — то перебирая символы с начала строки выполнится команда "статистика", именно поэтому я и решил перебирать символы с конца. Пока у меня в командах нет подобного противоречия, но кто знает… когда их будет за пару сотен, могут появиться.
P.S
if (strpos($text_lower, "/save") == 0) { // действие }
всё же условие, занимает значительно больше места нежели ключ в массиве… А вот то что я вместо strpos использую substr этому нет объяснения, видимо сонный был и затупил…
P.P.S вот за что люблю хабр, что тут люди всегда посоветуют то что лучше, сейчас заменю substr на strpos и временно переключу перебор строки с первого символа, а когда появятся противоречия в командах, верну обратно перебор с конца. Grogina, вам спасибо.webdevium
19.02.2017 16:18Ребята, вы конечно простите, но на дворе 2017 год.
Чем вам регулярные выражения так в душу нагадили, что вы начали перебирать «Войну и мир»?
Я понимаю и знаю, что они медленные, но в данном случае — это просто панацея отхламавелосипедов. Можно будет команды буквально в любую позицию строки писать.funnybanana
20.02.2017 09:39что бы писать команду в любом месте строки можно и strpos или substr_count использовать. И опять же команды все удобно держать в одном месте, отделив его от кода. Поэтому и такое странное решение с ключами массива…
Grogina
19.02.2017 18:22даже если у вас их будет несколько сотен и вы хотите реализовать все в таком миксе текста и команд, ничто не мешает начать, бог с ним, перебор с позиции strlen($command_with_max_length)-1
tmnhy
18.02.2017 15:54поэтому не пойму тех кто хвастается кол-вом строк в коде
Мне странно, что вы где-то увидели хвастовство.
"...50 строк кода" в заголовке — это означает, что кода в статье немного и он не сложный. Не ищите подтекста там, где его нет.
bitver
Да и да…
wirr
Скорее нет, чем да. Статья качественнее написана и есть своя плюшка в виде aiohttp.
tmnhy
Телеграм очень опосредован в статье, скорее повод привлечь внимание тех, кто говорит, что хуки — это сложно и накладно.