Данное решение подойдет для небольших проектов, так как возможность параллельно вести диалог с несколькими пользователями реализована с помощью создания нового чат-бота, то есть чем больше ботов будет, тем больше людей смогут с вами связаться в один момент времени.
Рис.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.
Автор: Kamrus