diff --git a/bwh_bot/api/telegram.py b/bwh_bot/api/telegram.py index dafca04..df17945 100644 --- a/bwh_bot/api/telegram.py +++ b/bwh_bot/api/telegram.py @@ -4,17 +4,18 @@ import frappe COMMAND_HANDLERS = {} +COMMAND_DESCRIPTIONS = {} CALLBACK_HANDLERS = {} def register_command(command, description=None): def decorator(fn): COMMAND_HANDLERS[command] = fn + # Keyed by command rather than stashed on the function: every conversation + # registers the same bound BotConversation.handle_command, so an attribute + # on the function is shared and the last registration wins for all of them. if description: - try: - fn._description = description - except AttributeError: - fn.__func__._description = description + COMMAND_DESCRIPTIONS[command] = description return fn return decorator @@ -28,15 +29,12 @@ def decorator(fn): return decorator -# import handlers to register them -from bwh_bot.handlers import ping # noqa: F401, E402 - -# import conversation handlers — auto-registers via __init_subclass__ -from bwh_bot.handlers import leave, petty_cash, wfh # noqa: F401, E402 - -# register conversation handlers into COMMAND_HANDLERS and CALLBACK_HANDLERS +# Importing the handler modules is what registers them: ping registers a plain +# command, and the conversation subclasses self-register via __init_subclass__. from bwh_bot.conversation import CONVERSATION_HANDLERS +from bwh_bot.handlers import hive, leave, petty_cash, ping, wfh +# Register conversation handlers into COMMAND_HANDLERS and CALLBACK_HANDLERS. for _handler in CONVERSATION_HANDLERS.values(): if _handler.command: register_command(_handler.command, _handler.command_description)(_handler.handle_command) @@ -44,7 +42,11 @@ def decorator(fn): register_callback(_handler.callback_prefix)(_handler.handle_callback) -@frappe.whitelist(allow_guest=True) +# Telegram calls this webhook unauthenticated by design, so it cannot require a +# session. The shared secret checked below (X-Telegram-Bot-Api-Secret-Token) is +# what authenticates the request, and each update is gated again by the chat +# whitelist before any handler runs. +@frappe.whitelist(allow_guest=True) # nosemgrep def hook(**kwargs): try: settings = frappe.get_single("BWH Bot Settings") @@ -80,21 +82,25 @@ def hook(**kwargs): if message.get("date"): message_date = datetime.fromtimestamp(message["date"]) - doc = frappe.get_doc({ - "doctype": "Telegram Webhook Log", - "update_type": update_type, - "chat_id": str(chat.get("id", "")), - "chat_title": chat.get("title", ""), - "telegram_user_id": str(telegram_user.get("id", "")), - "telegram_username": telegram_user.get("username", ""), - "telegram_user_name": f"{telegram_user.get('first_name', '')} {telegram_user.get('last_name', '')}".strip(), - "command": command, - "message_text": text, - "message_id": str(message.get("message_id", "")), - "message_thread_id": str(message["message_thread_id"]) if message.get("message_thread_id") else None, - "message_date": message_date, - "payload": json.dumps(data, indent=2), - }) + doc = frappe.get_doc( + { + "doctype": "Telegram Webhook Log", + "update_type": update_type, + "chat_id": str(chat.get("id", "")), + "chat_title": chat.get("title", ""), + "telegram_user_id": str(telegram_user.get("id", "")), + "telegram_username": telegram_user.get("username", ""), + "telegram_user_name": f"{telegram_user.get('first_name', '')} {telegram_user.get('last_name', '')}".strip(), + "command": command, + "message_text": text, + "message_id": str(message.get("message_id", "")), + "message_thread_id": str(message["message_thread_id"]) + if message.get("message_thread_id") + else None, + "message_date": message_date, + "payload": json.dumps(data, indent=2), + } + ) doc.insert(ignore_permissions=True) frappe.db.commit() except Exception: @@ -112,20 +118,24 @@ def _handle_callback_query(data, callback_query): if message.get("date"): message_date = datetime.fromtimestamp(message["date"]) - doc = frappe.get_doc({ - "doctype": "Telegram Webhook Log", - "update_type": "callback_query", - "chat_id": str(chat.get("id", "")), - "chat_title": chat.get("title", ""), - "telegram_user_id": str(telegram_user.get("id", "")), - "telegram_username": telegram_user.get("username", ""), - "telegram_user_name": f"{telegram_user.get('first_name', '')} {telegram_user.get('last_name', '')}".strip(), - "callback_query_id": callback_query.get("id", ""), - "callback_data": callback_query.get("data", ""), - "message_id": str(message.get("message_id", "")), - "message_thread_id": str(message["message_thread_id"]) if message.get("message_thread_id") else None, - "message_date": message_date, - "payload": json.dumps(data, indent=2), - }) + doc = frappe.get_doc( + { + "doctype": "Telegram Webhook Log", + "update_type": "callback_query", + "chat_id": str(chat.get("id", "")), + "chat_title": chat.get("title", ""), + "telegram_user_id": str(telegram_user.get("id", "")), + "telegram_username": telegram_user.get("username", ""), + "telegram_user_name": f"{telegram_user.get('first_name', '')} {telegram_user.get('last_name', '')}".strip(), + "callback_query_id": callback_query.get("id", ""), + "callback_data": callback_query.get("data", ""), + "message_id": str(message.get("message_id", "")), + "message_thread_id": str(message["message_thread_id"]) + if message.get("message_thread_id") + else None, + "message_date": message_date, + "payload": json.dumps(data, indent=2), + } + ) doc.insert(ignore_permissions=True) frappe.db.commit() diff --git a/bwh_bot/bwh_bot_backend/doctype/__init__.py b/bwh_bot/bwh_bot_backend/doctype/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bwh_bot/bwh_bot_backend/doctype/bwh_bot_hive_site/__init__.py b/bwh_bot/bwh_bot_backend/doctype/bwh_bot_hive_site/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bwh_bot/bwh_bot_backend/doctype/bwh_bot_hive_site/bwh_bot_hive_site.json b/bwh_bot/bwh_bot_backend/doctype/bwh_bot_hive_site/bwh_bot_hive_site.json new file mode 100644 index 0000000..248feb6 --- /dev/null +++ b/bwh_bot/bwh_bot_backend/doctype/bwh_bot_hive_site/bwh_bot_hive_site.json @@ -0,0 +1,71 @@ +{ + "actions": [], + "allow_bulk_edit": 1, + "allow_rename": 1, + "creation": "2026-07-30 01:57:20.881718", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "label", + "site_url", + "api_key", + "api_secret", + "is_active" + ], + "fields": [ + { + "columns": 2, + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label" + }, + { + "columns": 3, + "description": "Base URL of the Frappe site running Hive, e.g. https://hive.example.com", + "fieldname": "site_url", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Site URL", + "reqd": 1 + }, + { + "columns": 2, + "fieldname": "api_key", + "fieldtype": "Data", + "in_list_view": 1, + "label": "API Key", + "reqd": 1 + }, + { + "fieldname": "api_secret", + "fieldtype": "Password", + "label": "API Secret", + "reqd": 1 + }, + { + "columns": 1, + "default": "1", + "fieldname": "is_active", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Is Active" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-07-30 01:57:20.881718", + "modified_by": "Administrator", + "module": "BWH Bot Backend", + "name": "BWH Bot Hive Site", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/bwh_bot/bwh_bot_backend/doctype/bwh_bot_hive_site/bwh_bot_hive_site.py b/bwh_bot/bwh_bot_backend/doctype/bwh_bot_hive_site/bwh_bot_hive_site.py new file mode 100644 index 0000000..c884bc5 --- /dev/null +++ b/bwh_bot/bwh_bot_backend/doctype/bwh_bot_hive_site/bwh_bot_hive_site.py @@ -0,0 +1,27 @@ +# Copyright (c) 2026, BWH and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class BWHBotHiveSite(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + api_key: DF.Data + api_secret: DF.Password + is_active: DF.Check + label: DF.Data | None + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + site_url: DF.Data + # end: auto-generated types + + _DOCTYPE_NAME = "BWH Bot Hive Site" diff --git a/bwh_bot/bwh_bot_backend/doctype/bwh_bot_settings/bwh_bot_settings.json b/bwh_bot/bwh_bot_backend/doctype/bwh_bot_settings/bwh_bot_settings.json index a4333be..9dbf5c4 100644 --- a/bwh_bot/bwh_bot_backend/doctype/bwh_bot_settings/bwh_bot_settings.json +++ b/bwh_bot/bwh_bot_backend/doctype/bwh_bot_settings/bwh_bot_settings.json @@ -1,113 +1,127 @@ { - "actions": [], - "creation": "2026-03-22 00:00:00.000000", - "doctype": "DocType", - "engine": "InnoDB", - "field_order": [ - "bot_token", - "webhook_secret", - "sb_whitelisted_chats", - "whitelisted_chats", - "sb_user_mappings", - "user_mappings", - "sb_petty_cash", - "default_cash_account", - "default_company", - "sb_actions", - "webhook_url", - "register_webhook" - ], - "fields": [ - { - "fieldname": "bot_token", - "fieldtype": "Password", - "label": "Bot Token", - "reqd": 1 - }, - { - "fieldname": "webhook_secret", - "fieldtype": "Data", - "label": "Webhook Secret" - }, - { - "fieldname": "sb_whitelisted_chats", - "fieldtype": "Section Break", - "label": "Whitelisted Chats" - }, - { - "fieldname": "whitelisted_chats", - "fieldtype": "Table", - "label": "Whitelisted Chats", - "options": "BWH Bot Whitelisted Chat" - }, - { - "fieldname": "sb_user_mappings", - "fieldtype": "Section Break", - "label": "User Mappings" - }, - { - "fieldname": "user_mappings", - "fieldtype": "Table", - "label": "User Mappings", - "options": "BWH Bot User Mapping" - }, - { - "fieldname": "sb_petty_cash", - "fieldtype": "Section Break", - "label": "Petty Cash Settings" - }, - { - "fieldname": "default_cash_account", - "fieldtype": "Link", - "label": "Default Cash Account", - "options": "Account", - "description": "Cash In Hand account used for monthly journal entry (credit side)" - }, - { - "fieldname": "default_company", - "fieldtype": "Link", - "label": "Default Company", - "options": "Company", - "description": "Company for journal entry creation" - }, - { - "fieldname": "sb_actions", - "fieldtype": "Section Break", - "label": "Actions" - }, - { - "fieldname": "webhook_url", - "fieldtype": "Data", - "label": "Webhook URL", - "description": "Public URL for the webhook (e.g. tunnel URL). Leave blank to use site URL." - }, - { - "fieldname": "register_webhook", - "fieldtype": "Button", - "label": "Register Webhook" - } - ], - "index_web_pages_for_search": 0, - "issingle": 1, - "links": [], - "modified": "2026-03-22 00:00:00.000000", - "modified_by": "Administrator", - "module": "BWH Bot Backend", - "name": "BWH Bot Settings", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "sort_field": "creation", - "sort_order": "DESC", - "states": [] + "actions": [], + "creation": "2026-03-22 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "bot_token", + "webhook_secret", + "sb_whitelisted_chats", + "whitelisted_chats", + "sb_user_mappings", + "user_mappings", + "sb_hive_sites", + "hive_sites", + "sb_petty_cash", + "default_cash_account", + "default_company", + "sb_actions", + "webhook_url", + "register_webhook" + ], + "fields": [ + { + "fieldname": "bot_token", + "fieldtype": "Password", + "label": "Bot Token", + "reqd": 1 + }, + { + "fieldname": "webhook_secret", + "fieldtype": "Data", + "label": "Webhook Secret" + }, + { + "fieldname": "sb_whitelisted_chats", + "fieldtype": "Section Break", + "label": "Whitelisted Chats" + }, + { + "fieldname": "whitelisted_chats", + "fieldtype": "Table", + "label": "Whitelisted Chats", + "options": "BWH Bot Whitelisted Chat" + }, + { + "fieldname": "sb_user_mappings", + "fieldtype": "Section Break", + "label": "User Mappings" + }, + { + "fieldname": "user_mappings", + "fieldtype": "Table", + "label": "User Mappings", + "options": "BWH Bot User Mapping" + }, + { + "fieldname": "sb_petty_cash", + "fieldtype": "Section Break", + "label": "Petty Cash Settings" + }, + { + "fieldname": "default_cash_account", + "fieldtype": "Link", + "label": "Default Cash Account", + "options": "Account", + "description": "Cash In Hand account used for monthly journal entry (credit side)" + }, + { + "fieldname": "default_company", + "fieldtype": "Link", + "label": "Default Company", + "options": "Company", + "description": "Company for journal entry creation" + }, + { + "fieldname": "sb_actions", + "fieldtype": "Section Break", + "label": "Actions" + }, + { + "fieldname": "webhook_url", + "fieldtype": "Data", + "label": "Webhook URL", + "description": "Public URL for the webhook (e.g. tunnel URL). Leave blank to use site URL." + }, + { + "fieldname": "register_webhook", + "fieldtype": "Button", + "label": "Register Webhook" + }, + { + "fieldname": "sb_hive_sites", + "fieldtype": "Section Break", + "label": "Hive Sites" + }, + { + "fieldname": "hive_sites", + "fieldtype": "Table", + "label": "Hive Sites", + "options": "BWH Bot Hive Site", + "description": "Remote Frappe sites running Hive where /hive can create tasks. Leave empty to disable the command." + } + ], + "index_web_pages_for_search": 0, + "issingle": 1, + "links": [], + "modified": "2026-03-22 00:00:00.000000", + "modified_by": "Administrator", + "module": "BWH Bot Backend", + "name": "BWH Bot Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] } diff --git a/bwh_bot/bwh_bot_backend/doctype/bwh_bot_settings/bwh_bot_settings.py b/bwh_bot/bwh_bot_backend/doctype/bwh_bot_settings/bwh_bot_settings.py index ea7ae63..066063d 100644 --- a/bwh_bot/bwh_bot_backend/doctype/bwh_bot_settings/bwh_bot_settings.py +++ b/bwh_bot/bwh_bot_backend/doctype/bwh_bot_settings/bwh_bot_settings.py @@ -10,11 +10,11 @@ def register_webhook(self): url = register_webhook(self.webhook_url or None) # Register bot commands with Telegram - from bwh_bot.api.telegram import COMMAND_HANDLERS + from bwh_bot.api.telegram import COMMAND_DESCRIPTIONS, COMMAND_HANDLERS commands = [] - for cmd, fn in COMMAND_HANDLERS.items(): - desc = getattr(fn, "_description", f"Run {cmd}") + for cmd in COMMAND_HANDLERS: + desc = COMMAND_DESCRIPTIONS.get(cmd, f"Run {cmd}") commands.append((cmd.lstrip("/"), desc)) if commands: diff --git a/bwh_bot/bwh_bot_backend/doctype/telegram_webhook_log/telegram_webhook_log.py b/bwh_bot/bwh_bot_backend/doctype/telegram_webhook_log/telegram_webhook_log.py index 846bd1c..0f9e59d 100644 --- a/bwh_bot/bwh_bot_backend/doctype/telegram_webhook_log/telegram_webhook_log.py +++ b/bwh_bot/bwh_bot_backend/doctype/telegram_webhook_log/telegram_webhook_log.py @@ -15,7 +15,11 @@ class TelegramWebhookLog(Document): def after_insert(self): if not self.chat_id or not is_whitelisted(self.chat_id): if self.command: - send_message(self.chat_id, "Unauthorized. This bot only works in registered groups.", message_thread_id=self.message_thread_id) + send_message( + self.chat_id, + "Unauthorized. This bot only works in registered groups.", + message_thread_id=self.message_thread_id, + ) return if self.update_type == "callback_query": diff --git a/bwh_bot/conversation.py b/bwh_bot/conversation.py index 3f6d04a..66b8307 100644 --- a/bwh_bot/conversation.py +++ b/bwh_bot/conversation.py @@ -20,6 +20,10 @@ class BotConversation: command_description = "" title = "" + # HR-backed flows (leave, WFH, petty cash) need the sender resolved to an + # Employee. Flows that do not touch HR doctypes set this to False. + requires_employee = True + def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) if cls.handler_name: @@ -41,16 +45,18 @@ def get_or_create_state(self, chat_id, telegram_user_id, initial_step="start"): if existing: return frappe.get_doc("Telegram Conversation State", existing) - state = frappe.get_doc({ - "doctype": "Telegram Conversation State", - "chat_id": str(chat_id), - "telegram_user_id": str(telegram_user_id), - "handler": self.handler_name, - "step": initial_step, - "is_active": 1, - "data": json.dumps({}), - "expires_at": frappe.utils.add_to_date(None, hours=1), - }) + state = frappe.get_doc( + { + "doctype": "Telegram Conversation State", + "chat_id": str(chat_id), + "telegram_user_id": str(telegram_user_id), + "handler": self.handler_name, + "step": initial_step, + "is_active": 1, + "data": json.dumps({}), + "expires_at": frappe.utils.add_to_date(None, hours=1), + } + ) state.insert(ignore_permissions=True) return state @@ -99,10 +105,17 @@ def handle_command(self, message): except Exception: pass - employee = get_employee_from_user(frappe.session.user) - if not employee: - send_message(chat_id, "You are not linked to any active employee record.", reply_to_message_id=message_id, message_thread_id=message_thread_id) - return + employee = None + if self.requires_employee: + employee = get_employee_from_user(frappe.session.user) + if not employee: + send_message( + chat_id, + "You are not linked to any active employee record.", + reply_to_message_id=message_id, + message_thread_id=message_thread_id, + ) + return state = self.get_or_create_state(chat_id, message["from"]["id"]) self.update_state(state, "start", {"employee": employee, "message_thread_id": message_thread_id}) @@ -153,7 +166,8 @@ def handle_callback(self, callback_query, log): data = self.get_data(state) header = self._build_header(data) edit_message_text( - chat_id, message_id, + chat_id, + message_id, f"{header}\n\nReply to this message with the from date (e.g. 25 Mar 2026):", parse_mode="HTML", ) @@ -165,7 +179,8 @@ def handle_callback(self, callback_query, log): data = self.get_data(state) header = self._build_header(data) edit_message_text( - chat_id, message_id, + chat_id, + message_id, f"{header}\n\nReply to this message with the to date (e.g. 28 Mar 2026):", parse_mode="HTML", ) diff --git a/bwh_bot/handlers/hive.py b/bwh_bot/handlers/hive.py new file mode 100644 index 0000000..65bb2a3 --- /dev/null +++ b/bwh_bot/handlers/hive.py @@ -0,0 +1,512 @@ +import frappe +from telegram import InlineKeyboardButton + +from bwh_bot import hive_client +from bwh_bot.conversation import BotConversation +from bwh_bot.hive_client import HiveSiteError +from bwh_bot.telegram_utils import answer_callback_query, edit_message_text, send_message +from bwh_bot.ui import ( + confirm_buttons, + from_date_buttons, + make_keyboard, + nav_buttons, + to_date_buttons, +) + +# Status a Telegram-created task lands in. Every other Hive Task status is a +# valid transition target from this one. +DEFAULT_STATUS = "To Do" + + +class HiveTaskConversation(BotConversation): + handler_name = "hive" + callback_prefix = "hive" + command = "/hive" + command_description = "Create a task in Hive" + title = "New Hive Task" + + # Tasks are created on a remote Hive site over its API, so this flow needs + # neither Frappe HR nor Hive installed alongside the bot. + requires_employee = False + + # --- Step 1: pick a site (skipped when only one is configured) ------------ + + def on_start(self, message, state, employee): + chat_id = message["chat"]["id"] + message_thread_id = message.get("message_thread_id") + reply_to = message["message_id"] + + sites = hive_client.active_sites() + if not sites: + self.clear_state(state) + send_message( + chat_id, + "No Hive sites are configured. Add one under Hive Sites in BWH Bot Settings.", + reply_to_message_id=reply_to, + message_thread_id=message_thread_id, + ) + return + + if len(sites) == 1: + self.update_state(state, "awaiting_title", _site_data(sites[0])) + send_message( + chat_id, + f"{self.title}\n\nReply with the task title:", + parse_mode="HTML", + reply_to_message_id=reply_to, + message_thread_id=message_thread_id, + ) + return + + self.update_state(state, "select_site") + send_message( + chat_id, + f"{self.title}\n\nSelect the Hive site:", + parse_mode="HTML", + reply_markup=make_keyboard( + [ + [ + InlineKeyboardButton( + hive_client.site_label(site), + callback_data=f"{self.callback_prefix}:site:{site.name}", + ) + ] + for site in sites + ], + nav_buttons(self.callback_prefix, show_back=False), + ), + reply_to_message_id=reply_to, + message_thread_id=message_thread_id, + ) + + # --- Free-text steps: title, description --------------------------------- + + def on_raw_text_input(self, state, chat_id, text): + message_thread_id = self.get_data(state).get("message_thread_id") + + if state.step == "awaiting_title": + title = text.strip() + if not title: + send_message( + chat_id, + "Task title can't be empty. Reply with the task title:", + parse_mode="HTML", + message_thread_id=message_thread_id, + ) + return + + self.update_state(state, "awaiting_description", {"title": title}) + self._prompt_description(state, chat_id, message_thread_id=message_thread_id) + + elif state.step == "awaiting_description": + self.update_state(state, "select_start_date", {"description": text.strip()}) + self._prompt_start_date(state, chat_id, message_thread_id=message_thread_id) + + # --- Date steps ---------------------------------------------------------- + # Quick-pick buttons emit `from`/`to` actions (see on_action). The base class + # routes the "Custom date..." buttons through awaiting_from_date / + # awaiting_to_date and hands the parsed date to on_text_input. + + def on_text_input(self, state, chat_id, date_str): + message_thread_id = self.get_data(state).get("message_thread_id") + + if state.step == "awaiting_from_date": + self.update_state(state, "select_end_date", {"start_date": date_str}) + self._prompt_end_date(state, chat_id, message_thread_id=message_thread_id) + + elif state.step == "awaiting_to_date": + if not self._reject_end_before_start(state, chat_id, date_str, message_thread_id): + return + self.update_state(state, "select_project", {"due_date": date_str}) + self._prompt_project(state, chat_id, message_thread_id=message_thread_id) + + # --- Button actions ------------------------------------------------------ + + def on_action(self, action, value, state, ctx): + chat_id, message_id, cqid = ctx["chat_id"], ctx["message_id"], ctx["callback_query_id"] + + if action == "site": + site = self._find_site(value) + if not site: + answer_callback_query(cqid, "That site is no longer configured.", show_alert=True) + return + self.update_state(state, "awaiting_title", _site_data(site)) + answer_callback_query(cqid) + edit_message_text( + chat_id, + message_id, + f"{self._build_header(self.get_data(state))}\n\nReply with the task title:", + parse_mode="HTML", + ) + + elif action == "skip_desc": + self.update_state(state, "select_start_date", {"description": ""}) + answer_callback_query(cqid) + self._prompt_start_date(state, chat_id, message_id=message_id) + + elif action == "from": + self.update_state(state, "select_end_date", {"start_date": value}) + answer_callback_query(cqid) + self._prompt_end_date(state, chat_id, message_id=message_id) + + elif action == "to": + if not self._reject_end_before_start(state, chat_id, value, None, cqid=cqid): + return + self.update_state(state, "select_project", {"due_date": value}) + answer_callback_query(cqid) + self._prompt_project(state, chat_id, message_id=message_id) + + elif action == "project": + project_title = self.get_data(state).get("project_titles", {}).get(value, value) + self.update_state(state, "select_assignees", {"project": value, "project_title": project_title}) + answer_callback_query(cqid) + self._prompt_assignees(state, chat_id, message_id=message_id) + + elif action == "assignee": + self._toggle_assignee(state, value) + answer_callback_query(cqid) + self._prompt_assignees(state, chat_id, message_id=message_id) + + elif action == "assignees_done": + self.update_state(state, "confirm") + answer_callback_query(cqid) + self._show_summary(state, chat_id, message_id) + + elif action == "confirm": + self._create_task(state, ctx) + + else: + answer_callback_query(cqid, "Unknown action.") + + def on_back(self, state, ctx): + chat_id, message_id = ctx["chat_id"], ctx["message_id"] + step = state.step + + if step == "select_start_date": + self.update_state(state, "awaiting_description") + self._prompt_description(state, chat_id, message_id=message_id) + + elif step == "select_end_date": + self.update_state(state, "select_start_date") + self._prompt_start_date(state, chat_id, message_id=message_id) + + elif step == "select_project": + self.update_state(state, "select_end_date") + self._prompt_end_date(state, chat_id, message_id=message_id) + + elif step == "select_assignees": + self.update_state(state, "select_project") + self._prompt_project(state, chat_id, message_id=message_id) + + elif step == "confirm": + self.update_state(state, "select_assignees") + self._prompt_assignees(state, chat_id, message_id=message_id) + + # --- Prompts ------------------------------------------------------------- + + def _prompt_description(self, state, chat_id, message_id=None, message_thread_id=None): + self._send( + chat_id, + f"{self._build_header(self.get_data(state))}\n\nReply with a description, or tap Skip:", + make_keyboard( + [ + [ + InlineKeyboardButton( + "Skip (no description)", + callback_data=f"{self.callback_prefix}:skip_desc", + ) + ] + ], + nav_buttons(self.callback_prefix, show_back=False), + ), + message_id=message_id, + message_thread_id=message_thread_id, + ) + + def _prompt_start_date(self, state, chat_id, message_id=None, message_thread_id=None): + self._send( + chat_id, + f"{self._build_header(self.get_data(state))}\n\nSelect start date:", + make_keyboard( + from_date_buttons(self.callback_prefix, include_today=True), + nav_buttons(self.callback_prefix), + ), + message_id=message_id, + message_thread_id=message_thread_id, + ) + + def _prompt_end_date(self, state, chat_id, message_id=None, message_thread_id=None): + data = self.get_data(state) + self._send( + chat_id, + f"{self._build_header(data)}\n\nSelect end date:", + make_keyboard( + to_date_buttons(self.callback_prefix, data["start_date"]), + nav_buttons(self.callback_prefix), + ), + message_id=message_id, + message_thread_id=message_thread_id, + ) + + def _prompt_project(self, state, chat_id, message_id=None, message_thread_id=None): + site = self._current_site(state) + if not site: + self._abort(state, chat_id, _SITE_GONE, message_id, message_thread_id) + return + + try: + projects = hive_client.list_projects(site) + except HiveSiteError as e: + self._abort(state, chat_id, str(e), message_id, message_thread_id) + return + + if not projects: + self._abort( + state, + chat_id, + f"No open projects found on {hive_client.site_label(site)}. Create one first.", + message_id, + message_thread_id, + ) + return + + # Cache the titles so later steps can label the chosen project without + # another round trip to the remote site. + self.update_state( + state, + state.step, + {"project_titles": {p["name"]: p.get("title") or p["name"] for p in projects}}, + ) + + self._send( + chat_id, + f"{self._build_header(self.get_data(state))}\n\nSelect project:", + make_keyboard( + _two_per_row( + [ + (p.get("title") or p["name"], f"{self.callback_prefix}:project:{p['name']}") + for p in projects + ] + ), + nav_buttons(self.callback_prefix), + ), + message_id=message_id, + message_thread_id=message_thread_id, + ) + + def _prompt_assignees(self, state, chat_id, message_id=None, message_thread_id=None): + """Multi-select picker: each tap toggles a member, Done moves on.""" + site = self._current_site(state) + if not site: + self._abort(state, chat_id, _SITE_GONE, message_id, message_thread_id) + return + + try: + members = hive_client.list_members(site) + except HiveSiteError as e: + self._abort(state, chat_id, str(e), message_id, message_thread_id) + return + + if not members: + self._abort( + state, + chat_id, + f"No active Hive members found on {hive_client.site_label(site)}.", + message_id, + message_thread_id, + ) + return + + selected = set(self.get_data(state).get("assignees") or []) + rows = _two_per_row( + [ + ( + f"{'✅ ' if m['user'] in selected else ''}{m.get('member_name') or m['user']}", + f"{self.callback_prefix}:assignee:{m['user']}", + ) + for m in members + ] + ) + done_label = f"Done ({len(selected)} selected)" if selected else "Done (no assignees)" + rows.append( + [InlineKeyboardButton(done_label, callback_data=f"{self.callback_prefix}:assignees_done")] + ) + + self._send( + chat_id, + f"{self._build_header(self.get_data(state))}\n\nSelect assignees (tap to toggle):", + make_keyboard(rows, nav_buttons(self.callback_prefix)), + message_id=message_id, + message_thread_id=message_thread_id, + ) + + def _show_summary(self, state, chat_id, message_id): + data = self.get_data(state) + assignees = data.get("assignees") or [] + description = data.get("description") or "" + + self._send( + chat_id, + ( + f"{self.title} — Review\n\n" + f"Site: {frappe.utils.escape_html(data['site_label'])}\n" + f"Title: {frappe.utils.escape_html(data['title'])}\n" + f"Description: {frappe.utils.escape_html(description) if description else '—'}\n" + f"Project: {frappe.utils.escape_html(data['project_title'])}\n" + f"Start: {data['start_date']}\n" + f"End: {data['due_date']}\n" + f"Assignees: {', '.join(assignees) if assignees else '—'}\n\n" + "Create this task?" + ), + make_keyboard(confirm_buttons(self.callback_prefix, "Confirm & Create")), + message_id=message_id, + ) + + # --- Creation ------------------------------------------------------------ + + def _create_task(self, state, ctx): + data = self.get_data(state) + chat_id, message_id, cqid = ctx["chat_id"], ctx["message_id"], ctx["callback_query_id"] + + site = self._current_site(state) + if not site: + self._abort(state, chat_id, _SITE_GONE, message_id, None) + answer_callback_query(cqid, "Site no longer configured.", show_alert=True) + return + + self.clear_state(state) + assignees = data.get("assignees") or [] + + try: + task_name = hive_client.create_task( + site, + title=data["title"], + project=data["project"], + status=DEFAULT_STATUS, + start_date=data["start_date"], + due_date=data["due_date"], + description=data.get("description") or None, + ) + except HiveSiteError as e: + answer_callback_query(cqid, "Failed to create task.", show_alert=True) + edit_message_text(chat_id, message_id, f"Failed to create task: {e}") + return + + # Assignment is best-effort: the task exists on the remote site either way, + # so report success rather than leaving the user unsure what happened. + assign_note = "" + if assignees: + try: + hive_client.assign_task(site, task_name, assignees) + except HiveSiteError: + frappe.log_error( + title="hive: remote assign failed", + message=f"Assign {assignees} to {task_name} on {site.site_url}", + ) + assign_note = "\n⚠️ Task created, but assigning failed — assign manually in Hive." + + answer_callback_query(cqid, "Task created!") + edit_message_text( + chat_id, + message_id, + ( + f"✅ Hive Task Created\n\n" + f"Site: {frappe.utils.escape_html(data['site_label'])}\n" + f"ID: {task_name}\n" + f"Title: {frappe.utils.escape_html(data['title'])}\n" + f"Project: {frappe.utils.escape_html(data['project_title'])}\n" + f"Start: {data['start_date']}\n" + f"End: {data['due_date']}\n" + f"Status: {DEFAULT_STATUS}\n" + f"Assignees: {', '.join(assignees) if assignees else '—'}\n" + f'Open in Hive' + f"{assign_note}" + ), + parse_mode="HTML", + ) + + # --- Helpers ------------------------------------------------------------- + + def _send(self, chat_id, text, reply_markup, message_id=None, message_thread_id=None): + """Edit in place when reacting to a button, otherwise post a new message.""" + if message_id: + edit_message_text(chat_id, message_id, text, parse_mode="HTML", reply_markup=reply_markup) + else: + send_message( + chat_id, + text, + parse_mode="HTML", + reply_markup=reply_markup, + message_thread_id=message_thread_id, + ) + + def _abort(self, state, chat_id, text, message_id, message_thread_id): + self.clear_state(state) + if message_id: + edit_message_text(chat_id, message_id, text) + else: + send_message(chat_id, text, message_thread_id=message_thread_id) + + def _find_site(self, row_name): + return next((s for s in hive_client.active_sites() if s.name == row_name), None) + + def _current_site(self, state): + """Re-read the site from settings each time: the conversation outlives the + request that started it, and the row may have been edited or disabled.""" + return self._find_site(self.get_data(state).get("site")) + + def _toggle_assignee(self, state, user): + selected = self.get_data(state).get("assignees") or [] + if user in selected: + selected.remove(user) + else: + selected.append(user) + self.update_state(state, state.step, {"assignees": selected}) + + def _reject_end_before_start(self, state, chat_id, end_date, message_thread_id, cqid=None): + """Hive treats due_date as on or after start_date; reject anything earlier.""" + start = self.get_data(state).get("start_date") + if start and frappe.utils.getdate(end_date) < frappe.utils.getdate(start): + msg = f"End date ({end_date}) can't be before the start date ({start})." + if cqid: + answer_callback_query(cqid, msg, show_alert=True) + else: + send_message(chat_id, msg, message_thread_id=message_thread_id) + return False + return True + + def _build_header(self, data): + parts = [f"{self.title}"] + if data.get("site_label"): + parts.append(f"Site: {frappe.utils.escape_html(data['site_label'])}") + if data.get("title"): + parts.append(f"Title: {frappe.utils.escape_html(data['title'])}") + if data.get("description"): + parts.append(f"Description: {frappe.utils.escape_html(data['description'])}") + if data.get("start_date"): + parts.append(f"Start: {data['start_date']}") + if data.get("due_date"): + parts.append(f"End: {data['due_date']}") + if data.get("project_title"): + parts.append(f"Project: {frappe.utils.escape_html(data['project_title'])}") + return "\n".join(parts) + + +_SITE_GONE = "That Hive site is no longer configured. Start again with /hive." + + +def _site_data(site): + return {"site": site.name, "site_label": hive_client.site_label(site)} + + +def _two_per_row(entries): + """Lay out (label, callback_data) pairs two to a row.""" + rows, row = [], [] + for label, callback_data in entries: + row.append(InlineKeyboardButton(label, callback_data=callback_data)) + if len(row) == 2: + rows.append(row) + row = [] + if row: + rows.append(row) + return rows diff --git a/bwh_bot/handlers/leave.py b/bwh_bot/handlers/leave.py index 2931492..f4e95ec 100644 --- a/bwh_bot/handlers/leave.py +++ b/bwh_bot/handlers/leave.py @@ -25,7 +25,12 @@ def on_start(self, message, state, employee): leave_types = get_leave_types_for_employee(employee) if not leave_types: - send_message(chat_id, "You have no leave allocations for the current period.", reply_to_message_id=message_id, message_thread_id=message_thread_id) + send_message( + chat_id, + "You have no leave allocations for the current period.", + reply_to_message_id=message_id, + message_thread_id=message_thread_id, + ) self.clear_state(state) return @@ -48,7 +53,8 @@ def on_action(self, action, value, state, ctx): self.update_state(state, "select_from_date", {"leave_type": value}) answer_callback_query(cqid) edit_message_text( - chat_id, message_id, + chat_id, + message_id, f"Leave Type: {value}\n\nSelect from date:", parse_mode="HTML", reply_markup=make_keyboard( @@ -62,10 +68,13 @@ def on_action(self, action, value, state, ctx): answer_callback_query(cqid) data = self.get_data(state) edit_message_text( - chat_id, message_id, + chat_id, + message_id, f"Leave Type: {data['leave_type']}\nFrom: {value}\n\nSelect to date:", parse_mode="HTML", - reply_markup=make_keyboard(to_date_buttons(self.callback_prefix, value), nav_buttons(self.callback_prefix)), + reply_markup=make_keyboard( + to_date_buttons(self.callback_prefix, value), nav_buttons(self.callback_prefix) + ), ) elif action == "to": @@ -95,7 +104,8 @@ def on_back(self, state, ctx): self.update_state(state, "select_leave_type") buttons = self._leave_type_buttons(leave_types) edit_message_text( - chat_id, message_id, + chat_id, + message_id, f"{self.title}\n\nSelect leave type:", parse_mode="HTML", reply_markup=make_keyboard(buttons, nav_buttons(self.callback_prefix, show_back=False)), @@ -104,7 +114,8 @@ def on_back(self, state, ctx): elif step == "select_to_date": self.update_state(state, "select_from_date") edit_message_text( - chat_id, message_id, + chat_id, + message_id, f"Leave Type: {data['leave_type']}\n\nSelect from date:", parse_mode="HTML", reply_markup=make_keyboard( @@ -116,10 +127,14 @@ def on_back(self, state, ctx): elif step == "confirm": self.update_state(state, "select_to_date", {"half_day": False}) edit_message_text( - chat_id, message_id, + chat_id, + message_id, f"Leave Type: {data['leave_type']}\nFrom: {data['from_date']}\n\nSelect to date:", parse_mode="HTML", - reply_markup=make_keyboard(to_date_buttons(self.callback_prefix, data["from_date"]), nav_buttons(self.callback_prefix)), + reply_markup=make_keyboard( + to_date_buttons(self.callback_prefix, data["from_date"]), + nav_buttons(self.callback_prefix), + ), ) def on_text_input(self, state, chat_id, date_str): @@ -133,7 +148,9 @@ def on_text_input(self, state, chat_id, date_str): chat_id, f"Leave Type: {data['leave_type']}\nFrom: {date_str}\n\nSelect to date:", parse_mode="HTML", - reply_markup=make_keyboard(to_date_buttons(self.callback_prefix, date_str), nav_buttons(self.callback_prefix)), + reply_markup=make_keyboard( + to_date_buttons(self.callback_prefix, date_str), nav_buttons(self.callback_prefix) + ), message_thread_id=message_thread_id, ) @@ -161,7 +178,9 @@ def _leave_type_buttons(self, leave_types): for lt in leave_types: balance = int(lt["balance"]) if lt["balance"] == int(lt["balance"]) else lt["balance"] label = f"{lt['leave_type']} ({balance} days)" - buttons.append([InlineKeyboardButton(label, callback_data=f"{self.callback_prefix}:type:{lt['leave_type']}")]) + buttons.append( + [InlineKeyboardButton(label, callback_data=f"{self.callback_prefix}:type:{lt['leave_type']}")] + ) return buttons def _summary_text(self, data): @@ -185,16 +204,22 @@ def _summary_buttons(self, data): p = self.callback_prefix return [ [ - InlineKeyboardButton(f"{'✅ Half Day' if half_day else 'Half Day'}", callback_data=f"{p}:half_day:1"), - InlineKeyboardButton(f"{'Full Day' if half_day else '✅ Full Day'}", callback_data=f"{p}:half_day:0"), + InlineKeyboardButton( + f"{'✅ Half Day' if half_day else 'Half Day'}", callback_data=f"{p}:half_day:1" + ), + InlineKeyboardButton( + f"{'Full Day' if half_day else '✅ Full Day'}", callback_data=f"{p}:half_day:0" + ), ], [InlineKeyboardButton("Confirm & Submit", callback_data=f"{p}:confirm")], - ] + nav_buttons(p) + *nav_buttons(p), + ] def _show_summary(self, state, chat_id, message_id): data = self.get_data(state) edit_message_text( - chat_id, message_id, + chat_id, + message_id, self._summary_text(data), parse_mode="HTML", reply_markup=make_keyboard(self._summary_buttons(data)), @@ -207,15 +232,17 @@ def _handle_confirm(self, state, ctx): try: frappe.db.savepoint("before_leave_application") - leave_app = frappe.get_doc({ - "doctype": "Leave Application", - "employee": data["employee"], - "leave_type": data["leave_type"], - "from_date": data["from_date"], - "to_date": data["to_date"], - "status": "Open", - "follow_via_email": 0, - }) + leave_app = frappe.get_doc( + { + "doctype": "Leave Application", + "employee": data["employee"], + "leave_type": data["leave_type"], + "from_date": data["from_date"], + "to_date": data["to_date"], + "status": "Open", + "follow_via_email": 0, + } + ) if data.get("half_day"): leave_app.half_day = 1 leave_app.half_day_date = data["from_date"] @@ -224,7 +251,8 @@ def _handle_confirm(self, state, ctx): answer_callback_query(cqid, "Leave application created!") edit_message_text( - chat_id, message_id, + chat_id, + message_id, ( f"Leave Application Created\n\n" f"ID: {leave_app.name}\n" @@ -239,7 +267,12 @@ def _handle_confirm(self, state, ctx): except Exception as e: frappe.db.rollback(save_point="before_leave_application") answer_callback_query(cqid, "Failed to create leave application.", show_alert=True) - edit_message_text(chat_id, message_id, f"Failed to create leave application:\n{e}", parse_mode="HTML") + edit_message_text( + chat_id, + message_id, + f"Failed to create leave application:\n{e}", + parse_mode="HTML", + ) # --- Doc Event Handler --- diff --git a/bwh_bot/handlers/petty_cash.py b/bwh_bot/handlers/petty_cash.py index ad2d327..7ace013 100644 --- a/bwh_bot/handlers/petty_cash.py +++ b/bwh_bot/handlers/petty_cash.py @@ -87,7 +87,8 @@ def on_action(self, action, value, state, ctx): answer_callback_query(cqid) data = self.get_data(state) edit_message_text( - chat_id, message_id, + chat_id, + message_id, f"{self._build_header(data)}\n\nReply with a short description/remarks:", parse_mode="HTML", ) @@ -102,7 +103,8 @@ def on_action(self, action, value, state, ctx): answer_callback_query(cqid) data = self.get_data(state) edit_message_text( - chat_id, message_id, + chat_id, + message_id, f"{self._build_header(data)}\n\nReply with the expense date (e.g. 25 Mar 2026):", parse_mode="HTML", ) @@ -137,7 +139,8 @@ def on_back(self, state, ctx): if step == "select_category": self.update_state(state, "awaiting_amount") edit_message_text( - chat_id, message_id, + chat_id, + message_id, f"{self.title}\n\nReply with the amount:", parse_mode="HTML", ) @@ -146,7 +149,8 @@ def on_back(self, state, ctx): categories = _get_categories() self.update_state(state, "select_category") edit_message_text( - chat_id, message_id, + chat_id, + message_id, f"{self._build_header(data)}\n\nSelect category:", parse_mode="HTML", reply_markup=make_keyboard( @@ -158,7 +162,8 @@ def on_back(self, state, ctx): elif step == "select_date": self.update_state(state, "awaiting_remarks") edit_message_text( - chat_id, message_id, + chat_id, + message_id, f"{self._build_header(data)}\n\nReply with a short description/remarks:", parse_mode="HTML", ) @@ -166,7 +171,8 @@ def on_back(self, state, ctx): elif step == "confirm": self.update_state(state, "select_date") edit_message_text( - chat_id, message_id, + chat_id, + message_id, f"{self._build_header(data)}\n\nSelect expense date:", parse_mode="HTML", reply_markup=make_keyboard( @@ -200,7 +206,8 @@ def _summary_text(self, data): def _show_summary(self, state, chat_id, message_id): data = self.get_data(state) edit_message_text( - chat_id, message_id, + chat_id, + message_id, self._summary_text(data), parse_mode="HTML", reply_markup=make_keyboard(confirm_buttons(self.callback_prefix, label="Confirm & Save")), @@ -213,20 +220,23 @@ def _handle_confirm(self, state, ctx): try: frappe.db.savepoint("before_petty_cash_usage") - doc = frappe.get_doc({ - "doctype": "Petty Cash Usage", - "employee": data["employee"], - "amount": data["amount"], - "category": data["category"], - "remarks": data.get("remarks"), - "expense_date": data["expense_date"], - }) + doc = frappe.get_doc( + { + "doctype": "Petty Cash Usage", + "employee": data["employee"], + "amount": data["amount"], + "category": data["category"], + "remarks": data.get("remarks"), + "expense_date": data["expense_date"], + } + ) doc.insert() frappe.db.commit() answer_callback_query(cqid, "Petty cash entry saved!") edit_message_text( - chat_id, message_id, + chat_id, + message_id, ( f"Petty Cash Entry Saved\n\n" f"ID: {doc.name}\n" @@ -242,7 +252,9 @@ def _handle_confirm(self, state, ctx): except Exception as e: frappe.db.rollback(save_point="before_petty_cash_usage") answer_callback_query(cqid, "Failed to save petty cash entry.", show_alert=True) - edit_message_text(chat_id, message_id, f"Failed to save petty cash entry:\n{e}", parse_mode="HTML") + edit_message_text( + chat_id, message_id, f"Failed to save petty cash entry:\n{e}", parse_mode="HTML" + ) def _get_categories(): @@ -250,7 +262,4 @@ def _get_categories(): def _category_buttons(prefix, categories): - return [ - [InlineKeyboardButton(cat, callback_data=f"{prefix}:cat:{cat}")] - for cat in categories - ] + return [[InlineKeyboardButton(cat, callback_data=f"{prefix}:cat:{cat}")] for cat in categories] diff --git a/bwh_bot/handlers/ping.py b/bwh_bot/handlers/ping.py index 088b1eb..d6f1ab7 100644 --- a/bwh_bot/handlers/ping.py +++ b/bwh_bot/handlers/ping.py @@ -5,4 +5,9 @@ @register_command("/ping", description="Check if bot is alive") def handle_ping(message): chat_id = message["chat"]["id"] - send_message(chat_id, "pong", reply_to_message_id=message["message_id"], message_thread_id=message.get("message_thread_id")) + send_message( + chat_id, + "pong", + reply_to_message_id=message["message_id"], + message_thread_id=message.get("message_thread_id"), + ) diff --git a/bwh_bot/handlers/wfh.py b/bwh_bot/handlers/wfh.py index fca01b8..4d34b53 100644 --- a/bwh_bot/handlers/wfh.py +++ b/bwh_bot/handlers/wfh.py @@ -38,10 +38,13 @@ def on_action(self, action, value, state, ctx): self.update_state(state, "select_to_date", {"from_date": value}) answer_callback_query(cqid) edit_message_text( - chat_id, message_id, + chat_id, + message_id, f"{self.title}\nFrom: {value}\n\nSelect to date:", parse_mode="HTML", - reply_markup=make_keyboard(to_date_buttons(self.callback_prefix, value), nav_buttons(self.callback_prefix)), + reply_markup=make_keyboard( + to_date_buttons(self.callback_prefix, value), nav_buttons(self.callback_prefix) + ), ) elif action == "to": @@ -69,7 +72,8 @@ def on_back(self, state, ctx): if step == "select_to_date": self.update_state(state, "select_from_date") edit_message_text( - chat_id, message_id, + chat_id, + message_id, f"{self.title}\n\nSelect from date:", parse_mode="HTML", reply_markup=make_keyboard( @@ -81,10 +85,14 @@ def on_back(self, state, ctx): elif step == "confirm": self.update_state(state, "select_to_date") edit_message_text( - chat_id, message_id, + chat_id, + message_id, f"{self.title}\nFrom: {data['from_date']}\n\nSelect to date:", parse_mode="HTML", - reply_markup=make_keyboard(to_date_buttons(self.callback_prefix, data["from_date"]), nav_buttons(self.callback_prefix)), + reply_markup=make_keyboard( + to_date_buttons(self.callback_prefix, data["from_date"]), + nav_buttons(self.callback_prefix), + ), ) def on_text_input(self, state, chat_id, date_str): @@ -97,7 +105,9 @@ def on_text_input(self, state, chat_id, date_str): chat_id, f"{self.title}\nFrom: {date_str}\n\nSelect to date:", parse_mode="HTML", - reply_markup=make_keyboard(to_date_buttons(self.callback_prefix, date_str), nav_buttons(self.callback_prefix)), + reply_markup=make_keyboard( + to_date_buttons(self.callback_prefix, date_str), nav_buttons(self.callback_prefix) + ), message_thread_id=message_thread_id, ) @@ -130,11 +140,16 @@ def _summary_buttons(self, data): p = self.callback_prefix return [ [ - InlineKeyboardButton(f"{'Half Day' if not half_day else '✅ Half Day'}", callback_data=f"{p}:half_day:1"), - InlineKeyboardButton(f"{'✅ Full Day' if not half_day else 'Full Day'}", callback_data=f"{p}:half_day:0"), + InlineKeyboardButton( + f"{'Half Day' if not half_day else '✅ Half Day'}", callback_data=f"{p}:half_day:1" + ), + InlineKeyboardButton( + f"{'✅ Full Day' if not half_day else 'Full Day'}", callback_data=f"{p}:half_day:0" + ), ], [InlineKeyboardButton("Confirm & Submit", callback_data=f"{p}:confirm")], - ] + nav_buttons(p) + *nav_buttons(p), + ] def _total_wfh_days(self, data): days = frappe.utils.date_diff(data["to_date"], data["from_date"]) + 1 @@ -148,7 +163,8 @@ def _show_summary(self, state, chat_id, message_id): total_wfh_days = self._total_wfh_days(data) edit_message_text( - chat_id, message_id, + chat_id, + message_id, ( f"Work From Home Summary\n\n" f"From: {data['from_date']}\n" @@ -189,7 +205,8 @@ def _handle_confirm(self, state, ctx): answer_callback_query(cqid, "WFH request submitted!") edit_message_text( - chat_id, message_id, + chat_id, + message_id, ( f"WFH Request Submitted\n\n" f"Employee: {doc.employee_name}\n" diff --git a/bwh_bot/hive_client.py b/bwh_bot/hive_client.py new file mode 100644 index 0000000..74d4789 --- /dev/null +++ b/bwh_bot/hive_client.py @@ -0,0 +1,166 @@ +"""Thin REST client for a remote Frappe site running Hive. + +The bot lives on the HR site, which has no Hive doctypes of its own, so every +read and write goes over the remote site's API. Sites and their credentials are +configured in the Hive Sites table on BWH Bot Settings. +""" + +import frappe +import requests +from frappe.utils.password import get_decrypted_password + +# Remote calls happen inside a Telegram webhook request, so they must fail fast +# rather than hold the worker open waiting on an unreachable site. +TIMEOUT = 10 + +# Cap the pickers so a long list does not build an unwieldy inline keyboard. +MAX_PROJECTS = 30 +MAX_MEMBERS = 30 + + +class HiveSiteError(frappe.ValidationError): + """A remote Hive site rejected a request or could not be reached.""" + + +def active_sites(): + """Configured Hive sites that are enabled, in table order.""" + settings = frappe.get_single("BWH Bot Settings") + return [row for row in settings.hive_sites if row.is_active] + + +def site_label(site): + return site.label or _host(site.site_url) + + +def list_projects(site): + """Open, non-archived projects on the remote site, newest first.""" + return _get( + site, + "Hive Project", + filters=[["is_archived", "=", 0], ["status", "=", "Open"]], + fields=["name", "title"], + order_by="modified desc", + limit=MAX_PROJECTS, + ) + + +def list_members(site): + """Active team members on the remote site, who can be assigned work.""" + return _get( + site, + "Hive Member", + filters=[["is_active", "=", 1], ["type", "=", "Team"]], + fields=["user", "member_name"], + order_by="member_name asc", + limit=MAX_MEMBERS, + ) + + +def create_task(site, title, project, status, start_date, due_date, description=None): + """Insert a Hive Task on the remote site and return its name.""" + payload = { + "title": title, + "project": project, + "status": status, + "start_date": start_date, + "due_date": due_date, + } + if description: + payload["description"] = description + + response = _request(site, "POST", "/api/resource/Hive Task", json=payload) + name = (response.get("data") or {}).get("name") + if not name: + raise HiveSiteError(f"{site_label(site)} did not return a task name") + return name + + +def assign_task(site, task_name, users): + """Assign the remote task via the standard assignment API, which is how Hive + tracks assignees (they live in `_assign`, not a child table).""" + _request( + site, + "POST", + "/api/method/frappe.desk.form.assign_to.add", + json={ + "doctype": "Hive Task", + "name": task_name, + "assign_to": users, + "notify": 0, + }, + ) + + +def task_url(site, task_name): + return f"{_base(site.site_url)}/app/hive-task/{task_name}" + + +# --- internals ----------------------------------------------------------- + + +def _get(site, doctype, filters, fields, order_by, limit): + response = _request( + site, + "GET", + f"/api/resource/{doctype}", + params={ + "filters": frappe.as_json(filters), + "fields": frappe.as_json(fields), + "order_by": order_by, + "limit_page_length": limit, + }, + ) + return response.get("data") or [] + + +def _request(site, method, path, **kwargs): + url = f"{_base(site.site_url)}{path}" + try: + response = requests.request( + method, + url, + headers={"Authorization": _auth_header(site), "Accept": "application/json"}, + timeout=TIMEOUT, + **kwargs, + ) + except requests.RequestException as e: + raise HiveSiteError(f"Could not reach {site_label(site)}: {e}") from e + + if response.status_code >= 400: + raise HiveSiteError(f"{site_label(site)} returned {response.status_code}: {_error_text(response)}") + + try: + return response.json() + except ValueError as e: + raise HiveSiteError(f"{site_label(site)} returned a non-JSON response") from e + + +def _auth_header(site): + secret = get_decrypted_password("BWH Bot Hive Site", site.name, "api_secret", raise_exception=False) + if not secret: + raise HiveSiteError(f"{site_label(site)} has no API secret configured") + return f"token {site.api_key}:{secret}" + + +def _error_text(response): + """Pull the useful line out of a Frappe error response, without the traceback.""" + try: + payload = response.json() + except ValueError: + return (response.text or "").strip()[:200] + + # Frappe reports failures as `exception`, or as `exc_type` plus an `exc` + # traceback. Prefer the human-readable keys and never surface the traceback. + for key in ("exception", "message", "exc_type", "_server_messages"): + value = payload.get(key) + if value: + return str(value)[:200] + return str(payload)[:200] + + +def _base(site_url): + return (site_url or "").rstrip("/") + + +def _host(site_url): + return _base(site_url).split("://")[-1] diff --git a/bwh_bot/hooks.py b/bwh_bot/hooks.py index e9369b2..36d9fb6 100644 --- a/bwh_bot/hooks.py +++ b/bwh_bot/hooks.py @@ -158,9 +158,7 @@ "bwh_bot.tasks.send_daily_wfh_notification", ], }, - "monthly": [ - "bwh_bot.tasks.create_monthly_petty_cash_journal_entry" - ], + "monthly": ["bwh_bot.tasks.create_monthly_petty_cash_journal_entry"], } fixtures = [ @@ -261,4 +259,3 @@ # ------------ # List of apps whose translatable strings should be excluded from this app's translations. # ignore_translatable_strings_from = [] - diff --git a/bwh_bot/install.py b/bwh_bot/install.py index 4a5dae9..437fd09 100644 --- a/bwh_bot/install.py +++ b/bwh_bot/install.py @@ -29,4 +29,14 @@ def after_migrate(): def _make_custom_fields(): - create_custom_fields(CUSTOM_FIELDS, ignore_validate=True) + # The doctypes we extend ship with Frappe HR, which is optional: the bot is + # installable on a site that only uses the non-HR flows, and CI installs it + # on a bare site. Skip anything absent; after_migrate re-runs this, so the + # fields appear if HR is installed later. + fields = { + doctype: definitions + for doctype, definitions in CUSTOM_FIELDS.items() + if frappe.db.exists("DocType", doctype) + } + if fields: + create_custom_fields(fields, ignore_validate=True) diff --git a/bwh_bot/tasks.py b/bwh_bot/tasks.py index 0b83938..fe0ef52 100644 --- a/bwh_bot/tasks.py +++ b/bwh_bot/tasks.py @@ -114,29 +114,35 @@ def create_monthly_petty_cash_journal_entry(): ) continue - accounts.append({ - "account": category_account, - "debit_in_account_currency": row.total, - "credit_in_account_currency": 0, - }) + accounts.append( + { + "account": category_account, + "debit_in_account_currency": row.total, + "credit_in_account_currency": 0, + } + ) grand_total += row.total if not accounts: return # Credit side: Cash In Hand - accounts.append({ - "account": cash_account, - "debit_in_account_currency": 0, - "credit_in_account_currency": grand_total, - }) - - jv = frappe.get_doc({ - "doctype": "Journal Entry", - "posting_date": to_date, - "company": company, - "voucher_type": "Journal Entry", - "user_remark": f"Petty Cash Summary for {month_label}", - "accounts": accounts, - }) + accounts.append( + { + "account": cash_account, + "debit_in_account_currency": 0, + "credit_in_account_currency": grand_total, + } + ) + + jv = frappe.get_doc( + { + "doctype": "Journal Entry", + "posting_date": to_date, + "company": company, + "voucher_type": "Journal Entry", + "user_remark": f"Petty Cash Summary for {month_label}", + "accounts": accounts, + } + ) jv.insert() diff --git a/bwh_bot/telegram_utils.py b/bwh_bot/telegram_utils.py index 23e2595..e3927be 100644 --- a/bwh_bot/telegram_utils.py +++ b/bwh_bot/telegram_utils.py @@ -11,7 +11,9 @@ def get_bot(): return telegram.Bot(token=token) -def send_message(chat_id, text, parse_mode=None, reply_markup=None, reply_to_message_id=None, message_thread_id=None): +def send_message( + chat_id, text, parse_mode=None, reply_markup=None, reply_to_message_id=None, message_thread_id=None +): bot = get_bot() asyncio.run( bot.send_message( @@ -118,10 +120,12 @@ def get_leave_types_for_employee(employee): from hrms.hr.doctype.leave_application.leave_application import get_leave_balance_on balance = get_leave_balance_on(employee, alloc.leave_type, today) - result.append({ - "leave_type": alloc.leave_type, - "balance": balance, - }) + result.append( + { + "leave_type": alloc.leave_type, + "balance": balance, + } + ) return result diff --git a/bwh_bot/tests/__init__.py b/bwh_bot/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bwh_bot/tests/test_hive.py b/bwh_bot/tests/test_hive.py new file mode 100644 index 0000000..6f97c7f --- /dev/null +++ b/bwh_bot/tests/test_hive.py @@ -0,0 +1,706 @@ +# Copyright (c) 2026, BWH Studios and Contributors +# See license.txt +"""Tests for the /hive conversation and its remote Hive client. + +Both the Telegram transport and the remote HTTP calls are patched out, so these +run on any site: the bot never needs Hive installed alongside it. +""" + +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase + +from bwh_bot import hive_client +from bwh_bot.handlers.hive import DEFAULT_STATUS, HiveTaskConversation +from bwh_bot.hive_client import HiveSiteError + +CHAT_ID = 987001 +TG_USER_ID = 424242 + +PROJECTS = [ + {"name": "PROJ-00001", "title": "Apollo"}, + {"name": "PROJ-00002", "title": "Borealis"}, +] +MEMBERS = [ + {"user": "ada@example.com", "member_name": "Ada"}, + {"user": "grace@example.com", "member_name": "Grace"}, +] + + +class FakeSite: + """Stands in for a BWH Bot Hive Site child row.""" + + def __init__(self, name="row1", label="Hive", url="https://hive.example.com", is_active=1): + self.name = name + self.label = label + self.site_url = url + self.api_key = "key" + self.is_active = is_active + + +def _command_message(chat_id=CHAT_ID, user_id=TG_USER_ID): + return {"chat": {"id": chat_id}, "message_id": 1, "from": {"id": user_id}} + + +class HiveConversationTestCase(IntegrationTestCase): + """Shared harness: Telegram calls captured, remote client stubbed.""" + + sites = None + + def setUp(self): + self.sites = self.sites or [FakeSite()] + self.created = [] + self.assigned = [] + self.sent = [] + self.markups = [] + + self.handler = HiveTaskConversation() + self.ctx = {"chat_id": CHAT_ID, "message_id": 1, "callback_query_id": "cq1"} + + for name in frappe.get_all( + "Telegram Conversation State", + filters={"chat_id": str(CHAT_ID), "handler": "hive"}, + pluck="name", + ): + frappe.delete_doc("Telegram Conversation State", name, force=True, ignore_permissions=True) + + self._patch( + "bwh_bot.handlers.hive", + send_message=self._record, + edit_message_text=self._record_edit, + answer_callback_query=self._record_answer, + ) + self._patch( + "bwh_bot.conversation", + send_message=self._record, + edit_message_text=self._record_edit, + answer_callback_query=self._record_answer, + set_message_reaction=lambda *a, **k: None, + ) + self._patch( + "bwh_bot.hive_client", + active_sites=lambda: [s for s in self.sites if s.is_active], + list_projects=lambda site: PROJECTS, + list_members=lambda site: MEMBERS, + create_task=self._fake_create, + assign_task=self._fake_assign, + ) + + def _patch(self, target, **attrs): + patcher = patch.multiple(target, **attrs) + patcher.start() + self.addCleanup(patcher.stop) + + # --- capture --- + + def _record(self, chat_id, text, **kwargs): + self.sent.append(text) + self.markups.append(kwargs.get("reply_markup")) + + def _record_edit(self, chat_id, message_id, text, **kwargs): + self.sent.append(text) + self.markups.append(kwargs.get("reply_markup")) + + def _record_answer(self, callback_query_id, text=None, show_alert=False): + self.sent.append(text or "") + self.markups.append(None) + + def _fake_create(self, site, **kwargs): + self.created.append((site, kwargs)) + return "TASK-00007" + + def _fake_assign(self, site, task_name, users): + self.assigned.append((task_name, users)) + + @property + def last(self): + return self.sent[-1] + + @property + def last_buttons(self): + markup = self.markups[-1] + if not markup: + return [] + return [button.text for row in markup.inline_keyboard for button in row] + + # --- driver --- + + def _start(self): + self.handler.handle_command(_command_message()) + return self.handler.get_active_state(CHAT_ID, TG_USER_ID) + + def _run_to_review(self, description="A description", assignees=("ada@example.com",)): + state = self._start() + self.handler.handle_text_input(state, CHAT_ID, "Ship the thing") + if description is None: + self.handler.on_action("skip_desc", None, state, self.ctx) + else: + self.handler.handle_text_input(state, CHAT_ID, description) + self.handler.on_action("from", "2026-08-03", state, self.ctx) + self.handler.on_action("to", "2026-08-07", state, self.ctx) + self.handler.on_action("project", "PROJ-00001", state, self.ctx) + for user in assignees: + self.handler.on_action("assignee", user, state, self.ctx) + self.handler.on_action("assignees_done", None, state, self.ctx) + return state + + +class TestHiveConversation(HiveConversationTestCase): + def test_creates_remote_task_with_all_fields(self): + state = self._run_to_review() + self.handler.on_action("confirm", None, state, self.ctx) + + self.assertEqual(len(self.created), 1) + site, payload = self.created[0] + self.assertEqual(site.site_url, "https://hive.example.com") + self.assertEqual(payload["title"], "Ship the thing") + self.assertEqual(payload["description"], "A description") + self.assertEqual(payload["project"], "PROJ-00001") + self.assertEqual(payload["status"], DEFAULT_STATUS) + self.assertEqual(payload["start_date"], "2026-08-03") + self.assertEqual(payload["due_date"], "2026-08-07") + self.assertIn("Hive Task Created", self.last) + self.assertIn("TASK-00007", self.last) + + def test_assignees_sent_to_remote_site(self): + state = self._run_to_review(assignees=("ada@example.com", "grace@example.com")) + self.handler.on_action("confirm", None, state, self.ctx) + + self.assertEqual(self.assigned, [("TASK-00007", ["ada@example.com", "grace@example.com"])]) + + def test_description_can_be_skipped(self): + state = self._run_to_review(description=None) + self.handler.on_action("confirm", None, state, self.ctx) + + _, payload = self.created[0] + self.assertIsNone(payload["description"]) + + def test_no_assignees_means_no_assign_call(self): + state = self._run_to_review(assignees=()) + self.handler.on_action("confirm", None, state, self.ctx) + + self.assertEqual(self.assigned, []) + self.assertEqual(len(self.created), 1) + + def test_assignee_selection_toggles_off(self): + state = self._start() + self.handler.handle_text_input(state, CHAT_ID, "Toggle test") + self.handler.on_action("skip_desc", None, state, self.ctx) + self.handler.on_action("from", "2026-08-03", state, self.ctx) + self.handler.on_action("to", "2026-08-07", state, self.ctx) + self.handler.on_action("project", "PROJ-00001", state, self.ctx) + + self.handler.on_action("assignee", "ada@example.com", state, self.ctx) + self.assertEqual(self.handler.get_data(state)["assignees"], ["ada@example.com"]) + self.assertIn("✅ Ada", self.last_buttons) + self.assertIn("Done (1 selected)", self.last_buttons) + + self.handler.on_action("assignee", "ada@example.com", state, self.ctx) + self.assertEqual(self.handler.get_data(state)["assignees"], []) + self.assertNotIn("✅ Ada", self.last_buttons) + + def test_project_titles_are_offered_as_buttons(self): + state = self._start() + self.handler.handle_text_input(state, CHAT_ID, "Project test") + self.handler.on_action("skip_desc", None, state, self.ctx) + self.handler.on_action("from", "2026-08-03", state, self.ctx) + self.handler.on_action("to", "2026-08-07", state, self.ctx) + + self.assertIn("Apollo", self.last_buttons) + self.assertIn("Borealis", self.last_buttons) + + def test_chosen_project_shows_its_title_not_its_id(self): + self._run_to_review() + self.assertIn("Apollo", self.last) + self.assertNotIn("PROJ-00001", self.last) + + def test_end_date_before_start_is_rejected(self): + state = self._start() + self.handler.handle_text_input(state, CHAT_ID, "Bad dates") + self.handler.on_action("skip_desc", None, state, self.ctx) + self.handler.on_action("from", "2026-08-10", state, self.ctx) + + self.handler.on_action("to", "2026-08-01", state, self.ctx) + + self.assertIn("can't be before the start date", self.last) + self.assertEqual(state.step, "select_end_date") + + def test_empty_title_is_rejected(self): + state = self._start() + self.handler.handle_text_input(state, CHAT_ID, " ") + + self.assertIn("can't be empty", self.last) + self.assertEqual(state.step, "awaiting_title") + + def test_back_from_project_returns_to_end_date(self): + state = self._start() + self.handler.handle_text_input(state, CHAT_ID, "Back test") + self.handler.on_action("skip_desc", None, state, self.ctx) + self.handler.on_action("from", "2026-08-03", state, self.ctx) + self.handler.on_action("to", "2026-08-07", state, self.ctx) + self.assertEqual(state.step, "select_project") + + self.handler.on_back(state, self.ctx) + + self.assertEqual(state.step, "select_end_date") + self.assertIn("end date", self.last) + + def test_remote_failure_is_reported_and_does_not_raise(self): + state = self._run_to_review() + with patch.object(hive_client, "create_task", side_effect=HiveSiteError("host unreachable")): + self.handler.on_action("confirm", None, state, self.ctx) + + self.assertIn("Failed to create task", self.last) + self.assertEqual(self.created, []) + + def test_assign_failure_still_reports_the_task_as_created(self): + state = self._run_to_review() + with patch.object(hive_client, "assign_task", side_effect=HiveSiteError("no permission")): + self.handler.on_action("confirm", None, state, self.ctx) + + self.assertIn("Hive Task Created", self.last) + self.assertIn("assigning failed", self.last) + + +class TestHiveEmptyAndFailingRemote(HiveConversationTestCase): + """What the user sees when the remote site answers but has nothing usable, + or stops answering part way through the conversation.""" + + def _to_project_step(self): + state = self._start() + self.handler.handle_text_input(state, CHAT_ID, "Remote edge cases") + self.handler.on_action("skip_desc", None, state, self.ctx) + self.handler.on_action("from", "2026-08-03", state, self.ctx) + return state + + def test_no_projects_on_remote_aborts_with_an_explanation(self): + state = self._to_project_step() + with patch.object(hive_client, "list_projects", lambda site: []): + self.handler.on_action("to", "2026-08-07", state, self.ctx) + + self.assertIn("No open projects", self.last) + self.assertEqual(state.is_active, 0) + + def test_no_members_on_remote_aborts_with_an_explanation(self): + state = self._to_project_step() + self.handler.on_action("to", "2026-08-07", state, self.ctx) + with patch.object(hive_client, "list_members", lambda site: []): + self.handler.on_action("project", "PROJ-00001", state, self.ctx) + + self.assertIn("No active Hive members", self.last) + self.assertEqual(state.is_active, 0) + + def test_unreachable_remote_while_listing_projects_aborts(self): + state = self._to_project_step() + + def boom(site): + raise HiveSiteError("Could not reach Hive: timed out") + + with patch.object(hive_client, "list_projects", boom): + self.handler.on_action("to", "2026-08-07", state, self.ctx) + + self.assertIn("Could not reach", self.last) + self.assertEqual(state.is_active, 0) + + def test_unreachable_remote_while_listing_members_aborts(self): + state = self._to_project_step() + self.handler.on_action("to", "2026-08-07", state, self.ctx) + + def boom(site): + raise HiveSiteError("Could not reach Hive: timed out") + + with patch.object(hive_client, "list_members", boom): + self.handler.on_action("project", "PROJ-00001", state, self.ctx) + + self.assertIn("Could not reach", self.last) + self.assertEqual(state.is_active, 0) + + +class TestHiveBackNavigation(HiveConversationTestCase): + """Every step reachable by Go Back, walked in reverse.""" + + def test_back_walks_from_review_to_description(self): + state = self._run_to_review() + self.assertEqual(state.step, "confirm") + + self.handler.on_back(state, self.ctx) + self.assertEqual(state.step, "select_assignees") + + self.handler.on_back(state, self.ctx) + self.assertEqual(state.step, "select_project") + + self.handler.on_back(state, self.ctx) + self.assertEqual(state.step, "select_end_date") + + self.handler.on_back(state, self.ctx) + self.assertEqual(state.step, "select_start_date") + + self.handler.on_back(state, self.ctx) + self.assertEqual(state.step, "awaiting_description") + self.assertIn("description", self.last) + + def test_going_back_and_forward_keeps_the_earlier_answers(self): + state = self._run_to_review() + self.handler.on_back(state, self.ctx) + self.handler.on_back(state, self.ctx) + + # Re-pick the project and finish again; the title must survive. + self.handler.on_action("project", "PROJ-00002", state, self.ctx) + self.handler.on_action("assignees_done", None, state, self.ctx) + self.handler.on_action("confirm", None, state, self.ctx) + + _, payload = self.created[0] + self.assertEqual(payload["title"], "Ship the thing") + self.assertEqual(payload["project"], "PROJ-00002") + + +class TestHiveCancel(HiveConversationTestCase): + def test_cancel_clears_the_session(self): + state = self._start() + + log = type( + "Log", + (), + { + "chat_id": CHAT_ID, + "telegram_user_id": TG_USER_ID, + "callback_query_id": "cq1", + "callback_data": "hive:cancel", + }, + )() + self.handler.handle_callback({"message": {"message_id": 1}}, log) + + state.reload() + self.assertEqual(state.is_active, 0) + self.assertIn("cancelled", self.sent[-2].lower()) + + def test_button_press_without_a_session_is_reported(self): + log = type( + "Log", + (), + { + "chat_id": CHAT_ID, + "telegram_user_id": TG_USER_ID, + "callback_query_id": "cq1", + "callback_data": "hive:confirm", + }, + )() + self.handler.handle_callback({"message": {"message_id": 1}}, log) + + self.assertIn("No active session", self.last) + + +class TestHiveCustomDates(HiveConversationTestCase): + """The "Custom date..." buttons route through the base class, which parses the + reply and hands a date string to on_text_input rather than on_action.""" + + def _to_custom_start(self): + state = self._start() + self.handler.handle_text_input(state, CHAT_ID, "Custom date test") + self.handler.on_action("skip_desc", None, state, self.ctx) + self.handler.update_state(state, "awaiting_from_date") + return state + + def test_custom_start_date_advances_to_end_date(self): + state = self._to_custom_start() + + self.handler.handle_text_input(state, CHAT_ID, "3 Aug 2026") + + self.assertEqual(state.step, "select_end_date") + self.assertEqual(self.handler.get_data(state)["start_date"], "2026-08-03") + self.assertIn("end date", self.last) + + def test_custom_end_date_advances_to_project(self): + state = self._to_custom_start() + self.handler.handle_text_input(state, CHAT_ID, "3 Aug 2026") + self.handler.update_state(state, "awaiting_to_date") + + self.handler.handle_text_input(state, CHAT_ID, "7 Aug 2026") + + self.assertEqual(state.step, "select_project") + self.assertEqual(self.handler.get_data(state)["due_date"], "2026-08-07") + + def test_custom_end_date_before_start_is_rejected(self): + state = self._to_custom_start() + self.handler.handle_text_input(state, CHAT_ID, "10 Aug 2026") + self.handler.update_state(state, "awaiting_to_date") + + self.handler.handle_text_input(state, CHAT_ID, "1 Aug 2026") + + self.assertIn("can't be before the start date", self.last) + self.assertEqual(state.step, "awaiting_to_date") + + def test_unparseable_date_is_reported(self): + state = self._to_custom_start() + + self.handler.handle_text_input(state, CHAT_ID, "not a date") + + self.assertIn("Could not parse", self.last) + self.assertEqual(state.step, "awaiting_from_date") + + def test_unknown_action_is_reported(self): + state = self._start() + self.handler.on_action("no_such_action", None, state, self.ctx) + self.assertEqual(self.last, "Unknown action.") + + +class TestHiveSiteSelection(HiveConversationTestCase): + def test_single_site_skips_the_picker(self): + state = self._start() + self.assertEqual(state.step, "awaiting_title") + self.assertIn("task title", self.last) + + def test_no_configured_sites_explains_itself(self): + self.sites = [] + with patch.object(hive_client, "active_sites", lambda: []): + self.handler.handle_command(_command_message()) + self.assertIn("No Hive sites are configured", self.last) + + def test_inactive_sites_are_ignored(self): + self.sites = [FakeSite(is_active=0)] + self.handler.handle_command(_command_message()) + self.assertIn("No Hive sites are configured", self.last) + + def test_multiple_sites_prompt_for_choice(self): + self.sites = [FakeSite("row1", "Hive A"), FakeSite("row2", "Hive B", "https://b.example.com")] + state = self._start() + + self.assertEqual(state.step, "select_site") + self.assertIn("Hive A", self.last_buttons) + self.assertIn("Hive B", self.last_buttons) + + self.handler.on_action("site", "row2", state, self.ctx) + self.assertEqual(state.step, "awaiting_title") + self.assertEqual(self.handler.get_data(state)["site"], "row2") + + def test_site_removed_mid_conversation_is_handled(self): + state = self._run_to_review() + self.sites = [] + self.handler.on_action("confirm", None, state, self.ctx) + + self.assertIn("no longer configured", self.last) + self.assertEqual(self.created, []) + + +class TestConversationRequirements(IntegrationTestCase): + def test_hive_does_not_require_employee(self): + self.assertFalse(HiveTaskConversation.requires_employee) + + def test_hr_flows_still_require_employee(self): + from bwh_bot.handlers.leave import LeaveConversation + from bwh_bot.handlers.wfh import WFHConversation + + self.assertTrue(LeaveConversation.requires_employee) + self.assertTrue(WFHConversation.requires_employee) + + def test_each_command_keeps_its_own_description(self): + """Every conversation registers the same bound handle_command, so storing + descriptions on the function object made the last registration win.""" + import bwh_bot.api.telegram as telegram_api + + descriptions = telegram_api.COMMAND_DESCRIPTIONS + self.assertEqual(descriptions.get("/hive"), "Create a task in Hive") + self.assertEqual(descriptions.get("/leave_application"), "Apply for leave") + self.assertEqual(descriptions.get("/wfh"), "Apply for Work From Home") + self.assertEqual(len(set(descriptions.values())), len(descriptions)) + + +class TestHiveClient(IntegrationTestCase): + """The REST plumbing: auth header, query shape, error surfacing.""" + + def test_auth_header_uses_token_scheme(self): + site = FakeSite() + with patch("bwh_bot.hive_client.get_decrypted_password", return_value="s3cret"): + self.assertEqual(hive_client._auth_header(site), "token key:s3cret") + + def test_missing_secret_is_a_clear_error(self): + site = FakeSite() + with patch("bwh_bot.hive_client.get_decrypted_password", return_value=None): + with self.assertRaises(HiveSiteError) as cm: + hive_client._auth_header(site) + self.assertIn("no API secret", str(cm.exception)) + + def test_trailing_slash_in_site_url_does_not_double_up(self): + site = FakeSite(url="https://hive.example.com/") + self.assertEqual( + hive_client.task_url(site, "TASK-1"), "https://hive.example.com/app/hive-task/TASK-1" + ) + + def test_label_falls_back_to_host(self): + self.assertEqual(hive_client.site_label(FakeSite(label=None)), "hive.example.com") + + def test_http_error_is_wrapped_with_the_site_label(self): + site = FakeSite() + + class Response: + status_code = 403 + text = "forbidden" + + def json(self): + return {"exception": "frappe.PermissionError: not allowed"} + + with patch("bwh_bot.hive_client.get_decrypted_password", return_value="s"): + with patch("bwh_bot.hive_client.requests.request", return_value=Response()): + with self.assertRaises(HiveSiteError) as cm: + hive_client._request(site, "GET", "/api/resource/Hive Project") + + message = str(cm.exception) + self.assertIn("Hive", message) + self.assertIn("403", message) + self.assertIn("PermissionError", message) + + def test_unreachable_host_is_wrapped(self): + import requests + + site = FakeSite() + with patch("bwh_bot.hive_client.get_decrypted_password", return_value="s"): + with patch( + "bwh_bot.hive_client.requests.request", + side_effect=requests.ConnectionError("dns failure"), + ): + with self.assertRaises(HiveSiteError) as cm: + hive_client._request(site, "GET", "/api/resource/Hive Project") + + self.assertIn("Could not reach", str(cm.exception)) + + def test_create_task_returns_the_remote_name(self): + site = FakeSite() + with patch.object(hive_client, "_request", return_value={"data": {"name": "TASK-42"}}): + name = hive_client.create_task( + site, + title="T", + project="P", + status=DEFAULT_STATUS, + start_date="2026-08-01", + due_date="2026-08-02", + ) + self.assertEqual(name, "TASK-42") + + def test_create_task_without_a_name_is_an_error(self): + site = FakeSite() + with patch.object(hive_client, "_request", return_value={"data": {}}): + with self.assertRaises(HiveSiteError): + hive_client.create_task( + site, + title="T", + project="P", + status=DEFAULT_STATUS, + start_date="2026-08-01", + due_date="2026-08-02", + ) + + def test_list_projects_filters_to_open_and_unarchived(self): + site = FakeSite() + captured = {} + + def fake_request(site, method, path, **kwargs): + captured.update(path=path, params=kwargs.get("params")) + return {"data": PROJECTS} + + with patch.object(hive_client, "_request", fake_request): + result = hive_client.list_projects(site) + + self.assertEqual(result, PROJECTS) + self.assertEqual(captured["path"], "/api/resource/Hive Project") + self.assertIn("is_archived", captured["params"]["filters"]) + self.assertIn("Open", captured["params"]["filters"]) + self.assertEqual(captured["params"]["limit_page_length"], hive_client.MAX_PROJECTS) + + def test_list_members_filters_to_active_team_members(self): + site = FakeSite() + captured = {} + + def fake_request(site, method, path, **kwargs): + captured.update(path=path, params=kwargs.get("params")) + return {"data": MEMBERS} + + with patch.object(hive_client, "_request", fake_request): + result = hive_client.list_members(site) + + self.assertEqual(result, MEMBERS) + self.assertEqual(captured["path"], "/api/resource/Hive Member") + self.assertIn("is_active", captured["params"]["filters"]) + self.assertIn("Team", captured["params"]["filters"]) + self.assertEqual(captured["params"]["limit_page_length"], hive_client.MAX_MEMBERS) + + def test_active_sites_skips_disabled_rows(self): + settings = type("S", (), {"hive_sites": [FakeSite("a"), FakeSite("b", is_active=0)]})() + with patch("bwh_bot.hive_client.frappe.get_single", return_value=settings): + self.assertEqual([s.name for s in hive_client.active_sites()], ["a"]) + + def test_non_json_response_is_reported(self): + site = FakeSite() + + class Response: + status_code = 200 + text = "nope" + + def json(self): + raise ValueError("not json") + + with patch("bwh_bot.hive_client.get_decrypted_password", return_value="s"): + with patch("bwh_bot.hive_client.requests.request", return_value=Response()): + with self.assertRaises(HiveSiteError) as cm: + hive_client._request(site, "GET", "/api/resource/Hive Project") + + self.assertIn("non-JSON", str(cm.exception)) + + def test_error_text_falls_back_to_exc_type(self): + class Response: + status_code = 500 + text = "" + + def json(self): + return {"exc_type": "ValidationError", "exc": "long traceback here"} + + message = hive_client._error_text(Response()) + self.assertEqual(message, "ValidationError") + self.assertNotIn("traceback", message) + + def test_assign_uses_the_standard_assignment_endpoint(self): + site = FakeSite() + captured = {} + + def fake_request(site, method, path, **kwargs): + captured.update(path=path, json=kwargs.get("json")) + return {"message": "ok"} + + with patch.object(hive_client, "_request", fake_request): + hive_client.assign_task(site, "TASK-1", ["ada@example.com"]) + + self.assertEqual(captured["path"], "/api/method/frappe.desk.form.assign_to.add") + self.assertEqual(captured["json"]["doctype"], "Hive Task") + self.assertEqual(captured["json"]["assign_to"], ["ada@example.com"]) + + +class TestInstallCustomFields(IntegrationTestCase): + """The custom fields target Frappe HR doctypes, which are optional. Without + this guard `install-app` fails outright on a site (and in CI) without HR.""" + + def test_absent_doctypes_are_skipped(self): + from bwh_bot import install + + with patch("bwh_bot.install.frappe.db.exists", return_value=None): + with patch("bwh_bot.install.create_custom_fields") as create: + install._make_custom_fields() + + create.assert_not_called() + + def test_present_doctypes_are_created(self): + from bwh_bot import install + + with patch("bwh_bot.install.frappe.db.exists", return_value="Attendance Request"): + with patch("bwh_bot.install.create_custom_fields") as create: + install._make_custom_fields() + + create.assert_called_once() + fields = create.call_args[0][0] + self.assertIn("Attendance Request", fields) + + def test_after_install_does_not_raise_without_hr(self): + from bwh_bot import install + + with patch("bwh_bot.install.frappe.db.exists", return_value=None): + install.after_install() + install.after_migrate() diff --git a/bwh_bot/ui.py b/bwh_bot/ui.py index 79f1b77..c7bd7ca 100644 --- a/bwh_bot/ui.py +++ b/bwh_bot/ui.py @@ -17,28 +17,46 @@ def from_date_buttons(prefix, include_today=False, include_next_monday=True): buttons = [] if include_today: - buttons.append([ - InlineKeyboardButton(f"Today ({today})", callback_data=f"{prefix}:from:{today}"), - InlineKeyboardButton(f"Tomorrow ({tomorrow})", callback_data=f"{prefix}:from:{tomorrow}"), - ]) + buttons.append( + [ + InlineKeyboardButton(f"Today ({today})", callback_data=f"{prefix}:from:{today}"), + InlineKeyboardButton(f"Tomorrow ({tomorrow})", callback_data=f"{prefix}:from:{tomorrow}"), + ] + ) if include_next_monday: - buttons.append([ - InlineKeyboardButton(f"Day After ({day_after})", callback_data=f"{prefix}:from:{day_after}"), - InlineKeyboardButton(f"Next Monday ({next_monday})", callback_data=f"{prefix}:from:{next_monday}"), - ]) + buttons.append( + [ + InlineKeyboardButton( + f"Day After ({day_after})", callback_data=f"{prefix}:from:{day_after}" + ), + InlineKeyboardButton( + f"Next Monday ({next_monday})", callback_data=f"{prefix}:from:{next_monday}" + ), + ] + ) else: - buttons.append([ - InlineKeyboardButton(f"Day After ({day_after})", callback_data=f"{prefix}:from:{day_after}"), - ]) + buttons.append( + [ + InlineKeyboardButton( + f"Day After ({day_after})", callback_data=f"{prefix}:from:{day_after}" + ), + ] + ) else: - buttons.append([ - InlineKeyboardButton(f"Tomorrow ({tomorrow})", callback_data=f"{prefix}:from:{tomorrow}"), - InlineKeyboardButton(f"Day After ({day_after})", callback_data=f"{prefix}:from:{day_after}"), - ]) + buttons.append( + [ + InlineKeyboardButton(f"Tomorrow ({tomorrow})", callback_data=f"{prefix}:from:{tomorrow}"), + InlineKeyboardButton(f"Day After ({day_after})", callback_data=f"{prefix}:from:{day_after}"), + ] + ) if include_next_monday: - buttons.append([ - InlineKeyboardButton(f"Next Monday ({next_monday})", callback_data=f"{prefix}:from:{next_monday}"), - ]) + buttons.append( + [ + InlineKeyboardButton( + f"Next Monday ({next_monday})", callback_data=f"{prefix}:from:{next_monday}" + ), + ] + ) buttons.append([InlineKeyboardButton("Custom date...", callback_data=f"{prefix}:custom_from")]) return buttons @@ -94,7 +112,8 @@ def nav_buttons(prefix, show_back=True): def confirm_buttons(prefix, label="Confirm"): return [ [InlineKeyboardButton(label, callback_data=f"{prefix}:confirm")], - ] + nav_buttons(prefix) + *nav_buttons(prefix), + ] def make_keyboard(*button_rows):