
Рис.1 UML диаграмма последовательностей
Реализация
При реализации было принято решение периодически опрашивать сервер Telegram, чтобы упростить развертывание и облегчить понимание материала, как альтернатива можно использовать WebHook.
Подготовка виртуального окружения
Если у вас не стоит virtualenv, то необходимо его установить:
pip install virtualenvСоздадим виртуальное окружение:
virtualenv --no-site-packages -p python3.4 chatАктивируем его:
source chat/bin/activateУстановим все необходимые библиотеки для работы нашего чата:
pip install tornado==4.4.2 psycopg2==2.7.3 pyTelegramBotAPI==2.2.3Для опроса сервера будем использовать библиотеку для работы с telegram.
Необходимо создать следующую файловую структуру:

Создание бота
Пришло время создать бота, данная реализация рассчитана на несколько ботов, чтобы обеспечить возможность общаться параллельно с несколькими клиентами.
Чтобы зарегистрировать бота, необходимо написать BotFather /newbot и все дальнейшие инструкции вы получите в диалоге с ним. В итоге, после успешной регистрации BotFather вернет вам токен вашего нового бота.
Теперь необходимо получить свой chat_id, чтобы бот знал, кому отправлять сообщения.
Для этого в приложении telegram находим своего бота, начинаем с ним взаимодействие командой /start, пишем ему какое-то сообщение и переходим по ссылке —
https://api.telegram.org/bot<токен_вашего_бота>/getUpdatesВидим примерно следующий ответ —
{"id":555455667,"first_name":"Иван","last_name":"Иванович","username":"kamrus","language_code":"ru-RU"}
id и есть ваш chat_idНастройка postgres
Чтобы обеспечить гибкость в работе чата и возможность его модернизации, необходимо использовать базу данных, я выбрал postgres.
Переключаемся на пользователя postgres:
sudo su - postgresВходим в CLI postgres:
psqlНеобходимо создать новую базу данных в кодировке Unicode;
CREATE DATABASE habr_chat ENCODING 'UNICODE';
Создадим нового пользователя в БД:
CREATE USER habr_user WITH PASSWORD '12345';
И отдадим ему все привилегии на базу:
GRANT ALL PRIVILEGES ON DATABASE habr_chat TO habr_user;
Подключаемся к только что созданной базе:
\c habr_chatСоздадим таблицу для хранения информации ботах, она будет иметь следующую модель:

Рис.2 Физическая модель таблицы chat
CREATE TABLE chat ( id SERIAL NOT NULL PRIMARY KEY, token character varying(300) NOT NULL UNIQUE, ready BOOLEAN NOT NULL DEFAULT True, last_message TEXT, customer_asked BOOLEAN NOT NULL DEFAULT False, remote_ip character varying(100) )
И так же дадим пользователю все привилегии на таблицу:
GRANT ALL PRIVILEGES ON TABLE chat TO habr_user;
Теперь необходимо добавить в нее токены ботов:
INSERT INTO chat (token) VALUES ('your_bot_token');
Выходим из CLI:
\qи меняем пользователя обратно:
exitНаписание кода
Первым делом вынесем настройки для работы чата в отдельный файл.
bot_settings.py
CHAT_ID = Вставить ваш chat_id db = { 'db_name': 'habr_chat', 'user': 'habr_user', 'password': '12345', 'host': '', 'port': '' }
Основные функции будут находиться в файле core.py
from telebot import apihelper from bot_settings import db import psycopg2 import datetime def get_updates(token, conn, cur, offset=None, limit=None, timeout=20): ''' Возвращает сообщение из телеграма ''' json_updates = apihelper.get_updates(token, offset, limit, timeout) try: answer = json_updates[-1]['message']['text'] except IndexError: answer = '' # если не проверять приходило ли сообщение от пользователя, то # функция будет просто возвращать последнее сообщение от менеджера, # которое в свою очередь могло предназначаться предыдущему клиенту if is_customer_asked(conn, cur, token): # необходимо проверять предыдущее сообщение, так как запрос к серверу # повторяется через константное время и клиенту будет отправляться одно и тоже сообщение if not is_last_message(conn, cur, token, answer): # если сообщение прошло обе проверки то обновить это сообщение # в базе данных update_last_message(conn, cur, token, answer) return answer else: # если пользователь еще ничего не спросил, то необходимо все равно обновить # предыдущее сообщение менеджера, на случай если предыдущии пользовватель отключится, # но менеджер все равно отправит сообщение update_last_message(conn, cur, token, answer) def send_message(token, chat_id, text): '''Отправить сообщение менеджеру в телеграм''' apihelper.send_message(token, chat_id, text) def connect_postgres(**kwargs): try: conn = psycopg2.connect(dbname=db['db_name'], user=db['user'], password=db['password'], host=db['host'], port=db['port']) except Exception as e: print(e, 'Ошибка при подключении к posqgres') raise e cur = conn.cursor() return conn, cur def update_last_message(conn, cur, token, message, **kwargs): ''' Обновляет последнее сообщение, присланное менеджером ''' query = "UPDATE chat SET last_message = %s WHERE token = %s" data = [message, token] try: cur.execute(query, data) conn.commit() except Exception as e: print(e, 'Ошибка при попытке обновить последнее сообщение на %s' %message) raise e def add_remote_ip(conn, cur, token, ip): ''' Функция добавляет ip адрес пользователя ''' query = "UPDATE chat SET remote_ip = %s WHERE token = %s" data = [ip, token] try: cur.execute(query, data) conn.commit() except Exception as e: print(e, 'Ошибка при попытке добавить ip адрес') raise e def delete_remote_ip(conn, cur, token): ''' Удалить ip адрес у бота по переданному токену ''' query = "UPDATE chat SET remote_ip = %s WHERE token = %s" data = ['', token] try: cur.execute(query, data) conn.commit() except Exception as e: print(e, 'Ошибка при попытке удалить ip адрес') raise e def is_last_message(conn, cur, token, message, **kwargs): ''' Проверить является ли переданное сообщение последним сообщением менеджера ''' query = "SELECT last_message FROM chat WHERE token = %s" data = [token, ] try: cur.execute(query, data) last_message = cur.fetchone() if last_message: if last_message[0] == message: return True return False except Exception as e: print(e, 'Ошибка при определении последнего сообщения') raise e def update_customer_asked(conn, cur, token, to_value): ''' Обновить статус ответа клиента ''' query = "UPDATE chat SET customer_asked = %s WHERE token = %s" # to_value = True/False data = [to_value, token] try: cur.execute(query, data) conn.commit() except Exception as e: print(e, 'Ошибка при попытке обновить "customer_asked" на %s' %to_value) raise e def is_customer_asked(conn, cur, token): ''' Если клиент уже написал сообщение, то функция вернет True ''' query = "SELECT customer_asked FROM chat WHERE token = %s" data = [token, ] try: cur.execute(query, data) customer_asked = cur.fetchone() return customer_asked[0] except Exception as e: print(e, "Ошибка при попытке узнать написал ли пользователь сообщение или еще нет") raise e def get_bot(conn, cur): ''' Функция берет из базы свободного бота, у которого ready = True. Возвращает (id, token, ready, last_message, customer_asked) для свободного бота ''' query = "SELECT * FROM chat WHERE ready = True" try: cur.execute(query) bot = cur.fetchone() if bot: return bot else: return None except Exception as e: print(e, "Ошибка при попытке найти свободного бота") raise e def make_bot_busy(conn, cur, token): ''' Меняет значение ready на False, тем самым делая бота занятым ''' query = "UPDATE chat SET ready = False WHERE token = %s" data = [token,] try: cur.execute(query, data) conn.commit() except Exception as e: print(e, 'Ошибка при попытке изменить значение "ready" на False') raise e def make_bot_free(conn, cur, token): ''' Меняет значение ready на False, тем самым делая бота свободным ''' update_customer_asked(conn, cur, token, False) delete_remote_ip(conn, cur, token) query = "UPDATE chat SET ready = True WHERE token = %s" data = [token,] try: cur.execute(query, data) conn.commit() except Exception as e: print(e, 'Ошибка при попытке изменить значение "ready" на True') raise e
tornadino.py
import tornado.ioloop import tornado.web import tornado.websocket import core from bot_settings import CHAT_ID import datetime class WSHandler(tornado.websocket.WebSocketHandler): def __init__(self, application, request, **kwargs): super(WSHandler, self).__init__(application, request, **kwargs) # При создании нового подключения с пользователем подключимся к postgres self.conn, self.cur = core.connect_postgres() self.get_bot(self.conn, self.cur, request.remote_ip) def get_bot(self, conn, cur, ip): while True: bot = core.get_bot(conn, cur) if bot: self.bot_token = bot[1] self.customer_asked = bot[4] # занять бота core.make_bot_busy(self.conn, self.cur, self.bot_token) # добавить боту ip адрес core.add_remote_ip(self.conn, self.cur, self.bot_token, ip) break def check_origin(self, origin): ''' Дает возможность подключаться с различных адресов ''' return True def bot_callback(self): ''' Функция вызывается PeriodicCallback и проверяет сервер Telegram на наличие новых сообщений от менеджера ''' ans_telegram = core.get_updates(self.bot_token, self.conn, self.cur) if ans_telegram: # если пришло сообщение от менеджера, то отправить его в браузер клиенту self.write_message(ans_telegram) def open(self): ''' Функция вызываемая при открытии сокета с клиентом ''' # Запускает опрос сервера Telegram каждые 3сек self.telegram_loop = tornado.ioloop.PeriodicCallback(self.bot_callback, 3000) self.telegram_loop.start() def on_message(self, message): ''' Функция вызываемая, когда по сокету приходит сообщение ''' if not self.customer_asked: self.customer_asked = True # обновить значение в бд, что клиент задал вопрос core.update_customer_asked(self.conn, self.cur, self.bot_token, True) core.send_message(self.bot_token, CHAT_ID, message) def on_close(self): ''' Функция вызываемая при закрытии соединения ''' core.send_message(self.bot_token, CHAT_ID, "Пользователь закрыл чат") # остановить PeriodicCallback self.telegram_loop.stop() # освободить бота core.make_bot_free(self.conn, self.cur, self.bot_token) # WebSocket будет доступен по адресу ws://127.0.0.1:8080/ws application = tornado.web.Application([ (r'/ws', WSHandler), ]) if __name__ == "__main__": application.listen(8080) tornado.ioloop.IOLoop.current().start()
Теперь создадим статические файл:
chat.html
<div class="chatbox chatbox-down chatbox--empty"> <div class="chatbox__title"> <h5><a href="#">Tornado-Telegram-chat</a></h5> <button class="chatbox__title__close"> <span> <svg viewBox="0 0 12 12" width="12px" height="12px"> <line stroke="#FFFFFF" x1="11.75" y1="0.25" x2="0.25" y2="11.75"></line> <line stroke="#FFFFFF" x1="11.75" y1="11.75" x2="0.25" y2="0.25"></line> </svg> </span> </button> </div> <div id="messages__box" class="chatbox__body"> <!-- сюда будут добавляться сообщения от клиента и менеджера --> </div> <button id="start-ws" type="button" class="btn btn-success btn-block">Начать чат</button> <form> <textarea id="message" class="chatbox__message" placeholder="Ваше сообщение..."></textarea> <input id="sendmessage" type="hidden"> </form> </div>
chat.css
.chatbox { position: fixed; bottom: 0; right: 30px; height: 400px; background-color: #fff; font-family: Arial, sans-serif; -webkit-transition: all 600ms cubic-bezier(0.19, 1, 0.22, 1); transition: all 600ms cubic-bezier(0.19, 1, 0.22, 1); display: -webkit-flex; display: flex; -webkit-flex-direction: column; flex-direction: column; } .chatbox-down { bottom: -350px; } .chatbox--closed { bottom: -400px; } .chatbox .form-control:focus { border-color: #1f2836; } .chatbox__title, .chatbox__body { border-bottom: none; } .chatbox__title { min-height: 50px; padding-right: 10px; background-color: #1f2836; border-top-left-radius: 4px; border-top-right-radius: 4px; cursor: pointer; display: -webkit-flex; display: flex; -webkit-align-items: center; align-items: center; } .chatbox__title h5 { height: 50px; margin: 0 0 0 15px; line-height: 50px; position: relative; padding-left: 20px; -webkit-flex-grow: 1; flex-grow: 1; } .chatbox__title h5 a { color: #fff; max-width: 195px; display: inline-block; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .chatbox__title h5:before { content: ''; display: block; position: absolute; top: 50%; left: 0; width: 12px; height: 12px; background: #4CAF50; border-radius: 6px; -webkit-transform: translateY(-50%); transform: translateY(-50%); } .chatbox__title__tray, .chatbox__title__close { width: 24px; height: 24px; outline: 0; border: none; background-color: transparent; opacity: 0.5; cursor: pointer; -webkit-transition: opacity 200ms; transition: opacity 200ms; } .chatbox__title__tray:hover, .chatbox__title__close:hover { opacity: 1; } .chatbox__title__tray span { width: 12px; height: 12px; display: inline-block; border-bottom: 2px solid #fff } .chatbox__title__close svg { vertical-align: middle; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.2px; } .chatbox__body, .chatbox__credentials { padding: 15px; border-top: 0; background-color: #f5f5f5; border-left: 1px solid #ddd; border-right: 1px solid #ddd; -webkit-flex-grow: 1; flex-grow: 1; } .chatbox__credentials { display: none; } .chatbox__credentials .form-control { -webkit-box-shadow: none; box-shadow: none; } .chatbox__body { overflow-y: auto; } .chatbox__body__message { position: relative; } .chatbox__body__message p { padding: 15px; border-radius: 4px; font-size: 14px; background-color: #fff; -webkit-box-shadow: 1px 1px rgba(100, 100, 100, 0.1); box-shadow: 1px 1px rgba(100, 100, 100, 0.1); } .chatbox__body__message img { width: 40px; height: 40px; border-radius: 4px; border: 2px solid #fcfcfc; position: absolute; top: 15px; } .chatbox__body__message--left p { margin-left: 15px; padding-left: 30px; text-align: left; } .chatbox__body__message--left img { left: -5px; } .chatbox__body__message--right p { margin-right: 15px; padding-right: 30px; text-align: right; } .chatbox__body__message--right img { right: -5px; } .chatbox__message { padding: 15px; min-height: 50px; outline: 0; resize: none; border: none; font-size: 12px; border: 1px solid #ddd; border-bottom: none; background-color: #fefefe; width: 100%; } .chatbox--empty { height: 262px; } .chatbox--empty.chatbox-down { bottom: -212px; } .chatbox--empty.chatbox--closed { bottom: -262px; } .chatbox--empty .chatbox__body, .chatbox--empty .chatbox__message { display: none; } .chatbox--empty .chatbox__credentials { display: block; } .description { font-family: Arial, sans-serif; font-size: 12px; } #start-ws { margin-top: 30px; } .no-visible { display: none; }
Перед написанием javascript файла необходимо определиться, как будет выглядеть код для сообщении от клиента и от менеджера.
Html код для сообщении от клиента:
<div class="chatbox__body__message chatbox__body__message--right"> <img src="../static/user.png" alt=""> <p></p> </div>
Html код для сообщении от менеджера:
<div class="chatbox__body__message chatbox__body__message--right"> <img src="../static/user.png" alt=""> <p></p> </div>
chat.js
(function($) { $(document).ready(function() { var $chatbox = $('.chatbox'), $chatboxTitle = $('.chatbox__title'), $chatboxTitleClose = $('.chatbox__title__close'), $chatboxWs = $('#start-ws'); // Свернуть чат при нажатии на заголовок и наоборот $chatboxTitle.on('click', function() { $chatbox.toggleClass('chatbox-down'); }); // Закрыть чат $chatboxTitleClose.on('click', function(e) { e.stopPropagation(); $chatbox.addClass('chatbox--closed'); // Если на момент закрытия был открыт сокет, то // следует закрыть его if (window.sock) { window.sock.close(); } }); // Подключиться к сокету $chatboxWs.on('click', function(e) { e.preventDefault(); // сделать диалог видимым $chatbox.removeClass('chatbox--empty'); // сделать кнопку начала чата невидимой $chatboxWs.addClass('no-visible'); if (!("WebSocket" in window)) { alert("Ваш браузер не поддерживает web sockets"); } else { alert("Начало соединения"); setup(); } }); }); })(jQuery); // Функция создания соединения по WebSocket function setup(){ var host = "ws://62.109.2.175:8084/ws"; var socket = new WebSocket(host); window.sock = socket; var $txt = $("#message"); var $btnSend = $("#sendmessage"); // Отслеживать изменения в textarea $txt.focus(); $btnSend.on('click',function(){ var text = $txt.val(); if(text == ""){return} // отправить сообщение по сокету socket.send(text); // отобразить в дилоге сообщение clientRequest(text); $txt.val(""); // $('#send') }); // отслеживать нажатие enter $txt.keypress(function(evt){ // если был нажат enter if(evt.which == 13){ $btnSend.click(); } }); if(socket){ // действие на момент открытия сокета socket.onopen = function(){ } // действие на момент получения сообщения по сокету socket.onmessage = function(msg){ // отобразить сообщение в диалоге managerResponse(msg.data); } // действия на момент закрытия сокета socket.onclose = function(){ webSocketClose("The connection has been closed."); window.sock = false; } }else{ console.log("invalid socket"); } } function webSocketClose(txt){ var p = document.createElement('p'); p.innerHTML = txt; document.getElementById('messages__box').appendChild(p); } //функция для ответов клиента function clientRequest(txt) { $("#messages__box").append("<div class='chatbox__body__message chatbox__body__message--right'> <img src='../static/user.png' alt=''> <p>" + txt + "</p> </div>"); } // Функция для ответов менеджера function managerResponse(txt) { $("#messages__box").append("<div class='chatbox__body__message chatbox__body__message--left'> <img src='../static/user.png' alt=''> <p>" + txt + "</p> </div>"); }
Развертывание на centos7
Для начала необходимо настроить виртуальное окружение для нашего приложения, собственно, повторить то, что мы уже делали на локальной машине в пункте реализация.
После того, как мы настроили окружение, нужно перенести туда наш проект, проще всего это сделать, используя git, предварительно необходимо загрузить код в свой репозиторий и оттуда уже клонировать его на сервер.
Настраиваем postgres
Если у вас на сервере не установлен postgres, то установить его можно так:
sudo yum install postgresql-server postgresql-devel postgresql-contrib Запускаем postgres:
sudo postgresql-setup initdbsudo systemctl start postgresqlДобавляем автозапуск:
sudo systemctl enable postgresqlПосле чего необходимо перейти в psql под пользователем postgres и повторить все, что мы делали на локальной машине.
Будем запускать наше tornado приложение с помощью supervisor в фоне.
Для начала установим supervisor:
sudo yum install supervisorТеперь откроем конфигурационный файл супервизора, который будет находится в /etc/supervisor.conf
[unix_http_server]
file=/path/to/supervisor.sock ; (the path to the socket file)
[supervisord]
logfile=/var/log/supervisor/supervisord.log ; (main log file;default $CWD/supervisord.log)
logfile_maxbytes=50MB ; (max main logfile bytes b4 rotation;default 50MB)
logfile_backups=10 ; (num of main logfile rotation backups;default 10)
loglevel=error ; (log level;default info; others: debug,warn,trace)
pidfile=/path/to/supervisord.pid ; (supervisord pidfile;default supervisord.pid)
nodaemon=false ; (start in foreground if true;default false)
minfds=1024 ; (min. avail startup file descriptors;default 1024)
minprocs=200 ; (min. avail process descriptors;default 200)
user=root
childlogdir=/var/log/supervisord/ ; ('AUTO' child log dir, default $TEMP)
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///path/to/supervisor.sock ; use a unix:// URL for a unix socket
[program:tornado-8004]
environment=PATH="/path/to/chat/bin"
command=/path/to/chat/bin/python3.4 /path/to/tornadino.py --port=8084
stopsignal=KILL
stderr_logfile=/var/log/supervisord/tornado-stderr.log
stdout_logfile=/var/log/supervisord/tornado-stdout.log
[include]
files = supervisord.d/*.iniНе забудьте поменять пути в конфигурационном файле!
Перед тем, как запускать supervisor, необходимо создать папку /var/log/supervisord/ в ней будут собираться логи торнадо, так что, если supervisor запустил tornado-8004, но чат не работает, то ошибку стоит искать там.
Запускаем супервизор:
sudo supervisorctl start tornado-8004Проверяем, что все в порядке:
sudo supervisorctl statusДолжны получить что-то подобное:
tornado-8004 RUNNING pid 32139, uptime 0:08:10На локальной машине вносим изменения в chat.js:
var host = "ws://адресс_вашего_сервера:8084/ws";и открываем в браузере chat.html.
Готово!
Можно без особых телодвижений прикручивать такой чат к своим проектам, так же достаточно удобно использовать для сбора feedback.

