From 609e91c6dedc40d233bf6920d6c72ff29836b835 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 5 Jun 2026 17:09:00 +0100 Subject: [PATCH] Add 'business' profile type and category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Business accounts are cli profiles with businessAddress=True, so each connecting customer gets their own group chat (handled via the existing chat UI) with an optional welcome auto-reply. New BUSINESS_TYPES, a /business page + sidebar entry, and a business variant of the create form. profile/chat pages route via a _category helper. Adds business_test.py (customer connects -> lands in a business group, not a direct contact) — passes. Co-Authored-By: Claude Opus 4.8 --- manager/business_test.py | 100 ++++++++++++++++++++++++++++++++++++ manager/main.py | 29 +++++++++-- manager/profiles.py | 14 +++-- manager/templates/base.html | 1 + manager/templates/list.html | 27 ++++++++-- 5 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 manager/business_test.py diff --git a/manager/business_test.py b/manager/business_test.py new file mode 100644 index 0000000..6522acf --- /dev/null +++ b/manager/business_test.py @@ -0,0 +1,100 @@ +"""End-to-end test of a 'business' profile (Pattern 3, in-process FFI). + +A business profile sets businessAddress=True, so a connecting customer gets their +own GROUP chat (business chat) rather than a plain direct contact. This starts a +business profile via profiles.start_bot, connects a customer, and asserts the +customer ends up in a group (the distinguishing business-address behavior). + +Run: .venv/bin/python business_test.py +""" + +import asyncio +import json +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import profiles as pm # noqa: E402 +from simplex_chat import ChatApi, SqliteDb # noqa: E402 + +DATA = Path("data") +BIZ_PREFIX = str(DATA / "biztest_biz") +CUST_PREFIX = str(DATA / "biztest_cust") +BIZ_PID = 99002 + + +def cleanup(): + for pat in ("biztest_biz_*", "biztest_cust_*"): + for p in DATA.glob(pat): + p.unlink() + + +async def wait_until(fn, timeout=120, every=1): + start = time.time() + while time.time() - start < timeout: + v = await fn() + if v: + return v + await asyncio.sleep(every) + return None + + +async def main() -> int: + cleanup() + addr_box = {} + + async def on_address(pid, addr): + addr_box["addr"] = addr + + profile = { + "id": BIZ_PID, "name": "biztestco", "bot_type": "business", + "db_prefix": BIZ_PREFIX, + "config": json.dumps({"welcome_message": "Thanks for contacting us!"}), + } + cust = None + ok = True + try: + await pm.start_bot(profile, on_address) + addr = await wait_until(lambda: asyncio.sleep(0, addr_box.get("addr")), timeout=90) + print("business address:", bool(addr)) + assert addr, "business profile never published an address" + + cust = await ChatApi.init(SqliteDb(file_prefix=CUST_PREFIX)) + if not await cust.api_get_active_user(): + await cust.api_create_active_user({"displayName": "customer", "fullName": ""}) + await cust.start_chat() + await cust.send_chat_cmd(f"/connect {addr}") + + u = await cust.api_get_active_user() + uid = u["userId"] + + async def customer_groups(): + return await cust.api_list_groups(uid) + + groups = await wait_until(lambda: customer_groups(), timeout=90, every=2) + contacts = await cust.api_list_contacts(uid) + print("customer groups:", [g.get("groupProfile", {}).get("displayName") for g in (groups or [])]) + print("customer direct contacts:", [c.get("localDisplayName") for c in contacts]) + + # business address ⇒ a business GROUP chat, not a plain direct contact + assert groups, "customer did not land in a business group (businessAddress not in effect?)" + ok = bool(groups) + except AssertionError as e: + ok = False + print("ASSERT FAIL:", e) + finally: + await pm.stop_bot(BIZ_PID) + if cust: + try: + await cust.close() + except Exception: + pass + cleanup() + + print("\nRESULT:", "PASS — business profile creates per-customer group chats" if ok else "FAIL") + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/manager/main.py b/manager/main.py index 71a4f0d..a06b7b0 100644 --- a/manager/main.py +++ b/manager/main.py @@ -70,6 +70,15 @@ def _enrich(profiles: list[dict]) -> list[dict]: return profiles +def _category(bot_type: str) -> str: + """Which sidebar category a profile belongs to: users / business / bots.""" + if bot_type in pm.BUSINESS_TYPES: + return "business" + if bot_type in pm.USER_TYPES: + return "users" + return "bots" + + # ── Auth ────────────────────────────────────────────────────────────────────── @app.get("/login", response_class=HTMLResponse) @@ -111,6 +120,17 @@ async def users_page(request: Request): }) +@app.get("/business", response_class=HTMLResponse) +async def business_page(request: Request): + if redir := _redirect_if_unauth(request): + return redir + items = _enrich([p for p in db.list_profiles() if p["bot_type"] in pm.BUSINESS_TYPES]) + return TEMPLATES.TemplateResponse(request, "list.html", { + "tab": "business", "items": items, "create_types": pm.BUSINESS_TYPES, + "nav_active": "business", + }) + + @app.get("/bots", response_class=HTMLResponse) async def bots_page(request: Request): if redir := _redirect_if_unauth(request): @@ -189,7 +209,7 @@ async def profile_page(request: Request, profile_id: int): contacts = bot.contacts if bot else [] groups = bot.groups if bot else [] log_lines = bot.log_lines[-50:] if bot else [] - is_user = profile["bot_type"] in pm.USER_TYPES + cat = _category(profile["bot_type"]) # Split groups by their link role: channels (observer) vs regular groups (member). # The is_channel flag is set during the bot's group refresh (see _classify_group). channels = [g for g in groups if g.get("is_channel")] @@ -200,8 +220,8 @@ async def profile_page(request: Request, profile_id: int): "groups": plain_groups, "channels": channels, "log_lines": log_lines, - "back": "/users" if is_user else "/bots", - "nav_active": "users" if is_user else "bots", + "back": "/" + cat, + "nav_active": cat, }) @@ -238,7 +258,6 @@ async def chat_room(request: Request, profile_id: int, chat_type: str, chat_id: if pm.group_id(g) == chat_id: is_channel = bool(g.get("is_channel")) break - is_user = profile["bot_type"] in pm.USER_TYPES return TEMPLATES.TemplateResponse(request, "chat.html", { "profile": profile, "running": pm.is_running(profile_id), @@ -246,7 +265,7 @@ async def chat_room(request: Request, profile_id: int, chat_type: str, chat_id: "chat_id": chat_id, "chat_name": name, "is_channel": is_channel, - "nav_active": "users" if is_user else "bots", + "nav_active": _category(profile["bot_type"]), }) diff --git a/manager/profiles.py b/manager/profiles.py index 4387e3b..ac8c0ce 100644 --- a/manager/profiles.py +++ b/manager/profiles.py @@ -132,9 +132,10 @@ def group_member_count(g: dict) -> int: return g.get("groupSummary", {}).get("currentMembers", 0) -BOT_TYPES = ["echo", "broadcast", "support", "directory", "deadmans"] -USER_TYPES = ["user"] -ALL_TYPES = BOT_TYPES + USER_TYPES +BOT_TYPES = ["echo", "broadcast", "support", "directory", "deadmans"] +USER_TYPES = ["user"] +BUSINESS_TYPES = ["business"] # cli accounts with a business address (per-customer group chats) +ALL_TYPES = BOT_TYPES + USER_TYPES + BUSINESS_TYPES @dataclass @@ -468,6 +469,13 @@ async def _run_bot( settings: dict = {"businessAddress": False, "autoAccept": {"acceptIncognito": False}} if bot_type == "user": pass # plain user: auto-accept on, no auto-reply + elif bot_type == "business": + # business account: each connecting customer becomes a group chat the + # operator handles in the UI. Optional welcome auto-reply if configured. + settings["businessAddress"] = True + welcome = (config.get("welcome_message") or "").strip() + if welcome: + settings["autoReply"] = {"type": "text", "text": welcome} elif bot_type == "support": settings["businessAddress"] = True welcome = config.get("welcome_message", f"Welcome to {name} support.") diff --git a/manager/templates/base.html b/manager/templates/base.html index 6c81365..aff730e 100644 --- a/manager/templates/base.html +++ b/manager/templates/base.html @@ -266,6 +266,7 @@