Пишем 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 <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 библиотеку)
18+ :)
! сиськи забирает рандомную фотку с тумблера. Ну а как же без них. К слову, одна из самых популярных команд в чате ))

! курс выводит текущие курсы и через параметры может детализировать вывод. Обмен евро на usd и тд.
и другие.


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

Итого


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

Бота можно потыкать палочкой по skype name: mensclubbot. Авторизация не требуется. Список модулей можно глянуть через "!modules". Крутится на хостинге, поэтому работает 24h.

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

Similar posts

Comments 29

    +1
    А чего Skype4COM, чего не через текстовое API? Было бы текстовое API, портирование на Linux/Mac было бы тривиальным.
      +5
      А, да, можно ещё жить без регистрации Skype4COM, достаточно в reference на него поставить галку и студия сгенерит соответствующий манифест для exe, но тогда работать с ним можно будет только из STA-потока.
        +2
        Спасибо, не знал.
        0
        А ещё скайп хранит всё своё добро в sqlite, если не путаю. В теории (и на практике тоже) — можно напрямую туда писать и читать.

        UPD: а вот вроде и сама статья: habrahabr.ru/post/160315/
          0
          доступ в последних версиях блокируется процессом скайпа даже на чтение, насколько я понял.
            0
            только что проверил — main.db читается без проблем при запущенном скайпе 6.20.0.104
              0
              У меня утилита SkypeLogView от nirsoft как то перестала читать историю при запущенном скайпе с какой то версии, от чего я и сделал такой вывод.
          0
          А что вы подразумеваете под текстовым API? Приведите ссылки, если не сложно.
          Skype4com выбрал по частоте выдачи его как решения проблемы коммуникации с апи.
        0
        почитайте про MEF, чтобы не городить огород с assembly.GetTypes().
          0
          Ещё лучше взять не MEF, а нормальный DI-контейнер.
            0
            а как в нормальном DI-контейнере получить инстансы всех наследников без ручной регистрации?
              0
              Использовать авторегистрацию. Конкретный код зависит от конкретного контейнера, знаете ли.
                0
                Простите, кэп, я думал вы и «нормальный» контейнер назовёте и кодом к нему похвастаетесь.
                  0
                  Так их много разных же. Unity, Ninject, Autofac, StructureMap. И везде нормально работает авторегистрация.
                    0
                    Ninject только жуткой тормозной. По крайней мере был год назад
            +3
            Вы мне советуете то, что я в статье сам посоветовал всем? :) Но в любом случае Вы правы, MEF — хороший и правильный выход.
            +1
            Блин, а я уж обрадовался, думал, что вы к новому Скайпу как-то подключились.
            RIP Skype API, ты был хорошим помощником.
              +4
              Вот тут новый скайп распотрошили, открутив gui от собственно скайпа. Сможете отреверсить вызовы между exe и dll — подключитесь.
                +8
                Будет как в одной сисадминской байке:
                — Был сервер на винде и часто зависал
                — Рядом стоял другой, уже надежный сервер, который проверял состояние первого
                — Как только сервер зависал, из другого выезжал лоток CD-ROM и нажимал на кнопку Reset.
                Вот примерно таким извратом и будет автоматизироваться новый Skype =)
                0
                а Восяню помнит кто? может отрефакторим под новые api?
                  0
                  А можно как-то сделать автоматическую авторизацию? Или есть какой-то другой способ добавить этого бота к чату в скайпе?
                    0
                    Для тех, кому .Net не близок: skype4py
                      +1
                      Вопрос может глупый, но видимо опыта не хватает: для чего надо каждую секунду замораживать потоки?
                        0
                        Это самый простой способ запретить им завершаться:) Как вариант можно было написать и через WaitHandle
                        0
                        Есть ли возможность сконфигруровать бота для работы с госпожой женщиной-пользователем?
                          0
                          Что Вы под этим подразумеваете?:)
                            0
                            «Чего желаете, моя госпожа?» — круто звучит
                              0
                              Всегда можно сделать форк и переделать как надобно ;)

                        Only users with full accounts can post comments. Log in, please.