Написание бота для мессенджера Tox
На фоне общей увлеченности созданием ботов для Telegram я бы хотел рассказать об API не очень широко известного мессенджера Tox и показать на примере простого echo-бота, как можно так же легко и быстро создавать собственных.
Ядро Tox (toxcore на github) состоит из трех частей:
- ToxCore — собственно, само ядро, которое позволяет управлять контактами, сообщениями, файлами, аватарами, статусами и групповыми чатами.
- ToxAV — подсистема голосовых и видео вызовов.
- ToxDNS — подсистема получения ToxID из человеко-читаемого адреса (приблизительного аналога email или JabberID) через запрос к DNS.
Единицей контакта является ToxID (он же «address» в терминах API) — шестнадцатеричная строка длиной 76 символов, кодирующая:
- Публичный ключ (32 байта или 64 символа).
- Защиту от спама «nospam» (4 байта или 8 символов) — случайный набор данных, смена которого позволяет продолжать поддерживать авторизованные ранее контакты, но игнорировать запросы от новых.
- Контрольная сумма (2 байта или 4 символа) — операция XOR над публичным ключом и значением «nospam», служит для быстрой проверки корректности ToxID.
Общий цикл работы с Tox API можно представить последовательно в виде:
- Инициализация ядра (tox_new) — здесь производится установка протоколов (IPv4/IPv6, UDP/TCP), параметров прокси (HTTP/SOCKS5), диапазонов используемых портов и (при наличии) загрузка ранее сохраненного состояния со списком контактов.
- Установка callback функций для обработки событий (tox_callback_*) — обработчики событий вызываются из основного цикла (4) и в них обычно сосредоточена основная логика работы приложения.
- Подключение к одной и более DHT-нод (tox_bootstrap).
- Основной рабочий цикл (tox_iterate) и обработка событий.
- Пауза на время tox_iteration_interval и возврат к предыдущему шагу.
Т.к. основным способом получения знаний о работе Tox API является чтение исходного кода (написанного на си), для упрощения дальнейшего изложения я воспользуюсь оберткой для языка python (pytoxcore на github). Для тех, кто не желает заниматься самостоятельной сборкой библиотеки из исходников, там же есть ссылки на готовые бинарные пакеты для распространенных дистрибутивов.
При использовании python-обертки получить справку по библиотеке можно следующим способом:
$ python
>>> from pytoxcore import ToxCore
>>> help(ToxCore)
class ToxCore(object)
| ToxCore object
...
| tox_add_tcp_relay(...)
| tox_add_tcp_relay(address, port, public_key)
| Adds additional host:port pair as TCP relay.
| This function can be used to initiate TCP connections to different ports on the same bootstrap node, or to add TCP relays without using them as bootstrap nodes.
|
| tox_bootstrap(...)
| tox_bootstrap(address, port, public_key)
| Sends a "get nodes" request to the given bootstrap node with IP, port, and public key to setup connections.
| This function will attempt to connect to the node using UDP. You must use this function even if Tox_Options.udp_enabled was set to false.
...
Ниже остановимся на каждом шаге работы с API чуть более подробно.
Для инициализации ядра в качестве параметра используется структура Tox_Options. В python это может быть словарь с одноименными полями. Значения по умолчанию можно получить при помощи вызова метода tox_options_default:
$ python
>>> from pytoxcore import ToxCore
>>> ToxCore.tox_options_default()
{'start_port': 0L, 'proxy_host': None, 'tcp_port': 0L, 'end_port': 0L, 'udp_enabled': True, 'savedata_data': None, 'proxy_port': 0L, 'ipv6_enabled': True, 'proxy_type': 0L}
Здесь:
- ipv6_enabled — True или False в зависимости от того, являетесь ли вы счастливым обладателем IPv6.
- udp_enabled — за исключением случаев работы через прокси рекомендуется устанавливать в значение True, т.к. UDP является «родным» протоколом для Tox.
- proxy_type — тип прокси, может принимать значения:
- TOX_PROXY_TYPE_NONE — не использовать прокси.
- TOX_PROXY_TYPE_HTTP — HTTP прокси.
- TOX_PROXY_TYPE_SOCKS5 — SOCKS5 прокси (например, Tor).
- proxy_host — хост или IP адрес прокси-сервера.
- proxy_port — порт прокси-сервера (игнорируется для TOX_PROXY_TYPE_NONE).
- start_port — начальный порт из диапазона разрешенных портов.
- end_port — конечный порт из диапазона разрешенных портов. Если начальный и конечный порты равны 0, то используется диапазон [33445, 33545]. Если только один из портов равен нулю, то используется единственный ненулевой порт. В случае start_port > end_port, они будут поменяны местами.
- tcp_port — порт для поднятия TCP-сервера (релея), при значении 0 сервер будет отключен. TCP релей позволяет другим пользователям использовать ваш экземпляр как промежуточный узел (концепция super-node).
- savedata_data — данные для загрузки или None в случае их отсутствия.
В подавляющем большинстве случаев, все параметры за исключением savedata_data можно оставить по умолчанию:
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
from pytoxcore import ToxCore
class EchoBot(ToxCore):
def __init__(self):
tox_options = ToxCore.tox_options_default()
if os.path.isfile("tox.save"):
with open("tox.save", "rb") as f:
tox_options["savedata_data"] = f.read()
super(EchoBot, self).__init__(tox_options)
При первом запуске ядро сгенерирует публичный и приватный ключи, которые при необходимости можно сохранить:
class EchoBot(ToxCore):
...
def save_data(self):
with open("tox.save", "wb") as f:
f.write(self.tox_get_savedata())
Поскольку данные всегда содержатся в памяти, то для защиты от случайных сбоев я рекомендую периодически сохранять данные во временный файл и (в случае успеха и атомарно) заменять им основной файл с данными.
Текущие значения адреса для передачи пользователям, публичного и приватного ключей и значения «nospam» можно получить вызовами:
- tox_self_get_address — текущий адрес (ToxID).
- tox_self_get_public_key — публичный ключ.
- tox_self_get_secret_key — приватный ключ.
- tox_self_get_nospam / tox_self_set_nospam — получение и установка значения «nospam».
$ python
>>> from pytoxcore import ToxCore
>>> core = ToxCore(ToxCore.tox_options_default())
>>> core.tox_self_get_address()
'366EA3B25BA31E3ADC4C476098A8686E4EAE87B04E4E4A3A3A0B865CBB9725704189FEDAEB26'
>>> core.tox_self_get_public_key()
'366EA3B25BA31E3ADC4C476098A8686E4EAE87B04E4E4A3A3A0B865CBB972570'
>>> core.tox_self_get_secret_key()
'86003764B4C99395E164024A17DCD0ECB80363C5976FF43ECE11637FA0B683F9'
>>> core.tox_self_get_nospam()
'4189FEDA'
После инициализации ядра, боту в любое время можно установить ник и подпись вызовами tox_self_set_name и tox_self_set_status_message. Длина имени не должна превышать значения TOX_MAX_NAME_LENGTH, длина подписи не должна превышать значения TOX_MAX_STATUS_MESSAGE_LENGTH (размеры в байтах). Установка аватара будет рассмотрена ниже, т.к. технически является отправкой файла контакту.
В python-обертке подключение к поддерживаемым callback функциям производится автоматически. Сами же обработчики могут являться методами наследника ToxCore и имеют суффикс *_cb:
class EchoBot(ToxCore):
...
def tox_self_connection_status_cb(self, connection_status):
if connection_status == ToxCore.TOX_CONNECTION_NONE:
print("Disconnected from DHT")
elif connection_status == ToxCore.TOX_CONNECTION_TCP:
print("Connected to DHT via TCP")
elif connection_status == ToxCore.TOX_CONNECTION_UDP:
print("Connected to DHT via UDP")
else:
raise NotImplementedError("Unknown connection_status: {0}".format(connection_status))
Конкретные обработчики и их аргументы будут рассмотрены ниже.
DHT-нода определяется IP адресом, номером порта и публичным ключом. Начальный список DHT-нод можно взять в вики проекта.
Согласно Tox Client Guidelines, клиент каждые 5 секунд должен пытаться подключиться как минимум к четырем случайным нодам, пока ядро не сообщит об успешном соединении (см. tox_self_connection_status_cb). В случае загрузки из файла состояния клиент не должен пытаться соединяться в течении 10 секунд после первого вызова tox_iterate и, в случае отсутствия соединения, повторить агрессивную стратегию соединения выше.
Для бота, который планирует всегда быть на связи, данные рекомендации выглядят немного переусложненными. Можно попробовать сократить требуемое количество DHT-нод для подключения за счет поднятия собственной локальной DHT-ноды. Дополнительный плюс наличия локальной ноды, помимо постоянной связи с ней, это «усиление» самой сети Tox.
Для поднятия локальной ноды потребуется установить и настроить демона tox-bootstrapd. Его можно собрать вместе с библиотекой toxcore, а так же получить в бинарном виде из репозитория разработчиков.
Конфигурация демона задается в файле /etc/tox-bootstrapd.conf и хорошо документирована. Дополнительную информацию по запуску демона можно получить в соответствующем README, а для deb-дистрибутивов проекта tox.pkg установка пакета tox-bootstrapd самодостаточна. Публичный ключ локальной DHT-ноды можно будет узнать в системном логе после запуска демона.
Т.о., упрощенная версия соединения с DHT-нодой и рабочего цикла может быть представлена в виде:
class EchoBot(ToxCore):
...
def run(self):
checked = False
self.tox_bootstrap("127.0.0.1", 33445, "366EA...72570")
while True:
status = self.tox_self_get_connection_status()
if not checked and status != ToxCore.TOX_CONNECTION_NONE:
checked = True
if checked and status == ToxCore.TOX_CONNECTION_NONE:
self.tox_bootstrap("127.0.0.1", 33445, "366EA...72570")
checked = False
self.tox_iterate()
interval = self.tox_iteration_interval()
time.sleep(interval / 1000.0)
В некоторых случаях клиент может работать вообще без вызова tox_bootstrap — для этого необходимо, чтобы в рамках одного широковещательного домена сети был запущен другой клиент или DHT-нода. Эта особенность делает возможным общение внутри локальной сети без необходимости выхода в интернет и общение с внешним миром, если хотя бы один клиент имеет доступ в сеть и является релеем.
Как было написано ранее, для подключения обработчика событий в python достаточно добавить нужный метод с нужными аргументами в класс-наследник и в качестве примера был приведен обработчик состояния соединения tox_self_connection_status_cb, который вызывается при подключении и отключении бота от DHT-сети.
Работа с контактами
Для того, чтобы бот мог взаимодействовать с другими участниками сети, они должны быть добавлены в контакт-лист бота. В терминах API контакт называется «friend» и обозначается целым числом (»friend_number»), уникальным в течении жизни экземпляра ToxCore.
Когда другой клиент делает запрос на добавление бота в контакт лист, на стороне бота вызывается обработчик tox_friend_request_cb (public_key, message), где:
- public_key — публичный ключ друга.
- message — сообщение от друга типа «Привет, это abbat! Добавите меня в друзья?».
Внутри обработчика вы можете как добавить человека в друзья вызвав tox_friend_add_norequest, так и просто проигнорировать запрос. Все дальнейшие обработчики событий будут использовать friend_number в качестве идентификатора друга, который можно получить из публичного ключа вызовом tox_friend_by_public_key.
После добавления контакта в друзья на стороне бота могут возникать следующие события:
- tox_friend_connection_status_cb (friend_number, connection_status) — изменение состояния соединения друга, где connection_status может принимать значения:
- TOX_CONNECTION_NONE — друг в offline.
- TOX_CONNECTION_TCP — друг в online и соединен по TCP.
- TOX_CONNECTION_UDP — друг в online и соединен по UDP.
- tox_friend_name_cb (friend_number, name) — изменение ника друга.
- tox_friend_status_message_cb (friend_number, message) — изменение подписи.
- tox_friend_status_cb (friend_number, status) — изменение состояния, где status может принимать значения:
- TOX_USER_STATUS_NONE — доступен («online»).
- TOX_USER_STATUS_AWAY — отошел.
- TOX_USER_STATUS_BUSY — занят.
- tox_friend_message_cb (friend_number, message) — сообщение от друга.
- tox_friend_read_receipt_cb (friend_number, message_id) — квитанция о получении другом сообщения, отправленного вызовом tox_friend_send_message (см. ниже).
В случае echo-бота при получении сообщения от друга, его требуется просто отправить обратно:
class EchoBot(ToxCore):
...
def tox_friend_request_cb(self, public_key, message):
self.tox_friend_add_norequest(public_key)
def tox_friend_message_cb(self, friend_number, message):
message_id = self.tox_friend_send_message(friend_number, ToxCore.TOX_MESSAGE_TYPE_NORMAL, message)
Отправка сообщения другу производится вызовом метода tox_friend_send_message, который возвращает идентификатор сообщения message_id — монотонно возрастающее число, уникальное для каждого друга. В качестве параметров метод принимает идентификатор друга, тип сообщения и сам текст сообщения. На текст сообщения накладываются следующие ограничения:
- Сообщение не может быть пустым.
- Сообщение не может быть больше TOX_MAX_MESSAGE_LENGTH (байт), длинные сообщения необходимо разбивать на части.
В случае, если обработка сообщения от друга требует некоторого времени, поведение бота можно разнообразить передавая случайным образом события «набора сообщения» (tox_self_set_typing).
По значению friend_number друга в любой момент работы можно получить следующую информацию о нем:
- tox_friend_get_connection_status — текущий сетевой статус друга (последнее значение из tox_friend_connection_status_cb).
- tox_friend_get_name — текущий ник друга (последнее значение из tox_friend_name_cb).
- tox_friend_get_status_message — текущая подпись друга (последнее значение tox_friend_status_message_cb).
- tox_friend_get_status — текущий статус друга (последнее значение из tox_friend_status_cb).
- tox_friend_get_last_online — дата последнего появления в online (unixtime).
Дополнительные операции с друзьями:
- tox_self_get_friend_list_size — получение количества друзей.
- tox_self_get_friend_list — получение списка friend_number друзей.
- tox_friend_delete — удаление друга из списка контактов.
Здесь кажется все просто и интуитивно. Дальше будет немного сложнее.
Работа с файлами
Прием файлов
Когда кто-либо из друзей отправляет боту файл, возникает событие tox_file_recv_cb (friend_number, file_number, kind, file_size, filename), где:
- friend_number — номер друга (см. «Работа с контактами»).
- file_number — номер файла, уникальное число в рамках текущего списка принимаемых и передаваемых файлов от данного друга.
- kind — тип файла:
- TOX_FILE_KIND_DATA — передаваемый файл является простым файлом.
- TOX_FILE_KIND_AVATAR — передаваемый файл является аватаром друга.
- file_size — размер файла. Для TOX_FILE_KIND_AVATAR размер файла равный 0 означает, что у друга не установлен аватар. Размер файла равный UINT64_MAX обозначает неизвестный размер файла (потоковую передачу).
- filename — имя файла.
Здесь следует обратить особое внимание на параметр filename. Несмотря на то, что спецификация требует передавать все данные в UTF-8 и имя файла не должно содержать частей пути, в реальной жизни может прилететь все что угодно вплоть до нечитаемых бинарных данных, содержащих символы перевода строк и нули.
При возникновении данного события следующим действием бота должен быть вызов управляющего метода tox_file_control (friend_number, file_number, control), где:
- friend_number — номер друга.
- file_number — номер файла.
- control — команда управления файлом:
- TOX_FILE_CONTROL_CANCEL — отменить прием файла.
- TOX_FILE_CONTROL_PAUSE — поставить передачу файла на паузу (поддерживается не всеми клиентами).
- TOX_FILE_CONTROL_RESUME — продолжить передачу файла.
Для echo-бота прием файлов не требуется, по этому он всегда может отменять операцию:
class EchoBot(ToxCore):
...
def tox_file_recv_cb(self, friend_number, file_number, kind, file_size, filename):
self.tox_file_control(friend_number, file_number, ToxCore.TOX_FILE_CONTROL_CANCEL)
В случае же передачи управления через TOX_FILE_CONTROL_RESUME, начинает вызваться событие tox_file_recv_chunk_cb (friend_number, file_number, position, data), где:
- friend_number — номер друга.
- file_number — номер файла.
- position — текущая позиция в файле.
- data — чанк данных или None для конца передачи.
Здесь следует обратить внимание на то, что position не обязана монотонно возрастать — в общем случае чанки могут приходить в любой последовательности и любой длины.
Передача файлов
Для начала процедуры передачи файла необходим вызов метода tox_file_send (friend_number, kind, file_size, file_id, filename), где:
- friend_number — номер друга.
- kind — значение TOX_FILE_KIND_DATA или TOX_FILE_KIND_AVATAR.
- file_size — размер файла (специальные значения 0 и UINT64_MAX рассмотрены выше).
- file_id — уникальный идентификатор файла длиной TOX_FILE_ID_LENGTH, который позволяет продолжить передачу после рестарта ядра или None для автоматической генерации.
- filename — имя файла.
Здесь особенным параметром является file_id. В случае автоматической генерации его впоследствии можно получить вызовом tox_file_get_file_id, однако при передаче аватара его значение рекомендуется устанавливать в результат вызова tox_hash от данных файла аватара, что позволяет принимающей стороне отменять передачу ранее загруженных аватаров экономя трафик.
Так же следует отметить, что передача файлов возможна только тем друзьям, которые подключены к сети. Отключение друга от сети прекращает передачу файла.
После вызова tox_file_send ядро ожидает решения от принимающей стороны. Решение обрабатывается событием tox_file_recv_control_cb (friend_number, file_number, control), где:
- friend_number — номер друга.
- file_number — номер файла.
- control — команда управления файлом (TOX_FILE_CONTROL_CANCEL, TOX_FILE_CONTROL_PAUSE или TOX_FILE_CONTROL_RESUME рассмотренные ранее).
Обработка данного события позволяет освободить ресурсы в случае отказа клиента от принятия файла.
Echo-боту требуется только передавать аватар. Передачу аватара рекомендуется делать каждый раз, когда друг появляется в сети. Если tox-клиент друга ранее уже загрузил аватар с данным file_id, то он может отменить повторную передачу аватара.
class EchoBot(ToxCore):
...
def __init__(self):
...
self.avatar_name = "avatar.png"
self.avatar_size = os.path.getsize(avatar_name)
self.avatar_fd = open(avatar_name, "rb")
data = self.avatar_fd.read()
self.avatar_id = ToxCore.tox_hash(data)
def tox_friend_connection_status_cb(self, friend_number, connection_status):
if connection_status != ToxCore.TOX_CONNECTION_NONE:
send_avatar(friend_number)
def send_avatar(self, friend_number):
file_number = self.tox_file_send(friend_number, ToxCore.TOX_FILE_KIND_AVATAR, self.avatar_size, self.avatar_id, self.avatar_name)
def tox_file_recv_control_cb(self, friend_number, file_number, control):
pass
def tox_file_chunk_request_cb(self, friend_number, file_number, position, length):
if length == 0:
return
self.avatar_fd.seek(position, 0)
data = self.avatar_fd.read(length)
self.tox_file_send_chunk(friend_number, file_number, position, data)
Помимо приема-передачи сообщений и файлов, в ядре реализована возможность передачи пакетов (с потерями или без потерь). Для этого служат методы tox_friend_send_lossy_packet и tox_friend_send_lossless_packet, а так же события tox_friend_lossy_packet_cb и tox_friend_lossless_packet_cb.
- tox.chat — текущий официальный сайт проекта Tox (взамен tox.im).
- Проект ядра toxcore на github.
- Проект python-обертки pytoxcore на github.
- Проект для сборки бинарных версий клиентов и библиотек tox.pkg на github.
- Проект «официальной» python-обертки PyTox на github (на момент написания статьи библиотека не компилировалась из за использования «старого» api).