Издательский дом ООО "Гейм Лэнд"ЖУРНАЛ ХАКЕР #126, ИЮНЬ 2009 г.

Робот для администратора. Программим крутой jabber-бот с поддержкой плагинов на Python'е

Роман «Spirit» Хоменко (http://tutamc.com)




Британские ученые давно доказали, что собственным джаббер-ботом должен обладать любой уважающий себя администратор. Еще бы, ведь это он спасет в экстремальной ситуации, когда под рукой нет putty, зато есть джаббер-клиент (ты ведь уже завязал с аськой, правда?).

Пришло тепло, а вместе с ним - и выезды на природу с шашлыками. И пока я смотрел на костер, предвкушая начало отдыха, зазвонил телефон (в соответствии с приказами он должен быть при каждом сотруднике милиции на случай тревоги или команды «сбор»). Звонил полковник, срочно вызывающий меня на работу и требующий выдать ему на серваке новый пароль. Работы на пару минут, но где же я в лесу достану комп с инетом? Матерый гик портативную ЭВМ себе, наверняка, найдет и там (пить, гулять и морально разлагаться без ноута/субноута немыслимо!), но я оказался не таков, и на этом пикник для меня закончился. Вечером я вспомнил, что на телефоне у меня установлен джаббер-месенджер, и, если бы на серваке присутствовал соответствующий бот, который мог бы исполнять его команды, летний отдых бы не обломался. «Никогда не поздно начать», - сказал себе я и принялся за работу.

Python & Jabber

Почему Python? А потому, что зеленый змий - единственный развивающийся (в отличие от Perl'а, развитие которого де-факто прекращено) системный (в отличие от вебовского PHP) скриптовый язык программирования. Выбор протокола сделать еще проще, ведь у джаббера в этом плане нет конкурентов - аська не подходит ввиду отсутствия нативного шифрования и централизованности (их сервак может лежать или капиталистическое начальство опять начнет что-то мутить со сменой протокола).

Юзать на Питоне джаббер-протокол можно двумя способами: простым и сложным. «Сложно» - это путем чтения документации (xmpp.org/rfcs/) и с использованием сокетов. Если хочешь разобраться с протоколом – это способ оптимален, советую посмотреть, как сделан бот от eLWAux (исходники есть на диске).

Легкий путь подразумевает использование уже написанных библиотек. Их немало, например:

Выбор определяется в основном личными пристрастиями. Мне больше понравилась xmppppy от Алексея Нежданова. Ею мы и воспользуемся.

Архитектура

Итак, задача поставлена: написать джаббер-бот, способный принимать и исполнять команды. Нет, стой, это слишком просто. Усложним все и напишем полноценный бот с поддержкой плагинов, один из которых и будет решать задачу администрирования.

Система у нас будет состоять из бота (bot.py), файла конфигурации (config.ini), папки с плагинами (plugins) и библиотеки xmpppy. Бот будет иметь два типа плагинов. Представители первой группы будут доступны всем, второй – лишь избранным, по списку или паролю.

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

Система конфигов

Программинг бота начнем с реализации чтения конфига, который будет храниться во всем известном ini-формате в файле config.ini (кстати, амеры весело произносят «ini» как «айнай»). В секции connect мы разместим параметры доступа к аккаунту, на котором будет висеть бот, а в секции permission - список юзеров, имеющих доступ к админке (пароли будут храниться там же).

Удобную работу с ini-файлами в Питоне обеспечивает библиотека ConfigParser. Для чтения параметров мы используем две функции оттуда. Первая, read - для чтения конфигурационного файла, имя которого передается как параметр. Вторая функция, get, нужна, чтобы достать какой-то параметр. Она принимает в качестве параметров секцию и имя параметра. Рассмотрим эту функцию:

def loadConfig():
import ConfigParser
config = ConfigParser.ConfigParser()
config.read('config.ini')
login = config.get('connect', 'login')
password = config.get('connect', 'password')
allow_password =
config.get('permission', 'allow_password')
user_no_pass =
config.get('permission', 'user_no_pass')
user_no_pass = user_no_pass.split(',')
return {'login':login,'password':password,
'allow_password':allow_password,
'user_no_pass':user_no_pass}

Здесь мы читаем переменную со списком юзеров, которым разрешен доступ в админку и превращаем в список методом split по запятой как разделителю. Далее все параметры мы возвращаем упакованными в ассоциативный массив (на языке Питона - словарь).

Теперь конфиг можно прочитать:

config = loadConfig()

Запуск бота

Перейдем к использованию xmpppy и запуску бота. Сперва создадим объект jid от xmpp.JID, передав имя пользователя, взятого из нашего загруженного конфига. Создание главного объекта bot производится от xmpp.Client с передачей домена, на котором находится юзер, и пустым списком, чтобы на экран не выводилась отладочная информация (тру-хакеры работают только методом научного тыка).

jid = xmpp.JID(config['login'])
bot = xmpp.Client(jid.getDomain(),debug=[])

Чтобы иметь полный контроль над ботом в любой точке программы (и в главном цикле, и в плагинах), мы будем передавать наш объект bot повсеместно. Но ведь конфиг, список плагинов и другая служебная информация также может понадобиться (например, плагину help нужно знать, какие плагины установлены)? Да, и поэтому в объект bot мы сохраним всю интересную инфу. Так, конфиг сохраним строкой:

bot.config = config

Можно законектиться и пройти аутентификацию:

bot.connect()
bot.auth(jid.getNode(),bot.config['password'])

Прием сообщения в xmpppy реализуется через привязку функции к событию прихода сообщения. Сначала нужно создать функцию, к примеру, message, а потом, методом bot.RegisterHandler зарегистрировать ее:

bot.RegisterHandler('message',message)

Теперь в цикле необходимо вызвать bot.Process(1), который принимает входящие сообщения и обрабатывает их. Разумеется, вечный цикл здесь не нужен, поэтому в свойстве online смело запишем единичку, и будем крутить цикл до тех пор, пока эта единичка не изменит свое значение:

bot.online = 1
while bot.online:
bot.Process(1)
bot.disconnect()

Для остановки бота нам будет достаточно изменить 1 на 0. В результате, он корректно отсоединится от сервера и завершит свою работу.

Когда наш бот запущен, мы можем вернуться к написанию функции обработки входящих сообщений. Простейший вариант, способный принимать сообщение и реагировать на сей знаменательный факт, выглядит примерно так:

def message(conn,mess):
global bot
if ( mess.getBody() == None ):
return
bot.send(xmpp.Message(mess.getFrom(),'hello'))

В начале этого участка кода мы командой «global bot» получаем доступ к объекту нашего бота. Далее идет обработка входящего сообщения, где с помощью команды mess.getBody мы получаем сообщение. Если входящее сообщение равно None, это означает, что пришла служебная команда (допустим, про то, что юзер что-то нам печатает). Такие сообщения мы обрабатывать не настроены, и реакция на них однозначна – выход из функции.

В следующей части Марлезонского балета мы воспользуемся методом send, отправив в ответ простое сообщение. Этот метод в качестве параметра принимает объект Message библиотеки xmpppy. При ее создании мы передаем два параметра: первый - кому нужно отправить сообщения, а второй - текст сообщения.

Плагины

Простая поддержка плагинов реализована в самой библиотекой xmpppy, но мы сделаем ее по-своему, написав собственную архитектуру плагинов (как минимум, с блэкджеком!). Сначала определимся со структурой плагина. Каждый плагин будет иметь название, аналогичное исполняемой им команде. К примеру, если мы хотим создать плагин, который на команду «echo some text» посылает сообщение с этим же текстом, то плагин должен будет называться echo и размещаться в файл echo.py в каталоге plugins. В каждом плагине должны содержаться две функции. Одна, init, может проводить некую предварительную инициализацию и обязательно возвращать 1, если плагин можно использовать лишь админам, и 0, если всем юзерам. Вторая же обязательная функция – run – в качестве входного параметра принимает ссылку на наш бот и входящее сообщение. К примеру, плагин echo выглядит так:

import xmpp
def init():
return 0
def run(bot,mess):
bot.send(
xmpp.Message(mess.getFrom(),mess.getBody()))

Напишем функцию, загружающую наши плагины. Поскольку мы уже решили, что они должны находиться в папке plugins, то в этом каталоге нам придется поместить пустой файл __init__.py - таково требование Питона. Это шаманское действо позволит нам импортировать файлы как модуль функцией __import__.

Итак, нам нужно загрузить все файлы, размещенные в папке plugins (кроме __init__.py) в какую-то переменную. Кроме того, во время инициализации мы создадим два списка, которые будут содержать реестр загруженного. В первом (public_commands) будут перечислены те плагины, которые можно запустить без авторизации, а во втором (commands) – элитные (тру, VIP) - админские плагины. В качестве результата работы функция вернет нам ассоциативный массив с плагинами и списками:

def loadPlugins():
import os
commands = []
public_commands = []
#Перебираем все файлы из папки plugins
for fname in os.listdir('plugins/'):
#Если файл заканчивается на '.py'
if fname.endswith('.py'):
#Обрезаем последние 3 буквы
plugin_name = fname[:-3]
#Если имя файла не '__init__'
if plugin_name != '__init__':
#Загружаем плагин в переменную
plugins=__import__('plugins.'+plugin_name)
#Достаем плагин с переменной
plugin = getattr(plugins,plugin_name)
#Если плагин админский
if plugin.init():
commands.append(plugin_name)
else:
public_commands.append(plugin_name)
#Возвращаем ассоциативный словарь
return {'plugins':plugins,'commands':commands,
'public_commands':public_commands}

Эта функция очень важна для понимания работы Питона в плане загрузки плагинов «на лету» - советую подробно в ней разобраться. Как видно, после загрузки в переменную plugins плагина, его оттуда можно вытянуть функцией getattr с указанием в первом параметре имени переменной, где хранятся плагины, а во втором параметре – имени плагина. В результате, функция возвращает сам плагин, из которого уже потом можно вызывать функции, в нем находящиеся. К примеру, в вышеуказанной функции загрузки мы запускаем для каждого из них функцию init. В зависимости от результата имя плагина добавляем или в список public_commands, или в commands.
Написанную загрузку плагинов можно заюзать и при этом записать результат в соответствующее свойство бота:

bot.plugins = loadPlugins()

Чтобы вызвать наш плагин, напишем маленькую функцию, которая будет принимать имя команды, ссылку на бота и входящее сообщение:

def runPlugin(command,bot,mess):
plugin = getattr(bot.plugins['plugins'],command)
plugin.run(bot,mess)

Эта функция, исходя из имени плагина (параметр command), вытаскивает его из bot.plugins['plugins'] и запускает функцию run.

Обработка сообщений

Осталось изменить функцию message, которая обрабатывает входящие сообщения. Логика ее работы такова:

  • Выделяем из входящего сообщения команду (символы от начала до пробела);
  • Смотрим наличие команды в списке «публичных».
  • Если к публичным она не относится, стало быть - проверяем авторизацию и факт наличия команды в списке «админских».
  • Если команда нашлась, то запускаем ее. В противном случае – сообщаем юзеру о прискорбном факте отсутствия команды в наших списках.

Кстати, рассмотрим один момент. Имя пользователя, которое возвращает команда mess.getFrom(), не всегда возвратит правильное значение (с точки зрения проверки авторизации). Почему? Ведь там может быть имя ресурса (смотри врезку про Джаббер), которое нам не нужно. Стало быть, ресурс мы должны обрезать, например, вызвав метод строки split('/') и потом взять первый элемент списка. Полностью функцию смотри на соответствующей врезке.

Исполнение команд

Ну, вот и настало время закодить «виновника торжества» - плагин, исполняющий системные команды. Наречен он будет говорящим именем «cmd», а принцип его работы будет таков:

  • Обрезаем первые 4 символа входящего сообщения (к примеру, 'cmd ls' -> 'ls').
  • Передаем команду в цепочку функций os.popen(cmd).read().
  • Полученный результат конвертируем (при необходимости) в utf-8 (при инициализации бота мы не указывали кодировку, по умолчанию это будет UNICODE).
  • Возвращаем пользователю результат.

Вот как это выглядит в строках сурового программного кода:

import xmpp
import os

def init():
return 1

def run(bot,mess):
cmd = mess.getBody()
cmd = cmd[4:]
output = os.popen(cmd).read()
if not isinstance(output, unicode):
output = unicode(output,'utf-8','ignore')
bot.send(xmpp.Message(mess.getFrom(),output))

Happy end

На этом разработку бота можно считать завершенной. Мы реализовали все, что хотели, оставив задел для неограниченного расширения функционала при помощи плагинов. На диске, кроме вышеперечисленных плагинов, содержатся и плагины бонусные. А чтобы продемонстрировать запуск и работу готового бота, я записал видео. Если что-то будет непонятно – спрашивай!

Ссылки на объекты и переменные

В Питоне (да и в других языках, например, в PHP), есть один нюанс, про который часто забывают. Рассмотрим пример работы с переменными:

a = 1; b = a; b = 2
print a #1
print b #2

Он выведет на экран 1, потом 2, и это закономерно. А что, если переменная будет в объекте? Посмотрим:

class Obj():
def __init__(self):
pass

a = Obj(); a.var = 1; b = a; b.var = 2

print a.var #2
print b.var #2

Сюрприз! На экран выводятся сплошные двойки! А случается это потому, что операция «=» при работе с объектами не копирует их, как при других типах переменных, а создает ссылку. В контексте нашего примера это означает, что переменную bot мы обычно передавали как бы по значению, а на самом деле – как ссылку.

Процедура обработки входящих сообщений

def message(conn,mess):
global bot
text = mess.getBody()
#если сообщение служебное - выходим
if ( text == None ):
return

# Из входящего сообщения достаем команду
command = text.split(' ')
command = command[0]
#если команда в списке публичных - запускаем
if command in bot.plugins['public_commands']:
#Запускаем команду
runPlugin(command,bot,mess)
return

#Достаем имя пользователя
user=mess.getFrom()
user=str(user).split('/')
user=user[0]

#Если юзер не админ - говорим «команды нет»
if user not in bot.config['user_no_pass']:
text = "wrong command. try 'help'"
bot.send(xmpp.Message(mess.getFrom(),text))
return

#Если команда есть в «админских»
if command in bot.plugins['commands']:
runPlugin(command,bot,mess)
else:
text = "wrong command. try 'help'"
bot.send(xmpp.Message(mess.getFrom(),text))

Проект Jabber

Jabber — система мгновенного обмена сообщениями и информацией о присутствии на основе открытого протокола XMPP. Проект был основан Джереми Миллером в начале 1998 года и стартовал с разработки сервера jabberd. В настоящий момент есть некоторая непонятность в отношении терминов jabber и xmpp. Даже в английской Википедии с jabber стоит переадресация на xmpp. Эта непонятка в первую очередь связана с тем, что под именем xmpp протокол был стандартизирован в IETF.

Каждый пользователь в джаббер-сети имеет уникальный идентификатор — Jabber ID (сокращенно JID). Адрес JID содержит имя пользователя и доменное имя сервера, на котором зарегистрирован пользователь. Подобно адресу электронной почты, они разделены знаком @.

Пользователь может иметь одновременно несколько подключений, для различения которых используется дополнительное значение JID, называемое «ресурсом» и добавляемое через слеш в конец адреса. К примеру, пусть полный адрес пользователя будет user@example.com/work, тогда сообщения, посланные на адрес user@example.com, дойдут на указанный адрес вне зависимости от имени ресурса, но сообщения для user@example.com/work дойдут только при соответствующем подключенном ресурсе!

Подробнее о xmpppy

Библиотека xmpppy содержит много полезных объектов. Рассмотрим некоторые из них. Объект JID для работы с Jabber ID. При создании принимает параметр - Jabber-идентификатор.

Методы:

  • getDomain, возвращает домен;
  • getNode, возвращаем имя пользователя;
  • getResource, возвращает ресурс.

Главный объект Client. При создании принимает домен джаббер-сервера и переменную для отладочной информации.

Методы:

  • connect, подключение к серверу;
  • auth, авторизация, принимает параметры: имя пользователя и пароль;
  • RegisterHandler, привязка функций к событиям, принимает параметры тип события(message, presence, iq) и имя функции;
  • sendInitPresence, отправка начальных запросов, нужно запускать после авторизации;
  • send, отправка сообщений, принимает объект Message;
  • Process, запустить обработку входных сообщений;
  • disconnect, отключиться от сервера.
  • Message - объект сообщения. Принимает параметры - имя юзера и текст сообщения.

Методы:

  • getBody, возвращает текст сообщения;
  • getFrom, возвращает имя пользователя от кого сообщение.

DVD

На диске ты найдешь полные исходники бота.

VIDEO

Видео, иллюстрирующее работу с ботом, ждет тебя там же – на нашем DVD.

WWW

xmpppy.sourceforge.net – cайт библиотеки xmpppy.

Содержание