def __init__(self, header): """ Create a new operator :header: Header required by Habitica API """ self._header = header self._operator = HabiticaOperator(header)
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
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.")
def test_operator(header_fx): """ Create a HabiticaOperator for tests. """ return HabiticaOperator(header_fx)
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"]
def __init__(self): """ Initialize a HabiticaOperator in addition to normal init. """ self.habitica_operator = HabiticaOperator(HEADER) super().__init__()
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()
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
def __init__(self): self.partytool = habiticatool.PartyTool(HEADER) self.habitica_operator = HabiticaOperator(HEADER) super().__init__()
def cron(): """ Run cron """ operator = HabiticaOperator(HEADER) operator.cron()
def join_quest(): """ Join challenge if there is one to be joined. """ operator = HabiticaOperator(HEADER) operator.join_quest()