Сегодня мы создадим бота для Facebook Messenger, который будет присылать нам свежие мемы, мотивационные сообщения и шутки. В этой статье есть большая часть информации, которую нужно знать для создания своего бота.
Вот так будет выглядеть финальная версия нашего приложения:
Технический стек
Нам понадобятся:
- Flask — фреймворк для разработки бэкенда. Поскольку он легкий, это позволит нам сосредоточиться на логике, а не на структуре папок;
- Heroku — хостинг для бесплатного размещения нашего кода;
- Reddit — как источник данных, потому что новые сообщения там появляются каждую минуту.
Создание приложения Reddit
Мы будем использовать Facebook, Heroku и Reddit. Во-первых, убедитесь, что у вас есть учетная запись в каждом из этих сервисов. Затем нужно создать приложение Reddit:
Нажмите кнопку «create an app…» и следуйте инструкциям на экране:
Два нижних поля не будут использоваться, поэтому оставьте их пустыми. В поле с описанием приложения лучше написать что-то связанное с проектом. Когда приложение начнёт делать много запросов, представители Reddit могут проверить, для чего оно.
Теперь, когда ваше приложение создано, вам нужно сохранить client_id
и client_secret
в безопасном месте:
Первая часть нашего проекта выполнена. Теперь нам нужно настроить базу для нашего приложения Heroku.
Создание приложения на Heroku
Перейдите на сайт Heroku и создайте новое приложение:
На следующей странице дайте своему приложению уникальное имя. Нажмите «Heroku CLI» и загрузите последнюю версию интерфейса командной строки Heroku для вашей операционной системы. Следуйте инструкциям на экране и вернитесь, как только интерфейс будет загружен.
Создание базового приложения Python
Приведенный ниже код взят с сайта Константиноса Цапраилиса.
from flask import Flask, request
import json
import requests
app = Flask(__name__)
# This needs to be filled with the Page Access Token that will be provided
# by the Facebook App that will be created.
PAT = ''
@app.route('/', methods=['GET'])
def handle_verification():
print "Handling Verification."
if request.args.get('hub.verify_token', '') == 'my_voice_is_my_password_verify_me':
print "Verification successful!"
return request.args.get('hub.challenge', '')
else:
print "Verification failed!"
return 'Error, wrong validation token'
@app.route('/', methods=['POST'])
def handle_messages():
print "Handling Messages"
payload = request.get_data()
print payload
for sender, message in messaging_events(payload):
print "Incoming from %s: %s" % (sender, message)
send_message(PAT, sender, message)
return "ok"
def messaging_events(payload):
"""Generate tuples of (sender_id, message_text) from the
provided payload.
"""
data = json.loads(payload)
messaging_events = data["entry"][0]["messaging"]
for event in messaging_events:
if "message" in event and "text" in event["message"]:
yield event["sender"]["id"], event["message"]["text"].encode('unicode_escape')
else:
yield event["sender"]["id"], "I can't echo this"
def send_message(token, recipient, text):
"""Send the message text to recipient with id recipient.
"""
r = requests.post("https://graph.facebook.com/v2.6/me/messages",
params={"access_token": token},
data=json.dumps({
"recipient": {"id": recipient},
"message": {"text": text.decode('unicode_escape')}
}),
headers={'Content-type': 'application/json'})
if r.status_code != requests.codes.ok:
print r.text
if __name__ == '__main__':
app.run()
Мы будем редактировать файл в соответствии с нашими потребностями. Бот Facebook будет работать следующим образом:
- Facebook отправляет запрос на наш сервер всякий раз, когда пользователь пишет сообщение на нашей странице Facebook.
- Мы отвечаем на запрос Facebook и сохраняем идентификатор пользователя и сообщение, которое было отправлено на нашу страницу.
- Мы отвечаем на сообщение пользователя через Graph API, используя сохранённый идентификатор пользователя и идентификатор сообщения.
Подробный разбор приведенного выше кода доступен на веб-сайте Константиноса Цапраилиса. В этом посте я сосредоточусь главным образом на интеграции Reddit и использовании базы данных Postgres на Heroku.
Прежде чем двигаться дальше, разместим вышеприведённый код Python на Heroku. Для этого вам нужно создать локальный репозиторий Git. Выполните следующие шаги:
$ messenger-bot
$ cd messenger-bot
$ touch requirements.txt app.py Procfile
Выполните вышеприведенные команды в терминале и поместите вышеуказанный код Python в файл app.py
. Потом поместите в Procfile
следующее:
web: gunicorn app:app
Теперь мы должны сообщить Heroku, какие библиотеки Python будет использовать наше приложение. Эти библиотеки должны быть перечислены в файле requirements.txt
:
click==6.6
Flask==0.11
gunicorn==19.6.0
itsdangerous==0.24
Jinja2==2.8
MarkupSafe==0.23
requests==2.10.0
Werkzeug==0.11.10
Выполните следующую команду в терминале. Вы получите такой результат:
$ ls
Procfile app.py requirements.txt
Теперь мы создадим Git-репозиторий, который затем может быть помещен на серверы Heroku. Для его создания мы должны выполнить следующие шаги:
- Войти в Heroku.
- Создать новый репозиторий.
- Сохранить состояние проекта в репозиторий.
- Отправить его на Heroku.
Для этого введите следующие команды в терминал:
$ heroku login
$ git init
$ heroku git:remote -a
$ git commit -am "Initial commit"
$ git push heroku master
...
remote: https://.herokuapp.com/ deployed to Heroku
...
$ heroku config:set WEB_CONCURRENCY=3
Сохраните адрес из строки «remote: …» — это адрес вашего Heroku-приложения. Он понадобится нам на следующем этапе.
Создание приложения Facebook
Для создания приложения нам нужна его страница на Facebook. Таково требование Facebook — у каждого приложения должна быть страница.
Теперь нам нужно зарегистрировать новое приложение. Перейдите на страницу создания приложения и следуйте инструкциям ниже:
Затем перейдите в свой файл app.py
и замените PAT в строке 9 на Page Access Token, который мы сохранили выше.
Cохраните все и отправьте код в Heroku.
$ git commit -am "Added in the PAT"
$ git push heroku master
Теперь, если вы перейдете на страницу Facebook и отправите сообщение на эту страницу, вы получите своё собственное сообщение в ответ. Это подтвердит, что мы всё сделали верно. Если сообщение не пришло, проверьте логи Heroku, которые дадут вам некоторое представление об ошибке. Вы можете получить доступ к логам следующим образом:
$ heroku logs -t -a
Примечание: бот будет отвечать только на ваши сообщения, потому что он ещё не одобрен Facebook. Однако вы можете добавить нескольких тестировщиков. Для этого следуйте указаниям на странице разработчика.
Получение данных с Reddit
Мы будем использовать данные из следующих источников:
Установим praw — Python-библиотеку сайта Reddit. Это легко сделать, введя следующую команду в терминале:
$ pip install praw
Теперь протестируем некоторые прелести Reddit в оболочке Python. В документации чётко показано, как получить доступ к Reddit и его сообществам. Сейчас самое подходящее время, чтобы использовать client_id
и client_secret
, которые мы создали в первой части статьи.
$ python
Python 2.7.13 (default, Dec 17 2016, 23:03:43)
[GCC 4.2.1 Compatible Apple LLVM 8.0.0 (clang-800.0.42.1)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import praw
>>> reddit = praw.Reddit(client_id='**********',
... client_secret='*****************',
... user_agent='my user agent')
>>>
>>> submissions = list(reddit.subreddit("GetMotivated").hot(limit=None))
>>> submissions[-4].title
u'[Video] Hi, Stranger.'
Не забудьте добавить в свой собственный client_id
и client_secret
вместо ****
.
В приложении используется limit = None
. Это позволит получать как можно больше сообщений. Изображения мы будем использовать только из GetMotivated и Memes, а текстовые сообщения только из Jokes и ShowerThoughts.
Теперь, когда мы знаем, как получить доступ к Reddit, используя библиотеку Python, мы можем продолжить и интегрировать его в app.py
.
Добавим несколько дополнительных библиотек в наш файл requirements.txt
, чтобы он выглядел примерно так:
$ cat requirements.txt
click==6.6
Flask==0.11
gunicorn==19.6.0
itsdangerous==0.24
Jinja2==2.8
MarkupSafe==0.23
requests==2.10.0
Werkzeug==0.11.10
flask-sqlalchemy
psycopg2
praw
Просто отправить пользователю изображение или текст, взятый из Reddit, было бы нетрудно. В функции send_message
мы могли бы сделать что-то вроде этого:
import praw
...
def send_message(token, recipient, text):
"""Send the message text to recipient with id recipient.
"""
if "meme" in text.lower():
subreddit_name = "memes"
elif "shower" in text.lower():
subreddit_name = "Showerthoughts"
elif "joke" in text.lower():
subreddit_name = "Jokes"
else:
subreddit_name = "GetMotivated"
....
if subreddit_name == "Showerthoughts":
for submission in reddit.subreddit(subreddit_name).hot(limit=None):
payload = submission.url
break
...
r = requests.post("https://graph.facebook.com/v2.6/me/messages",
params={"access_token": token},
data=json.dumps({
"recipient": {"id": recipient},
"message": {"attachment": {
"type": "image",
"payload": {
"url": payload
}}
}),
headers={'Content-type': 'application/json'})
...
Но нам нужен идентификатор для каждого изображения или текста, отправляемого пользователю, чтобы не отправлять одну и ту же запись дважды. Чтобы решить эту проблему, мы будем использовать PostgresSQL и идентификаторы постов Reddit (у каждого поста на Reddit есть уникальный id).
Мы будем использовать отношение «многие-ко-многим». Создадим две таблицы:
- пользователи;
- посты.
Сначала определим их в нашем коде, а затем уже разберёмся, как это будет работать:
from flask_sqlalchemy import SQLAlchemy
...
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ['DATABASE_URL']
db = SQLAlchemy(app)
...
relationship_table=db.Table('relationship_table
db.Column('user_id', db.Integer,db.ForeignKey('users.id'), nullable=False),
db.Column('post_id',db.Integer,db.ForeignKey('posts.id'),nullable=False db.PrimaryKeyConstraint('user_id', 'post_id') )
class Users(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255),nullable=False)
posts=db.relationship('Posts', secondary=relationship_table, backref='users' )
def __init__(self, name):
self.name = name
class Posts(db.Model):
id=db.Column(db.Integer, primary_key=True)
name=db.Column(db.String, unique=True, nullable=False)
url=db.Column(db.String, nullable=False)
Таким образом, в таблице будет два поля. Имя будет идентификатором, отправленным с запросом Facebook Messenger Webhook. Посты будут связаны с другой таблицей — «Посты». В таблице сообщений есть имя и URL-адрес. «Имя» будет заполнено идентификатором поста Reddit, а URL — адресом этого поста.
Итак, теперь наш окончательный код будет работать так:
- Мы запрашиваем список сообщений из определенного сообщества Reddit:
reddit.subreddit(subreddit_name).hot(limit=None)
- Проверяем, был ли конкретный пост отправлен пользователю ранее.
- Если сообщение уже было отправлено, мы будем продолжать запрашивать посты от Reddit, пока не найдем новую запись.
- Если сообщение не было отправлено пользователю, мы отправляем сообщение и выходим из цикла.
Итоговый код app.py
выглядит так:
from flask import Flask, request
import json
import requests
from flask_sqlalchemy import SQLAlchemy
import os
import praw
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ['DATABASE_URL']
db = SQLAlchemy(app)
reddit = praw.Reddit(client_id='*************',
client_secret='****************',
user_agent='my user agent')
# This needs to be filled with the Page Access Token that will be provided
# by the Facebook App that will be created.
PAT = '*********************************************'
quick_replies_list = [{
"content_type":"text",
"title":"Meme",
"payload":"meme",
},
{
"content_type":"text",
"title":"Motivation",
"payload":"motivation",
},
{
"content_type":"text",
"title":"Shower Thought",
"payload":"Shower_Thought",
},
{
"content_type":"text",
"title":"Jokes",
"payload":"Jokes",
}
]
@app.route('/', methods=['GET'])
def handle_verification():
print "Handling Verification."
if request.args.get('hub.verify_token', '') == 'my_voice_is_my_password_verify_me':
print "Verification successful!"
return request.args.get('hub.challenge', '')
else:
print "Verification failed!"
return 'Error, wrong validation token'
@app.route('/', methods=['POST'])
def handle_messages():
print "Handling Messages"
payload = request.get_data()
print payload
for sender, message in messaging_events(payload):
print "Incoming from %s: %s" % (sender, message)
send_message(PAT, sender, message)
return "ok"
def messaging_events(payload):
"""Generate tuples of (sender_id, message_text) from the
provided payload.
"""
data = json.loads(payload)
messaging_events = data["entry"][0]["messaging"]
for event in messaging_events:
if "message" in event and "text" in event["message"]:
yield event["sender"]["id"], event["message"]["text"].encode('unicode_escape')
else:
yield event["sender"]["id"], "I can't echo this"
def send_message(token, recipient, text):
"""Send the message text to recipient with id recipient.
"""
if "meme" in text.lower():
subreddit_name = "memes"
elif "shower" in text.lower():
subreddit_name = "Showerthoughts"
elif "joke" in text.lower():
subreddit_name = "Jokes"
else:
subreddit_name = "GetMotivated"
myUser = get_or_create(db.session, Users, name=recipient)
if subreddit_name == "Showerthoughts":
for submission in reddit.subreddit(subreddit_name).hot(limit=None):
if (submission.is_self == True):
query_result = Posts.query.filter(Posts.name == submission.id).first()
if query_result is None:
myPost = Posts(submission.id, submission.title)
myUser.posts.append(myPost)
db.session.commit()
payload = submission.title
break
elif myUser not in query_result.users:
myUser.posts.append(query_result)
db.session.commit()
payload = submission.title
break
else:
continue
r = requests.post("https://graph.facebook.com/v2.6/me/messages",
params={"access_token": token},
data=json.dumps({
"recipient": {"id": recipient},
"message": {"text": payload,
"quick_replies":quick_replies_list}
}),
headers={'Content-type': 'application/json'})
elif subreddit_name == "Jokes":
for submission in reddit.subreddit(subreddit_name).hot(limit=None):
if ((submission.is_self == True) and ( submission.link_flair_text is None)):
query_result = Posts.query.filter(Posts.name == submission.id).first()
if query_result is None:
myPost = Posts(submission.id, submission.title)
myUser.posts.append(myPost)
db.session.commit()
payload = submission.title
payload_text = submission.selftext
break
elif myUser not in query_result.users:
myUser.posts.append(query_result)
db.session.commit()
payload = submission.title
payload_text = submission.selftext
break
else:
continue
r = requests.post("https://graph.facebook.com/v2.6/me/messages",
params={"access_token": token},
data=json.dumps({
"recipient": {"id": recipient},
"message": {"text": payload}
}),
headers={'Content-type': 'application/json'})
r = requests.post("https://graph.facebook.com/v2.6/me/messages",
params={"access_token": token},
data=json.dumps({
"recipient": {"id": recipient},
"message": {"text": payload_text,
"quick_replies":quick_replies_list}
}),
headers={'Content-type': 'application/json'})
else:
payload = "http://imgur.com/WeyNGtQ.jpg"
for submission in reddit.subreddit(subreddit_name).hot(limit=None):
if (submission.link_flair_css_class == 'image') or ((submission.is_self != True) and ((".jpg" in submission.url) or (".png" in submission.url))):
query_result = Posts.query.filter(Posts.name == submission.id).first()
if query_result is None:
myPost = Posts(submission.id, submission.url)
myUser.posts.append(myPost)
db.session.commit()
payload = submission.url
break
elif myUser not in query_result.users:
myUser.posts.append(query_result)
db.session.commit()
payload = submission.url
break
else:
continue
r = requests.post("https://graph.facebook.com/v2.6/me/messages",
params={"access_token": token},
data=json.dumps({
"recipient": {"id": recipient},
"message": {"attachment": {
"type": "image",
"payload": {
"url": payload
}},
"quick_replies":quick_replies_list}
}),
headers={'Content-type': 'application/json'})
if r.status_code != requests.codes.ok:
print r.text
def get_or_create(session, model, **kwargs):
instance = session.query(model).filter_by(**kwargs).first()
if instance:
return instance
else:
instance = model(**kwargs)
session.add(instance)
session.commit()
return instance
relationship_table=db.Table('relationship_table',
db.Column('user_id', db.Integer,db.ForeignKey('users.id'), nullable=False),
db.Column('post_id',db.Integer,db.ForeignKey('posts.id'),nullable=False),
db.PrimaryKeyConstraint('user_id', 'post_id') )
class Users(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255),nullable=False)
posts=db.relationship('Posts', secondary=relationship_table, backref='users' )
def __init__(self, name=None):
self.name = name
class Posts(db.Model):
id=db.Column(db.Integer, primary_key=True)
name=db.Column(db.String, unique=True, nullable=False)
url=db.Column(db.String, nullable=False)
def __init__(self, name=None, url=None):
self.name = name
self.url = url
if __name__ == '__main__':
app.run()
Отправьте его в Heroku:
$ git commit -am "Updated the code with Reddit feature"
$ git push heroku master
Осталось последнее. Нам нужно сказать Heroku, что мы будем использовать базу данных. Для этого введите в терминале следующую команду:
$ heroku addons:create heroku-postgresql:hobby-dev --app <app_name>
Так мы создадим базу данных, которой достаточно для нашего проекта. Теперь нам нужно инициализировать её правильными таблицами. Для этого мы сначала должны запустить оболочку Python на нашем сервере Heroku:
$ heroku run python
Затем в оболочке Python ввести следующие команды:
>>> from app import db
>>> db.create_all()
Наше приложение готово! Поздравляю!
Некоторые интересные особенности кода
Во-первых, в коде используется функция быстрых ответов интерфейса Facebook Messenger Bot API. Это позволяет нам отправлять некоторые предварительно отформатированные данные, которые пользователь может быстро выбрать. Они будут выглядеть примерно так:
С каждым запросом на публикацию в API Facebook мы отправляем дополнительные данные:
quick_replies_list = [{
"content_type":"text",
"title":"Meme",
"payload":"meme",
},
{
"content_type":"text",
"title":"Motivation",
"payload":"motivation",
},
{
"content_type":"text",
"title":"Shower Thought",
"payload":"Shower_Thought",
},
{
"content_type":"text",
"title":"Jokes",
"payload":"Jokes",
}]
Еще одна интересная особенность кода заключается в том, как мы определяем, является ли сообщение текстом, изображением или видеозаписью. В сообществе GetMotivated некоторые изображения не имеют расширения «.jpg» или «.png» в их URL, поэтому мы полагаемся на:
submission.link_flair_css_class == 'image'
Вы могли заметить эту строку кода в файле app.py:
payload = "http://imgur.com/WeyNGtQ.jpg"
Она гарантирует, что если новые записи не будут найдены для конкретного пользователя (у каждого сообщества есть максимальное количество «горячих» сообщений), приложению будет, что отправить. В противном случае мы получим ошибку.
Следующая функция проверяет, существует ли пользователь с определенным именем или нет. Если он существует, она выбирает этого пользователя из базы данных и возвращает его. Если он не существует, она создает его, а затем возвращает вновь созданного пользователя:
myUser = get_or_create(db.session, Users, name=recipient)
...
def get_or_create(session, model, **kwargs):
instance = session.query(model).filter_by(**kwargs).first()
if instance:
return instance
else:
instance = model(**kwargs)
session.add(instance)
session.commit()
return instance
Исходный код приложения также доступен на Github.
Перевод статьи «Making a Reddit + Facebook Messenger Bot»