Ejemplo n.º 1
0
    def __init__(self, header):
        """
        Create a new operator

        :header: Header required by Habitica API
        """
        self._header = header
        self._operator = HabiticaOperator(header)
Ejemplo n.º 2
0
    def __init__(self, header):
        """
        Initialize the class.

        Database operator is not created at init, because all operations don't
        need one.

        :header: Habitica requires specific fields to be present in all API
                 calls. This must be a dict containing them.
        """
        self._header = header
        self._habitica_operator = HabiticaOperator(header)
        self._logger = habot.logger.get_logger()
        self._db = None
Ejemplo n.º 3
0
class SendWinnerMessage(Functionality):
    """
    Functionality for announcing sharing weekend challenge winner.
    """
    def __init__(self):
        self.partytool = habiticatool.PartyTool(HEADER)
        self.habitica_operator = HabiticaOperator(HEADER)
        super().__init__()

    def act(self, message):
        """
        Determine who should win this week's sharing weekend challenge.

        Returns a message listing the names of participants, the seed used for
        picking the winner, and the resulting winner. In case there are no
        participants, the message just states that.
        """
        challenge_id = self.partytool.current_sharing_weekend()["id"]
        challenge = Challenge(HEADER, challenge_id)
        completer_str = challenge.completer_str()
        try:
            stock_day = utils.last_weekday_date(STOCK_DAY_NUMBER)
            winner_str = challenge.winner_str(stock_day, STOCK_NAME)

            response = completer_str + "\n\n" + winner_str
        except ValueError:
            response = (completer_str + "\n\nNobody completed the challenge, "
                        "so winner cannot be chosen.")

        self.habitica_operator.tick_task(WINNER_PICKED, task_type="habit")
        return response

    def help(self):
        return ("List participants for the current sharing weekend challenge "
                "and declare a winner from amongst them. The winner is chosen "
                "using stock data as a source of randomness.")
Ejemplo n.º 4
0
def test_operator(header_fx):
    """
    Create a HabiticaOperator for tests.
    """
    return HabiticaOperator(header_fx)
Ejemplo n.º 5
0
class SharingChallengeOperator():
    """
    Sharing Weekend challenge creator and operator.
    """

    def __init__(self, header):
        """
        Create a new operator

        :header: Header required by Habitica API
        """
        self._header = header
        self._operator = HabiticaOperator(header)

    def create_new(self):
        """
        Create a new sharing weekend challenge.

        Name, summary, description and prize are set, but no tasks are added.

        :returns: Challenge object representing the challenge
        """
        challenge_tool = ChallengeTool(self._header)
        challenge = challenge_tool.create_challenge({
            "group": self._party_id(),
            "name": self._next_weekend_name(),
            "shortName": self._next_weekend_shortname(),
            "summary": SUMMARY,
            "description": DESCRIPTION,
            "prize": 3,
            })
        try:
            self._operator.tick_task(CHALLENGE_CREATED)
        except NotFoundException:
            # It's ok to run this task without having a habit to tick
            pass
        return challenge

    def add_tasks(self, challenge, static_task_file, question_file,
                  update_questions=True):
        """
        Add sharing weekend tasks to the challenge.

        :challenge: ID of the challenge
        :questionfile: path to the file from which the weekly question is read
        :update_questions: If set to False, the used weekly question isn't
                           marked as used.
        """
        static_tasks = YAMLFileIO.read_tasks(static_task_file)

        # The challenge starts on the next Saturday, so the due date will be
        # the following Monday
        deadline = get_next_weekday("mon", from_date=get_next_weekday("sat"))

        for task in static_tasks:
            task.date = deadline
            task.create_to_challenge(challenge, self._header)

        self._add_weekly_question(challenge, question_file, deadline,
                                  update_questions)

    def _add_weekly_question(self, challenge, question_file, deadline,
                             update_questions):
        """
        Get a question, add task to the challenge, and update the question file

        :challenge: ID of the challenge to which the question is to be added.
        :question_file: A file containing question data in YAMLFileIO compliant
                        YAML format.
        :deadline: Date to be used as the due date for the task.
        :update_questions: A boolean that determines if the question file is to
                           be updated.
        """
        all_questions = YAMLFileIO.read_question_list(question_file,
                                                      unused_only=False)
        unused_questions = YAMLFileIO.read_question_list(question_file,
                                                         unused_only=True)

        selected_question = None
        try:
            selected_question = unused_questions.popitem(last=False)[0]
        except KeyError:
            pass
        if not selected_question:
            raise IndexError("There are no more unused weekly questions "
                             "in file '{}'.".format(question_file))

        selected_question.difficulty = "hard"
        selected_question.date = deadline
        selected_question.create_to_challenge(challenge, self._header)

        if update_questions:
            del all_questions[selected_question]
            all_questions[selected_question] = True
            YAMLFileIO.write_question_list(all_questions, question_file)

    def _next_weekend_name(self):
        """
        Return the name of the challenge for the next weekend.
        """
        # pylint: disable=no-self-use
        sat = get_next_weekday("saturday")
        mon = get_next_weekday("monday", from_date=sat)

        if sat.month == mon.month:
            name = "Sharing Weekend {} {}−{}".format(
                sat.strftime("%b")[:3],
                sat.strftime("%-d"),
                mon.strftime("%-d"))
        else:
            name = "Sharing Weekend {} {} − {} {}".format(
                sat.strftime("%b")[:3],
                sat.strftime("%-d"),
                mon.strftime("%b")[:3],
                mon.strftime("%-d"))
        return name

    def _next_weekend_shortname(self):
        """
        Return a short name for the next challenge.

        The name is always "sharing weekend [year]-[weeknumber]".
        """
        # pylint: disable=no-self-use
        next_saturday = get_next_weekday("saturday")
        return "sharing weekend {}-{:02d}".format(
            next_saturday.strftime("%Y"),
            next_saturday.isocalendar()[1])

    def _party_id(self):
        """
        Return the ID of the party user is currently in.
        """
        user_data = get_dict_from_api(self._header,
                                      "https://habitica.com/api/v3/user")
        return user_data["party"]["_id"]
Ejemplo n.º 6
0
 def __init__(self):
     """
     Initialize a HabiticaOperator in addition to normal init.
     """
     self.habitica_operator = HabiticaOperator(HEADER)
     super().__init__()
Ejemplo n.º 7
0
class AddTask(Functionality):
    """
    Add a new task for the bot.
    """

    def __init__(self):
        """
        Initialize a HabiticaOperator in addition to normal init.
        """
        self.habitica_operator = HabiticaOperator(HEADER)
        super().__init__()

    def act(self, message):
        """
        Add task specified by the message.

        See docstring of `help` for information about the command syntax.
        """
        if not self._sender_is_admin(message):
            return "Only administrators are allowed to add new tasks."

        try:
            task_type = self._task_type(message)
            task_text = self._task_text(message)
            task_notes = self._task_notes(message)
        except ValueError as err:
            return str(err)

        self.habitica_operator.add_task(
            task_text=task_text,
            task_notes=task_notes,
            task_type=task_type,
            )

        return ("Added a new task with the following properties:\n\n"
                "```\n"
                "type: {}\n"
                "text: {}\n"
                "notes: {}\n"
                "```".format(task_type, task_text, task_notes)
                )

    def help(self):
        return ("Add a new task for the bot. The following syntax is used for "
                "new tasks: \n\n"
                "```\n"
                "    add-task [task_type]: [task name]\n\n"
                "    [task description (optional)]\n"
                "```"
                )

    def _task_type(self, message):
        """
        Parse the task type from the command in the message.
        """
        parameter_parts = self._command_body(message).split(":", 1)

        if len(parameter_parts) < 2:
            raise ValueError("Task type missing from the command, no new "
                             "tasks added. See help:\n\n" + self.help())

        return parameter_parts[0].strip()

    def _task_text(self, message):
        """
        Parse the task name from the command in the message.
        """
        command_parts = self._command_body(message).split(":", 1)[1]
        if len(command_parts) < 2 or not command_parts[1]:
            raise ValueError("Task name missing from the command, no new "
                             "tasks added. See help:\n\n" + self.help())
        return command_parts.split("\n")[0].strip()

    def _task_notes(self, message):
        """
        Parse the task description from the command in the message.

        :returns: Task description if present, otherwise None
        """
        task_text = self._command_body(message).split(":", 1)[1]
        task_text_parts = task_text.split("\n", 1)
        if len(task_text_parts) == 1:
            return None
        return task_text_parts[1].strip()
Ejemplo n.º 8
0
class HabiticaMessager():
    """
    A class for handling Habitica messages (private and party).
    """
    def __init__(self, header):
        """
        Initialize the class.

        Database operator is not created at init, because all operations don't
        need one.

        :header: Habitica requires specific fields to be present in all API
                 calls. This must be a dict containing them.
        """
        self._header = header
        self._habitica_operator = HabiticaOperator(header)
        self._logger = habot.logger.get_logger()
        self._db = None

    def _ensure_db(self):
        """
        Make sure that a database operator is available
        """
        if not self._db:
            self._db = DBOperator()

    def _split_long_message(self, message, max_length=3000):
        """
        If the given message is too long, split it into multiple messages.

        If the message is shorter than the given max_length, the returned list
        will just contain the original message. Otherwise the message is split
        into parts, each shorter than the given max_length. Splitting is only
        done at newlines.

        :message: String containing the message body
        :max_length: Maximum length for one message. Default 3000.
        :raises: `UnsplittableMessage` if the message contains a paragraph that
                 is longer than `max_length` and thus cannot be split at a
                 newline.
        :returns: A list of strings, each string containing one piece of the
                  given message.
        """
        # pylint: disable=no-self-use

        if len(message) < max_length:
            return [message]

        split_at = "\n"
        messages = []
        while len(message) > max_length:
            split_index = message.find(split_at)
            if split_index > max_length or split_index == -1:
                raise UnsplittableMessage("Cannot find a legal split "
                                          "location in the following part "
                                          "of an outgoing message:\n"
                                          "{}".format(message))
            while split_index != -1:
                next_split_candidate = message.find(split_at, split_index + 1)
                if (next_split_candidate > max_length
                        or next_split_candidate == -1):
                    break
                split_index = next_split_candidate
            messages.append(message[:split_index])
            message = message[split_index + 1:]
        messages.append(message)
        return messages

    def send_private_message(self, to_uid, message):
        """
        Send a private message with the given content to the given user.

        After a message has been successfully sent, the bot ticks its PM
        sending habit.

        :to_uid: Habitica user ID of the recipient
        :message: The contents of the message
        """
        api_url = "https://habitica.com/api/v3/members/send-private-message"
        message_parts = self._split_long_message(message)
        if len(message_parts) > 3:
            raise SpamDetected("Sending {} messages at once is not supported."
                               "".format(len(message_parts)))
        for message_part in message_parts:
            try:
                habrequest.post(api_url,
                                headers=self._header,
                                data={
                                    "message": message_part,
                                    "toUserId": to_uid
                                })
            #  pylint: disable=invalid-name
            except requests.exceptions.HTTPError as e:
                #  pylint: disable=raise-missing-from
                raise CommunicationFailedException(str(e))

        self._habitica_operator.tick_task(PM_SENT, task_type="habit")

    def send_group_message(self, group_id, message):
        """
        Send a message with the given content to the given group.

        :group_id: UUID of the recipient group, or 'party' for current party of
                   the bot.
        :message: Contents of the message to be sent
        """
        api_url = "https://habitica.com/api/v3/groups/{}/chat".format(group_id)
        try:
            habrequest.post(api_url,
                            headers=self._header,
                            data={"message": message})
        #  pylint: disable=invalid-name
        except requests.exceptions.HTTPError as e:
            #  pylint: disable=raise-missing-from
            raise CommunicationFailedException(str(e))
        self._habitica_operator.tick_task(GROUP_MSG_SENT, task_type="habit")

    def get_party_messages(self):
        """
        Fetches party messages and stores them into the database.

        Both system messages (e.g. boss damage) and chat messages (sent by
        habiticians) are stored.
        """
        message_data = get_dict_from_api(
            self._header, "https://habitica.com/api/v3/groups/party/chat")
        messages = [None] * len(message_data)
        for i, message_dict in zip(range(len(message_data)), message_data):
            if "user" in message_dict:
                messages[i] = ChatMessage(
                    message_dict["uuid"],
                    message_dict["groupId"],
                    content=message_dict["text"],
                    message_id=message_dict["id"],
                    timestamp=datetime.utcfromtimestamp(
                        # Habitica saves party chat message times as unix time
                        # with three extra digits for milliseconds (no
                        # decimal separator)
                        message_dict["timestamp"] / 1000),
                    likers=self._marker_list(message_dict["likes"]),
                    flags=self._marker_list(message_dict["flags"]))
            else:
                messages[i] = SystemMessage(
                    message_dict["groupId"],
                    datetime.utcfromtimestamp(
                        # Habitica saves party chat message times as unix time
                        # with three extra digits for milliseconds (no
                        # decimal separator)
                        message_dict["timestamp"] / 1000),
                    content=message_dict["text"],
                    message_id=message_dict["id"],
                    likers=self._marker_list(message_dict["likes"]),
                    info=message_dict["info"])
        self._logger.debug("Fetched %d messages from Habitica API",
                           len(messages))

        new_messages = 0
        for message in messages:
            if isinstance(message, SystemMessage):
                new = self._write_system_message_to_db(message)
            elif isinstance(message, ChatMessage):
                new = self._write_chat_message_to_db(message)
            else:
                raise ValueError("Unexpected message type received from API")
            new_messages += 1 if new else 0
        self._logger.debug(
            "%d new chat/system messages written to the "
            "database", new_messages)

    def _write_system_message_to_db(self, system_message):
        """
        Add a system message to the database if not already there.

        In addition to writing the core message data, contents of the `info`
        dict are also written into their own table. All values within this
        dict, including e.g. nested dicts and integers, are coerced to strings.

        System messages can also be liked: these likes are written into `likes`
        table.

        :system_message: SystemMessage to be written to the database
        :returns: True if a new message was added to the database
        """
        self._ensure_db()
        existing_message = self._db.query_table("system_messages",
                                                condition="id='{}'".format(
                                                    system_message.message_id))
        if not existing_message:
            for key, value in system_message.info.items():
                info_data = {
                    "message_id": system_message.message_id,
                    "info_key": key,
                    "info_value": str(value),
                }
                existing_info = self._db.query_table_based_on_dict(
                    "system_message_info", info_data)
                if not existing_info:
                    self._db.insert_data("system_message_info", info_data)
            for liker in system_message.likers:
                self._write_like(system_message.message_id, liker)
            message_data = {
                "id": system_message.message_id,
                "to_group": system_message.group_id,
                "timestamp": system_message.timestamp,
                "content": system_message.content,
            }
            self._db.insert_data("system_messages", message_data)
            return True
        return False

    def _write_chat_message_to_db(self, chat_message):
        """
        Add a chat message to the database if not already there.

        At this point, all chat messages are marked as not requiring a
        reaction.

        :chat_message: ChatMessage to be written to the database
        :returns: True if a new message was added to database, otherwise False
        """
        self._ensure_db()
        existing_message = self._db.query_table("chat_messages",
                                                condition="id='{}'".format(
                                                    chat_message.message_id))
        if not existing_message:
            for liker in chat_message.likers:
                self._write_like(chat_message.message_id, liker)
            for flagger in chat_message.flags:
                self._write_like(chat_message.message_id, flagger)
            db_data = {
                "id": chat_message.message_id,
                "from_id": chat_message.from_id,
                "to_group": chat_message.group_id,
                "content": chat_message.content,
                "timestamp": chat_message.timestamp,
                "reaction_pending": 0,
            }
            self._db.insert_data("chat_messages", db_data)
            return True
        return False

    def _marker_list(self, user_dict):
        """
        Return a list of users who have liked/flagged a message.

        This list is parsed from the given user_dict, which has UIDs as keys
        and True/False as the value depending on whether the given user has
        marked that message as liked/flagged. This is the format Habitica
        reports likes for party messages.
        """
        # pylint: disable=no-self-use
        return [uid for uid in user_dict if user_dict[uid]]

    def _write_like(self, message_id, user_id):
        """
        Add information about a person liking a message into the db.

        If the row already exists, it is not inserted again.

        :message_id: The liked message
        :user_id: The person who hit the like button
        """
        self._ensure_db()
        like_dict = {"message": message_id, "user": user_id}
        existing_like = self._db.query_table_based_on_dict("likes", like_dict)
        if not existing_like:
            self._db.insert_data("likes", like_dict)

    def _write_flag(self, message_id, user_id):
        """
        Add information about a person reporting a message into the db.

        If the row already exists, it is not inserted again.

        :message_id: The reported message
        :user_id: The person who reported the message
        """
        self._ensure_db()
        flag_dict = {"message": message_id, "user": user_id}
        existing_flag = self._db.query_table_based_on_dict("flags", flag_dict)
        if not existing_flag:
            self._db.insert_data("flags", flag_dict)

    def get_private_messages(self):
        """
        Fetch private messages using Habitica API.

        If there are new messages, they are written to the database and
        returned.

        No paging is implemented: all new messages are assumed to fit into the
        returned data from the API.
        """
        try:
            message_data = get_dict_from_api(
                self._header, "https://habitica.com/api/v3/inbox/messages")
        except requests.exceptions.HTTPError as err:
            raise CommunicationFailedException(err.response) from err

        messages = [None] * len(message_data)
        for i, message_dict in zip(range(len(message_data)), message_data):
            if message_dict["sent"]:
                recipient = message_dict["uuid"]
                sender = message_dict["ownerId"]
            else:
                recipient = message_dict["ownerId"]
                sender = message_dict["uuid"]
            messages[i] = PrivateMessage(sender,
                                         recipient,
                                         timestamp=timestamp_to_datetime(
                                             message_dict["timestamp"]),
                                         content=message_dict["text"],
                                         message_id=message_dict["id"])
        self._logger.debug("Fetched %d messages from Habitica API",
                           len(messages))
        self.add_PMs_to_db(messages)

    def add_PMs_to_db(self, messages):
        """
        Write all given private messages to the database.

        New messages not sent by this user are marked as
        reaction_pending=True if they have not already been responded to (i.e.
        a newer message sent to the same user is present in the database).
        If none of the given messages are present in the database, returns
        True to signal that fetching more messages might be necessary.
        Otherwise returns False.

        :messages: `PrivateMessage`s to be added to the database
        :returns: True if all of the messages were new (not already in the db),
                  otherwise False
        """
        # pylint: disable=invalid-name
        self._ensure_db()
        all_new = True
        for message in messages:
            existing_message = self._db.query_table("private_messages",
                                                    condition="id='{}'".format(
                                                        message.message_id))
            if not existing_message:
                self._logger.debug("message.from_id = %s", message.from_id)
                self._logger.debug("id of x-api-user: %s",
                                   self._header["x-api-user"])
                if (message.from_id == self._header["x-api-user"]
                        or self._has_newer_sent_message_in_db(
                            message.from_id, message.timestamp)):
                    reaction_pending = 0
                else:
                    reaction_pending = 1
                self._logger.debug(
                    "Adding new message to the database: '%s', "
                    "reaction_pending=%d", message.excerpt(), reaction_pending)
                db_data = {
                    "id": message.message_id,
                    "from_id": message.from_id,
                    "to_id": message.to_id,
                    "content": message.content,
                    "timestamp": message.timestamp,
                    "reaction_pending": reaction_pending,
                }
                self._db.insert_data("private_messages", db_data)
            else:
                all_new = False
        return all_new

    @classmethod
    def set_reaction_pending(cls, message, reaction):
        """
        Set reaction_pending field in the DB for a given message.

        :message: A Message for which database is altered
        :reaction: True/False for reaction pending
        """
        db = DBOperator()
        db.update_row("private_messages", message.message_id,
                      {"reaction_pending": reaction})

    def _has_newer_sent_message_in_db(self, to_id, timestamp):
        """
        Return True if `to_id` has been sent a message after `timestamp`.

        This is checked from the database, so if a message has not yet been
        processed into the DB, it won't affect the result. However, if the
        messages are processed from newest to oldest, this function can be used
        to determine if a "new" message has already been responded to.

        :to_id: Habitica user UID
        :timestamp: datetime after which to look for messages
        """
        self._ensure_db()
        sent_messages = self._db.query_table(
            "private_messages",
            condition="timestamp>'{}' AND to_id='{}'".format(timestamp, to_id))
        if sent_messages:
            return True
        return False
Ejemplo n.º 9
0
 def __init__(self):
     self.partytool = habiticatool.PartyTool(HEADER)
     self.habitica_operator = HabiticaOperator(HEADER)
     super().__init__()
Ejemplo n.º 10
0
def cron():
    """
    Run cron
    """
    operator = HabiticaOperator(HEADER)
    operator.cron()
Ejemplo n.º 11
0
def join_quest():
    """
    Join challenge if there is one to be joined.
    """
    operator = HabiticaOperator(HEADER)
    operator.join_quest()