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):