alert('Привет!');
Давно уже засела мысль сделать эдакую тулзу-помощника, которая смогла бы мне и курсы валют вывести и погоду подсказать и анекдот затравить, да всё руки не доходили… ну вы же знаете как это бывает, верно? Кроме того, в моём бесконечном списке с забавными идеями, которые неплохо бы когда-нибудь реализовать — был пункт «бот для скайпа 4fun».
Руки дошли. Речь пойдёт о написании простого модульного бота на C# с интеграцией в Skype. Что получилось в итоге, а также почему стоит выключать системник от сети перед тем как в него лезть отвёрткой — читайте под катом.
Предисловие
Казалось бы, причем тут системный блок и отвёртка? Ну так вот… Одним томным вечером разобрал я системник дабы смазать кулер на блоке питания (шумел аки жук). Смазал, проверил, что всё крутится-вертится и радует ухо. Начал собирать всё в исходное состояние и… не удосужился отключить его от сети. За что судьба вознаградила меня звёздами в глазах, тратой энной суммы на новый бпшник и… решением наконец написать первую за 4 года статью на любимый хабр. Просьба сильно не пинать, чукча автор не очень писатель.
Для тех, кому лень читать всю статью: все сорцы тут: https://github.com/Nigrimmist/HelloBot и инструкция по
Вступление
На очередных выходных, немного подустав пилить своё детище — очередного убийцу фейсбуков, я решил подобрать что-нибудь для души да реализовать. Выбор пал на бота для скайпа. Решил писать сразу с заделом на расширяемость, дабы коллеги могли дописать те модули бота, которые нужны непосредственно им.
К слову, состою я в одном 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. Он будет служить мостом между модулями и ботом. Помещаем туда три интерфейса:
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
public interface IActionHandlerRegister
{
List<IActionHandler> GetHandlers();
}
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 секунд), после чего тред просто прекращает своё существование.
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());
}
}
Бот готов, остался последний штрих — связать его с консолным приложением, которое обрабатывает сообщения от скайпа.
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);
}
}
}
Вот собственно и всё. За пару дней коллегами и мной были написаны пару модулей. Примеры под катом.
!ithap выводит случайную IT историю
! погода показывает текущую погоду в Минске
!say говорит то, что прикажете
!calc выполняет арифметические операции (через NCalc библиотеку)
! курс выводит текущие курсы и через параметры может детализировать вывод. Обмен евро на usd и тд.
и другие.
Известные проблемы
К сожалению, что-то в протоколе судя по всему поменялось и бот не видит новые групповые чаты. Старые почему-то подхватывает на ура, а вот с новыми проблема. Я пытался копаться, но решения не нашёл. Если кто подскажет как побороть эту болячку, буду благодарен.
Так же иногда бывает, что сообщения теряются и скайпу нужен «прогрев», после чего он заводится и адекватно реагирует на все последующие сообщения.
Итого
По итогу имеем то что имеем. Бот не зависит от клиента, поддерживает систему модулей и весь исходный код всего этого добра залит на гитхаб: https://github.com/Nigrimmist/HelloBot. Если у кого-то есть желание и время — жду пулл-реквесты ваших полезных модулей :)
Бота можно потыкать палочкой по skype name: mensclubbot. Список модулей можно глянуть через "!modules".
Спасибо за внимание, надеюсь первый блин не пошёл комом и материал оказался полезным.
Автор: Nigrimmist