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

Внутреннее устройство чата
image
Рис.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 initdb
sudo 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.

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