[recovery mode] Пишем Skype бота на C# с модульной архитектурой

imagealert ('Привет Хабр!'); Давно уже засела мысль сделать эдакую тулзу-помощника, которая смогла бы мне и курсы валют вывести и погоду подсказать и анекдот затравить, да всё руки не доходили… ну вы же знаете как это бывает, верно? Кроме того, в моём бесконечном списке с забавными идеями, которые неплохо бы когда-нибудь реализовать — был пункт «бот для скайпа 4fun».

Руки дошли. Речь пойдёт о написании простого модульного бота на C# с интеграцией в Skype. Что получилось в итоге, а также почему стоит выключать системник от сети перед тем как в него лезть отвёрткой — читайте под катом.

ПредисловиеКазалось бы, причем тут системный блок и отвёртка? Ну так вот… Одним томным вечером разобрал я системник дабы смазать кулер на блоке питания (шумел аки жук). Смазал, проверил, что всё крутится-вертится и радует ухо. Начал собирать всё в исходное состояние и… не удосужился отключить его от сети. За что судьба вознаградила меня звёздами в глазах, тратой энной суммы на новый бпшник и… решением наконец написать первую за 4 года статью на любимый хабр. Просьба сильно не пинать, чукча автор не очень писатель.Для тех, кому лень читать всю статью: все сорцы тут: https://github.com/Nigrimmist/HelloBot и инструкция по

запуску Нужно лишь всё скомпилить и в \SkypeBotAdapterConsole\bin\Debug будет лежать готовая консолька, которую нужно запустить для тестирования (нужна регистрация библиотеки skyp4com в системе + старый скайп). Дальше в статье эти моменты расписаны более детально.

Вступление На очередных выходных, немного подустав пилить своё детище — очередного убийцу фейсбуков, я решил подобрать что-нибудь для души да реализовать. Выбор пал на бота для скайпа. Решил писать сразу с заделом на расширяемость, дабы коллеги могли дописать те модули бота, которые нужны непосредственно им.К слову, состою я в одном Skype чате, который в свою очередь состоит из друзей, знакомых, да коллег и именумый Men’s club. Был создан во времена совместной работы на одном из проектов, да так как-то и прижился в наших контакт-листах, принимая на себя роль мужской болталки. Именно для этого чата я и написал бота, дабы немного повеселить народ, да внести небольшую изюминку.Ставим задачи И так. определимся с тем, что хотелось бы иметь в конечном итоге: — Отдельный модуль бота, цель которого — обрабатывать сообщения и возвращать ответ.— Интеграция со Skype. Бот должен уметь принимать сообщения в чатах и реагировать на них, если они адресованы ему— Лёгкость написания и подключения «модулей» со стороны третьих разработчиков— Возможность интеграции с различными клиентами

Исследование предметной области Полез я в эти ваши интернеты искать информацию о том, каким образом я смог бы решить главную проблему — взаимодействовать со скайпом. Первыми же ссылками меня выбросило на информацию о том, что Microsoft с декабря 2013 года урезал API (это легко сказано, потому что «урезано» было 99% возможностей) и пока не планирует каким-либо образом развивать данное направление.Сделав задумчивое лицо, я в течении получаса набросал код, который каждые пару секунд кликал на чат, копировал в буфер сообщения и таким образом взаимодействовал с ui оболочкой. Посмотрев на этого франкенштейна, моё сердце сжалось и я зажал backspace на добрых 10 секунд. «Да не может быть, чтобы не нашлось решения получше» — пронеслось в голове, а руки сами потянулись к клавиатуре.

Появилась мысль прикрутить старую api библиотечку к старому skype, но, как вы знаете — Microsoft и тут подложил нам розовое животное, запретив использовать старые версии скайпа. Изучив некоторое количество статей я пришёл к выводу, что существуют отдельные старые portable версии, переделанные умельцами до работоспособного состояния с сохранением старого функционала. И таки да, запустив скайп на виртуалке, я убедился, что старая api библиотека таки работает с чуть более старым скайпом.

Реализация И так, для реализации задуманного нам потребуется: — Skype4COM.dll — это компонент ActiveX, который предоставляет API для общения со Skype’ом— Interop.SKYPE4COMLib.dll — прокси либа для взаимодействия с Skype4COM.DLL из .net кода— Запущенный Skype (подойдет к примеру версия 6.18, пробовал и на 4.2, но там ещё не было поддержки чатов)— Кефир и овсяное печенье

Код писался в Visual Studio 2012 под 4.5 .NET Framework.

Регистрируем Skype4COM.DLL в системе. Самый простой способ — создать .bat файл и вписать туда

regsvr32 Skype4COM.dll Кладём его рядом с dll и запускаем батник. Надкусываем печеньку, запиваем кефиром и потираем руки, потому что десятая часть дела сделана.Далее нам нужно каким-то образом проверить работает ли оно вообще.

Взаимодействие со скайпом Создаём консольное приложение, подключаем Interop.SKYPE4COMLib.dll и пишем следующий нехитрый код: Код с комментариями class Program { //инициализируем объект класса Skype, с ним в дальнейшем и будем работать private static Skype skype = new Skype ();

static void Main (string[] args) { //создаём тред, дабы не лочить нашу консольку Task.Run (delegate { try { //подписываемся на новые сообщения skype.MessageStatus += OnMessageReceived; //пытаемся присоединиться к скайпу. В данный момент вылезет окошко, где он у вас спросит разрешения на открытие доступа программе. //5 это версия протокола (идёт по-умолчанию), true — отключить ли отваливание по таймауту для запроса к скайпу. skype.Attach (5, true); Console.WriteLine («skype attached»); } catch (Exception ex) { //выводим в консольку, если что-то не так Console.WriteLine («top lvl exception:» + ex.ToString ()); } //варварски фризим поток while (true) { Thread.Sleep (1000); } }); //варварски фризим основной поток while (true) { Thread.Sleep (1000); } } //обработчик новых сообщений private static void OnMessageReceived (ChatMessage pMessage, TChatMessageStatus status) { //суть такова, что для каждого сообщения меняется несколько статусов, поэтому мы ловим только те, у которых статус cmsReceived + это не позволит в будущем реагировать нашему боту на свои же сообщения if (status == TChatMessageStatus.cmsReceived) { Console.WriteLine (pMessage.Body); } } } Запускаем, просим кого-нибудь нам написать в скайпе — в консольку выводится текст собеседника. Win. Тянемся к ещё одной печеньке и доливаем в кружку кефира.Пишем модули И так, осталось совсем малость. Нам нужно реализовать бота таким образом, чтобы подключать дополнительные модули с командами для бота было проще чем смазать кулер в блоке питания.Создаём library проект и назовём его, допустим HelloBotCommunication. Он будет служить мостом между модулями и ботом. Помещаем туда три интерфейса:

IActionHandler Он будет отвечать за классы-обработчики сообщений. public interface IActionHandler { List CallCommandList { get;} string CommandDescription { get; } void HandleMessage (string args, object clientData, Action sendMessageFunc); } где CallCommandList это список команд по которым будет вызван HandleMessage, CommandDescription нужен для вывода описания в команде! modules (об этом ниже) и HandleMessage — где модуль должен обработать входящие параметры (args), передав ответ в коллбек sendMessageFunc IActionHandlerRegister Он будет отвечать за регистрацию наших обработчиков. public interface IActionHandlerRegister { List GetHandlers (); } ISkypeData Он будет отвечать за дополнительную информацию о клиенте, в данном случае — о скайпе, если таковая необходима обработчику. public interface ISkypeData { string FromName { get; set; } } Смысл этого всего вот в чём: разработчик создаёт свою .dll, подключает нашу библиотеку для коммуникации, наследуется от IActionHandler и IActionHandlerRegister и реализует нужный ему функционал не думая о всём том, что лежит выше.Пример Пример в виде модуля команды «скажи», который заставит бота сказать всё что будет после самой команды. public class Say: IActionHandler { private Random r = new Random (); private List answers = new List() { «Вот сам и скажи», «Ищи дурака», «Зачем?», »5$», «Нет, спасибо», };

public List CallCommandList { get { return new List() { «скажи», «say» }; } } public string CommandDescription { get { return @«Говорит что прикажете»; } } public void HandleMessage (string args, object clientData, Action sendMessageFunc) { if (args.StartsWith (»/»)) { sendMessageFunc (answers[r.Next (0, answers.Count-1)]); } else { sendMessageFunc (args); } } } Пишем тело бота Модуль есть, библиотека для связи есть, осталось написать главного виновника торжества — мсье бота и всё это как-то связать. Да легко — скажете вы и сбегаете на кухню за вторым пакетом кефира. И будете правы.Назвал я его HelloBot и создал отдельный library проект. Суть класса заключается в поиске нужных .dll с модулями и работе с ними. Делается это через

assembly.GetTypes ().Where (x => i.IsAssignableFrom (x)) // и Activator.CreateInstance (type); Тут хочу немного предостеречь вас. Это по большому счёту решение в лоб и потенциально является дырой в безопасности. По-хорошему нужно создавать отдельный домен и давать только нужные права при выполнении чужих модулей, но мы люди наивные и предполагаем, что весь код проверен, а модули написаны из лучших побуждений. (Правильное решение не писать велосипед, а заюзать например, MEF)

После регистрации создания объекта у нас будут в распоряжении префикс команды (по умолчанию »!») и маска для поиска .dll модулей. А так же метод HandleMessage в котором и творится вся магия.Магия состоит в принятии входящего сообщения, каких-то специфичных данных от клиента (если таковые имеются) и коллбека на ответ. Так же введён список системных команд («help» и «modules»), которые позволяют увидеть эти самые команды в первом случае и список всех подключенных модулей во втором.Исполнение модуля выделено в отдельный тред и ограничено по времени исполнения (по умолчанию в 60 секунд), после чего тред просто прекращает своё существование.

HelloBot класс public class HelloBot { private List handlers = new List(); //за Tuple автора не пинать, ему как и вам хочется прокрастинировать, а не писать спецклассы private IDictionary>> systemCommands; private string dllMask { get; set; } private string botCommandPrefix; private int commandTimeoutSec;

public HelloBot (string dllMask = »*.dll», string botCommandPrefix = »!») { this.dllMask = dllMask; this.botCommandPrefix = botCommandPrefix; this.commandTimeoutSec = 60;

systemCommands = new Dictionary>>() { {«help», new Tuple>(«список системных команд», GetSystemCommands)}, {«modules», new Tuple>(«список кастомных модулей», GetUserDefinedCommands)}, }; RegisterModules (); }

private void RegisterModules () { handlers = GetHandlers (); }

protected virtual List GetHandlers () { List toReturn = new List(); var dlls = Directory.GetFiles (».», dllMask); var i = typeof (IActionHandlerRegister); foreach (var dll in dlls) { var ass = Assembly.LoadFile (Environment.CurrentDirectory + dll);

//get types from assembly var typesInAssembly = ass.GetTypes ().Where (x => i.IsAssignableFrom (x)).ToList ();

foreach (Type type in typesInAssembly) { object obj = Activator.CreateInstance (type); var clientHandlers = ((IActionHandlerRegister)obj).GetHandlers (); foreach (IActionHandler handler in clientHandlers) { if (handler.CallCommandList.Any ()) { toReturn.Add (handler); } } } }

return toReturn; }

public void HandleMessage (string incomingMessage, Action answerCallback, object data) { if (incomingMessage.StartsWith (botCommandPrefix)) { incomingMessage = incomingMessage.Substring (botCommandPrefix.Length); var argsSpl = incomingMessage.Split (' ');

var command = argsSpl[0];

var systemCommandList = systemCommands.Where (x => x.Key.ToLower () == command.ToLower ()).ToList (); if (systemCommandList.Any ()) { var systemComand = systemCommandList.First (); answerCallback (systemComand.Value.Item2()); } else {

var foundHandlers = FindHandler (command); foreach (IActionHandler handler in foundHandlers) { string args = incomingMessage.Substring ((command).Length).Trim (); IActionHandler hnd = handler; var cts = new CancellationTokenSource (TimeSpan.FromSeconds (commandTimeoutSec)); var token = cts.Token; Task.Run (() => { using (cts.Token.Register (Thread.CurrentThread.Abort)) { try { hnd.HandleMessage (args, data, answerCallback); } catch (Exception ex) { if (OnErrorOccured!= null) { OnErrorOccured (ex); } answerCallback (command + » пал смертью храбрых :(»); } } }, token);

} } } }

public delegate void onErrorOccuredDelegate (Exception ex); public event onErrorOccuredDelegate OnErrorOccured;

private List FindHandler (string command) { return handlers.Where (x => x.CallCommandList.Any (y=>y.Equals (command, StringComparison.OrdinalIgnoreCase))).ToList (); }

private string GetSystemCommands () { return String.Join (Environment.NewLine, systemCommands.Select (x => String.Format (»!{0} — {1}», x.Key, x.Value.Item1)).ToList ()); }

private string GetUserDefinedCommands () { return String.Join (Environment.NewLine, handlers.Select (x => String.Format (»{0} — {1}», string.Join (» / », x.CallCommandList.Select (y => botCommandPrefix + y)), x.CommandDescription)).ToList ()); } } Бот готов, остался последний штрих — связать его с консолным приложением, которое обрабатывает сообщения от скайпа.

Конечный вариант Program.cs для консольного приложения Комментарии выставлены только на новых участках кода. class Program { private static Skype skype = new Skype (); //объявляем нашего бота private static HelloBot bot;

static void Main (string[] args) { bot = new HelloBot (); //подписываемся на событие ошибки, если таковая случится bot.OnErrorOccured += BotOnErrorOccured; Task.Run (delegate { try { skype.MessageStatus += OnMessageReceived; skype.Attach (5, true); Console.WriteLine («skype attached»); } catch (Exception ex) { Console.WriteLine («top lvl exception:» + ex.ToString ()); } while (true) { Thread.Sleep (1000); } }); while (true) { Thread.Sleep (1000); } }

static void BotOnErrorOccured (Exception ex) { Console.WriteLine (ex.ToString ()); }

private static void OnMessageReceived (ChatMessage pMessage, TChatMessageStatus status) { Console.WriteLine (status + pMessage.Body); if (status == TChatMessageStatus.cmsReceived) { //отсылаем сообщения на обработку боту и указываем в качестве ответного коллбека функцию SendMessage, проксируя туда чат, откуда пришло собщение bot.HandleMessage (pMessage.Body, answer => SendMessage (answer, pMessage.Chat), new SkypeData (){FromName = pMessage.FromDisplayName}); } }

public static object _lock = new object (); private static void SendMessage (string message, Chat toChat) { //во избежании конкурентных вызовов скайпа ставим все приходящие сообщения в очередь посредством lock’а lock (_lock) { //отвечаем в чат, из которого пришло сообщение. Профит! toChat.SendMessage (message); } } } Вот собственно и всё. За пару дней коллегами и мной были написаны пару модулей. Примеры под катом.

Написанные модули ! bash выводит случайную цитату с баша! ithap выводит случайную IT историю! погода показывает текущую погоду в Минске! say говорит то, что прикажете! calc выполняет арифметические операции (через NCalc библиотеку)18+ :) ! сиськи забирает рандомную фотку с тумблера. Ну, а как же без них. К слову, одна из самых популярных команд в чате))

! курс выводит текущие курсы и через параметры может детализировать вывод. Обмен евро на usd и тд.и другие. Известные проблемыК сожалению, что-то в протоколе судя по всему поменялось и бот не видит новые групповые чаты. Старые почему-то подхватывает на ура, а вот с новыми проблема. Я пытался копаться, но решения не нашёл. Если кто подскажет как побороть эту болячку, буду благодарен.Так же иногда бывает, что сообщения теряются и скайпу нужен «прогрев», после чего он заводится и адекватно реагирует на все последующие сообщения.

Итого По итогу имеем то что имеем. Бот не зависит от клиента, поддерживает систему модулей и весь исходный код всего этого добра залит на гитхаб: https://github.com/Nigrimmist/HelloBot. Если у кого-то есть желание и время — жду пулл-реквесты ваших полезных модулей :)Бота можно потыкать палочкой по skype name: mensclubbot. Список модулей можно глянуть через »! modules».

Спасибо за внимание, надеюсь первый блин не пошёл комом и материал оказался полезным.

© Habrahabr.ru