alert('Привет Хабр!');Давно уже засела мысль сделать эдакую тулзу-помощника, которая смогла бы мне и курсы валют вывести и погоду подсказать и анекдот затравить, да всё руки не доходили… ну вы же знаете как это бывает, верно? Кроме того, в моём бесконечном списке с забавными идеями, которые неплохо бы когда-нибудь реализовать — был пункт «бот для скайпа 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
Он будет отвечать за классы-обработчики сообщений.
где CallCommandList это список команд по которым будет вызван HandleMessage, CommandDescription нужен для вывода описания в команде !modules (об этом ниже) и HandleMessage — где модуль должен обработать входящие параметры (args), передав ответ в коллбек sendMessageFunc
public interface IActionHandler { List <string> CallCommandList { get;} string CommandDescription { get; } void HandleMessage(string args, object clientData, Action<string> sendMessageFunc); }
где CallCommandList это список команд по которым будет вызван HandleMessage, CommandDescription нужен для вывода описания в команде !modules (об этом ниже) и HandleMessage — где модуль должен обработать входящие параметры (args), передав ответ в коллбек sendMessageFunc
IActionHandlerRegister
Он будет отвечать за регистрацию наших обработчиков.
public interface IActionHandlerRegister { List<IActionHandler> GetHandlers(); }
ISkypeData
Он будет отвечать за дополнительную информацию о клиенте, в данном случае — о скайпе, если таковая необходима обработчику.
public interface ISkypeData { string FromName { get; set; } }
Смысл этого всего вот в чём: разработчик создаёт свою .dll, подключает нашу библиотеку для коммуникации, наследуется от IActionHandler и IActionHandlerRegister и реализует нужный ему функционал не думая о всём том, что лежит выше.
Пример
Пример в виде модуля команды «скажи», который заставит бота сказать всё что будет после самой команды.
public class Say : IActionHandler { private Random r = new Random(); private List<string> answers = new List<string>() { "Вот сам и скажи", "Ищи дурака", "Зачем?", "5$", "Нет, спасибо", }; public List<string> CallCommandList { get { return new List<string>() { "скажи", "say" }; } } public string CommandDescription { get { return @"Говорит что прикажете"; } } public void HandleMessage(string args, object clientData, Action<string> 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<IActionHandler> handlers = new List<IActionHandler>(); //за Tuple автора не пинать, ему как и вам хочется прокрастинировать, а не писать спецклассы private IDictionary<string, Tuple<string, Func<string>>> 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<string, Tuple<string, Func<string>>>() { {"help", new Tuple<string, Func<string>>("список системных команд", GetSystemCommands)}, {"modules", new Tuple<string, Func<string>>("список кастомных модулей", GetUserDefinedCommands)}, }; RegisterModules(); } private void RegisterModules() { handlers = GetHandlers(); } protected virtual List<IActionHandler> GetHandlers() { List<IActionHandler> toReturn = new List<IActionHandler>(); 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<string> 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<IActionHandler> 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 библиотеку)
! курс выводит текущие курсы и через параметры может детализировать вывод. Обмен евро на usd и тд.
и другие.
!ithap выводит случайную IT историю
! погода показывает текущую погоду в Минске
!say говорит то, что прикажете
!calc выполняет арифметические операции (через NCalc библиотеку)
18+ :)
! сиськи забирает рандомную фотку с тумблера. Ну а как же без них. К слову, одна из самых популярных команд в чате ))
! курс выводит текущие курсы и через параметры может детализировать вывод. Обмен евро на usd и тд.
и другие.
Известные проблемы
К сожалению, что-то в протоколе судя по всему поменялось и бот не видит новые групповые чаты. Старые почему-то подхватывает на ура, а вот с новыми проблема. Я пытался копаться, но решения не нашёл. Если кто подскажет как побороть эту болячку, буду благодарен.
Так же иногда бывает, что сообщения теряются и скайпу нужен «прогрев», после чего он заводится и адекватно реагирует на все последующие сообщения.
Итого
По итогу имеем то что имеем. Бот не зависит от клиента, поддерживает систему модулей и весь исходный код всего этого добра залит на гитхаб: https://github.com/Nigrimmist/HelloBot. Если у кого-то есть желание и время — жду пулл-реквесты ваших полезных модулей :)
Бота можно потыкать палочкой по skype name: mensclubbot. Авторизация не требуется. Список модулей можно глянуть через "!modules". Крутится на хостинге, поэтому работает 24h.
Спасибо за внимание, надеюсь первый блин не пошёл комом и материал оказался полезным.