Статьи

Создание чат-приложений и роботов для Skype

Командная строка — это мощный интерфейс. Каждый, кто впервые погрузился в UNIX и выжил, чтобы рассказать об этом, может это подтвердить. Однако вам не нужно быть компьютерным гуру, чтобы взаимодействовать с командной строкой. В этой записи блога я хочу открыть для вас окно возможности раскрыть свой потенциал в чатах (Skype). Обратите внимание, что этот пост не ограничивается рамками Skype, но принципы, представленные здесь, могут быть применены к любому чату, который использует ваша команда.

sevabot-256

1. Зачем создавать чат-робота?

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

  • Адресная строка браузера ( Awesome Bar в Firefox добился потрясающего прогресса в переносе моей когнитивной нагрузки в адресную строку браузера)
  • Окно поиска в поисковой системе (автозаполнение, но DuckDuckGo предлагает здесь еще больше удивительных трюков )
  • Поле ввода Microsoft Excel или подобное
  • Вход в чат и обмен мгновенными сообщениями
  • и т.п.

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

2. Skype-чат, универсальная командная строка

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

Мы могли бы использовать хорошее веб-приложение или даже мобильное приложение, чтобы отслеживать задачи, над которыми все работают. Однако именно этот менеджер подумал: «Это слишком тяжелый вес». Экономическое обоснование состояло в том, что, когда ему звонит клиент, спрашивающий, выполнена ли эта задача или еще не запущена, он должен знать об этом в данный момент. Организация небольшая, каждый человек — гик и занимается «Принеси свои собственные устройства», поэтому приложение должно быть доступно всем, что у тебя под рукой.

Менеджер видел некоторые более ранние попытки автоматизации, которые мы предприняли с ботом чата Skype, Sevabot. Поэтому он спросил меня, можем ли мы создать приложение со списком задач, которое будет упрощено в чате через Skype: просто отметьте все, над чем вы работаете, и отметьте снова, когда вы закончите, и все это с помощью команд чата Skype.

Преимущества этого подхода

  • Все в команде уже использовали Skype: люди знакомы с ним
  • Skype известен своей работой на любом устройстве, в любой сети, везде
  • Синхронизация Skype: даже в автономном режиме у вас есть некоторые статусные сообщения на вашем мобильном устройстве
  • Командная строка эффективна: запись сообщения чата для вывода текущих задач может быть выполнена, даже если вы разговариваете по мобильному телефону. Это буквально занимает секунду.
  • Написание простого приложения на основе команд имеет очень хорошее соотношение затрат и выгод: написание текстовых приложений — это быстрая задача, которую можно выполнить за пару часов (в отличие от создания веб-приложения, некоторых форм и т. Д.)

И вот как это работает:

Снимок экрана 2013-03-28 в 1.47.40 вечера

3. Создание приложения чата с сохранением состояния с помощью Sevabot

Sevabot — это постоянный проект бота Skype с открытым исходным кодом . Раньше (версия 1.2) уже поддерживала запуск сценариев UNIX с помощью команд чата и HTTP-подключений для отправки уведомлений чата из внешних служб . Задача, стоящая перед нами, могла быть создана с небольшим количеством магии сценариев UNIX. Однако я связывался с Наото Йокоямой, который нуждался в более сложных командах чата в Skype, кроме простых триггеров. Основываясь на обсуждении и оригинальной работе Наото, мы создали поддержку сценариев в чате для Sevabot .

  • Вы определяете свое (сохраняющее состояние) приложение чата как простой модуль и класс Python
  • Модули перезаряжаемые
  • Установка нового сценария с состоянием проста, как перетащить файл .py в правильную папку
  • Сценарий может устанавливать обработчики событий для всех необработанных событий Skype4Py , включая обработку вызовов Skype
  • Вы можете использовать фоновые процессы, таймеры, потоки, собственные расширения и всю мощь обычных долгосрочных приложений Python UNIX

Ниже приведен исходный код Python для приложения для управления задачами, описанного выше.

Некоторые заметки о модуле

  • Мы сохраняем состояние скрипта на диске. Sevabot работает в облаке и может быть перезапущен в любой момент. Мы просто используем Python pickles , здесь базы данных не нужны.
  • Каждый групповой чат имеет свой собственный список задач, поэтому можно мультиплексировать один экземпляр бота в разных командах и рабочих группах.
  • Существует таймер, который будет толкать людей, если они забыли закрыть задачи, которые они начали.

4. Будущее

Далее я хотел бы попробовать следующее

5. Исходный код

Исходный код модуля также находится на Github и распространяется вместе с Sevabot 1.2 .

#!/sevabot
"""

    Simple group chat task manager.

    This also serves as an example how to write stateful handlers.

"""

from __future__ import unicode_literals

from threading import Timer
from datetime import datetime
import os
import logging
import pickle
from collections import OrderedDict

from sevabot.bot.stateful import StatefulSkypeHandler
from sevabot.utils import ensure_unicode, get_chat_id

logger = logging.getLogger("Tasks")

# Set to debug only during dev
logger.setLevel(logging.INFO)

logger.debug("Tasks module level load import")

# How long one can work on a task before we give a warning
MAX_TASK_DURATION = 24*60*60

HELP_TEXT = """!tasks is a noteboard where virtual team members can share info which tasks they are currently working on.

Commands
------------------------------

!tasks: This help text

Start task: You start working on a task. When you started is recorded. Example:

    start task I am now working on new Sevabot module interface

Stop task: Stop working on the current task. Example:

    stop task

List tasks: List all tasks an people working on them. Example:

    list tasks

Task lists are chat specific and the list is secure to the members of the chat.
All commands are case-insensitive.
"""

class TasksHandler(StatefulSkypeHandler):
    """
    Skype message handler class for the task manager.
    """

    def __init__(self):
        """Use `init` method to initialize a handler.
        """
        logger.debug("Tasks constructed")

    def init(self, sevabot):
        """
        Set-up our state. This is called

        :param skype: Handle to Skype4Py instance
        """
        logger.debug("Tasks init")
        self.sevabot = sevabot
        self.status_file = os.path.join(os.path.dirname(__file__), "sevabot-tasks.tmp")
        self.status = Status.read(self.status_file)

        self.commands = {
            "!tasks": self.help,
            "start task": self.start_task,
            "list tasks": self.list_tasks,
            "stop task": self.stop_task,
        }

        self.reset_timeout_notifier()

    def handle_message(self, msg, status):
        """Override this method to customize a handler.
        """

        # Skype API may give different encodings
        # on different platforms
        body = ensure_unicode(msg.Body)

        logger.debug("Tasks handler got: %s" % body)

        # Parse the chat message to commanding part and arguments
        words = body.split(" ")
        lower = body.lower()

        if len(words) == 0:
            return False

        # Parse argument for two part command names
        if len(words) >= 2:
            desc = " ".join(words[2:])
        else:
            desc = None

        chat_id = get_chat_id(msg.Chat)

        # Check if we match any of our commands
        for name, cmd in self.commands.items():
            if lower.startswith(name):
                cmd(msg, status, desc, chat_id)
                return True

        return False

    def shutdown(self):
        """ Called when the module is reloaded.
        """
        logger.debug("Tasks shutdown")
        self.stop_timeout_notifier()

    def save(self):
        """
        Persistent our state.
        """
        Status.write(self.status_file, self.status)

    def reset_timeout_notifier(self):
        """
        Check every minute if there are overdue jobs
        """
        self.notifier = Timer(60.0, self.check_overdue_jobs)
        self.notifier.daemon = True  # Make sure CTRL+C works and does not leave timer blocking it
        self.notifier.start()

    def stop_timeout_notifier(self):
        """
        """
        self.notifier.cancel()

    def help(self, msg, status, desc, chat_id):
        """
        Print help text to chat.
        """

        # Make sure we don't trigger ourselves with the help text
        if not desc:
            msg.Chat.SendMessage(HELP_TEXT)

    def warn_overdue(self, chat_id, job):
        """
        Generate overdue warning.
        """
        self.sevabot.sendMessage(chat_id, "Task hanging: %s started working on %s, %s" % (job.real_name, job.desc, pretty_time_delta(job.started)))
        job.warned = True

    def check_overdue_jobs(self):
        """
        Timer callback to go through jobs which might be not going forward.
        """

        found = False

        logger.debug("Running overdue check")

        now = datetime.now()

        for chat_id, chat in self.status.chats.items():
            for job in chat.values():
                if (now - job.started).total_seconds() > MAX_TASK_DURATION and not job.warned:
                    found = True
                    self.warn_overdue(chat_id, job)

        if found:
            logger.debug("Found overdue jobs")
            self.save()
        else:
            logger.debug("Did not found overdue jobs")

        # http://www.youtube.com/watch?v=ZEQydmaPjF0
        self.reset_timeout_notifier()

    def start_task(self, msg, status, desc, chat_id):
        """
        Command handler.
        """

        if desc.strip() == "":
            msg.Chat.SendMessage("Please give task description also")
            return

        tasks = self.status.get_tasks(chat_id)
        existing_job = tasks.get(msg.Sender.Handle, None)
        if existing_job:
            msg.Chat.SendMessage("Stopped existing task %s" % existing_job.desc)

        job = Job(msg.Sender.FullName, datetime.now(), desc)
        tasks = self.status.get_tasks(chat_id)
        tasks[msg.Sender.Handle] = job
        self.save()
        msg.Chat.SendMessage("%s started working on %s." % (job.real_name, job.desc))

    def list_tasks(self, msg, status, desc, chat_id):
        """
        Command handler.
        """

        jobs = self.status.get_tasks(chat_id).values()

        if len(jobs) == 0:
            msg.Chat.SendMessage("No active tasks for anybody")

        for job in jobs:
            msg.Chat.SendMessage("%s started working on %s, %s" % (job.real_name, job.desc, pretty_time_delta(job.started)))

    def stop_task(self, msg, status, desc, chat_id):
        """
        Command handler.
        """
        tasks = self.status.get_tasks(chat_id)
        if msg.Sender.Handle in tasks:
            job = tasks[msg.Sender.Handle]
            del tasks[msg.Sender.Handle]
            msg.Chat.SendMessage("%s finished" % job.desc)
        else:
            msg.Chat.SendMessage("%s had no active task" % msg.Sender.FullName)

        self.save()

class Status:
    """
    Stored pickled state of the tasks.

    Use Python pickling serialization for making status info persistent.
    """

    def __init__(self):
        # Chat id -> OrderedDict() of jobs mappings
        self.chats = dict()

    @classmethod
    def read(cls, path):
        """
        Read status file.

        Return fresh status if file does not exist.
        """

        if not os.path.exists(path):
            # Status file do not exist, get default status
            return Status()

        f = open(path, "rb")

        try:
            return pickle.load(f)
        finally:
            f.close()

    @classmethod
    def write(cls, path, status):
        """
        Write status file
        """
        f = open(path, "wb")
        pickle.dump(status, f)
        f.close()

    def get_tasks(self, chat_id):
        """
        Get jobs of a particular chat.
        """
        if not chat_id in self.chats:
            # Skype username -> Task instance mappings
            self.chats[chat_id] = OrderedDict()

        return self.chats[chat_id]

class Job:
    """
    Tracks who is doing what
    """

    def __init__(self, real_name, started, desc):
        """
        :param started: datetime when the job was started
        """
        self.started = started
        self.desc = desc
        self.real_name = real_name
        # Have we given timeout warning for this job
        self.warned = False

# The following has been
# ripped off from https://github.com/imtapps/django-pretty-times/blob/master/pretty_times/pretty.py

_ = lambda x: x

def pretty_time_delta(time):

    now = datetime.now(time.tzinfo)

    if time > now:
        past = False
        diff = time - now
    else:
        past = True
        diff = now - time

    days = diff.days

    if days is 0:
        return get_small_increments(diff.seconds, past)
    else:
        return get_large_increments(days, past)

def get_small_increments(seconds, past):
    if seconds < 10:
        result = _('just now')
    elif seconds < 60:
        result = _pretty_format(seconds, 1, _('seconds'), past)
    elif seconds < 120:
        result = past and _('a minute ago') or _('in a minute')
    elif seconds < 3600:
        result = _pretty_format(seconds, 60, _('minutes'), past)
    elif seconds < 7200:
        result = past and _('an hour ago') or _('in an hour')
    else:
        result = _pretty_format(seconds, 3600, _('hours'), past)
    return result

def get_large_increments(days, past):
    if days == 1:
        result = past and _('yesterday') or _('tomorrow')
    elif days < 7:
        result = _pretty_format(days, 1, _('days'), past)
    elif days < 14:
        result = past and _('last week') or _('next week')
    elif days < 31:
        result = _pretty_format(days, 7, _('weeks'), past)
    elif days < 61:
        result = past and _('last month') or _('next month')
    elif days < 365:
        result = _pretty_format(days, 30, _('months'), past)
    elif days < 730:
        result = past and _('last year') or _('next year')
    else:
        result = _pretty_format(days, 365, _('years'), past)
    return result

def _pretty_format(diff_amount, units, text, past):
    pretty_time = (diff_amount + units / 2) / units

    if past:
        base = "%(amount)d %(quantity)s ago"
    else:
        base = "%(amount)d %(quantity)s"

    return base % dict(amount=pretty_time, quantity=text)

# Export the instance to Sevabot
sevabot_handler = TasksHandler()

__all__ = ["sevabot_handler"]

Наслаждаться.