Add 'business' profile type and category
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 <noreply@anthropic.com>
This commit is contained in:
100
manager/business_test.py
Normal file
100
manager/business_test.py
Normal file
@@ -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()))
|
||||||
@@ -70,6 +70,15 @@ def _enrich(profiles: list[dict]) -> list[dict]:
|
|||||||
return profiles
|
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 ──────────────────────────────────────────────────────────────────────
|
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/login", response_class=HTMLResponse)
|
@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)
|
@app.get("/bots", response_class=HTMLResponse)
|
||||||
async def bots_page(request: Request):
|
async def bots_page(request: Request):
|
||||||
if redir := _redirect_if_unauth(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 []
|
contacts = bot.contacts if bot else []
|
||||||
groups = bot.groups if bot else []
|
groups = bot.groups if bot else []
|
||||||
log_lines = bot.log_lines[-50:] 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).
|
# 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).
|
# 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")]
|
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,
|
"groups": plain_groups,
|
||||||
"channels": channels,
|
"channels": channels,
|
||||||
"log_lines": log_lines,
|
"log_lines": log_lines,
|
||||||
"back": "/users" if is_user else "/bots",
|
"back": "/" + cat,
|
||||||
"nav_active": "users" if is_user else "bots",
|
"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:
|
if pm.group_id(g) == chat_id:
|
||||||
is_channel = bool(g.get("is_channel"))
|
is_channel = bool(g.get("is_channel"))
|
||||||
break
|
break
|
||||||
is_user = profile["bot_type"] in pm.USER_TYPES
|
|
||||||
return TEMPLATES.TemplateResponse(request, "chat.html", {
|
return TEMPLATES.TemplateResponse(request, "chat.html", {
|
||||||
"profile": profile,
|
"profile": profile,
|
||||||
"running": pm.is_running(profile_id),
|
"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_id": chat_id,
|
||||||
"chat_name": name,
|
"chat_name": name,
|
||||||
"is_channel": is_channel,
|
"is_channel": is_channel,
|
||||||
"nav_active": "users" if is_user else "bots",
|
"nav_active": _category(profile["bot_type"]),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -132,9 +132,10 @@ def group_member_count(g: dict) -> int:
|
|||||||
return g.get("groupSummary", {}).get("currentMembers", 0)
|
return g.get("groupSummary", {}).get("currentMembers", 0)
|
||||||
|
|
||||||
|
|
||||||
BOT_TYPES = ["echo", "broadcast", "support", "directory", "deadmans"]
|
BOT_TYPES = ["echo", "broadcast", "support", "directory", "deadmans"]
|
||||||
USER_TYPES = ["user"]
|
USER_TYPES = ["user"]
|
||||||
ALL_TYPES = BOT_TYPES + USER_TYPES
|
BUSINESS_TYPES = ["business"] # cli accounts with a business address (per-customer group chats)
|
||||||
|
ALL_TYPES = BOT_TYPES + USER_TYPES + BUSINESS_TYPES
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -468,6 +469,13 @@ async def _run_bot(
|
|||||||
settings: dict = {"businessAddress": False, "autoAccept": {"acceptIncognito": False}}
|
settings: dict = {"businessAddress": False, "autoAccept": {"acceptIncognito": False}}
|
||||||
if bot_type == "user":
|
if bot_type == "user":
|
||||||
pass # plain user: auto-accept on, no auto-reply
|
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":
|
elif bot_type == "support":
|
||||||
settings["businessAddress"] = True
|
settings["businessAddress"] = True
|
||||||
welcome = config.get("welcome_message", f"Welcome to {name} support.")
|
welcome = config.get("welcome_message", f"Welcome to {name} support.")
|
||||||
|
|||||||
@@ -266,6 +266,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<nav class="side-nav">
|
<nav class="side-nav">
|
||||||
<a href="/users" {% if nav_active == 'users' %}class="active"{% endif %}><span class="ico">👤</span><span class="lbl">Users</span></a>
|
<a href="/users" {% if nav_active == 'users' %}class="active"{% endif %}><span class="ico">👤</span><span class="lbl">Users</span></a>
|
||||||
|
<a href="/business" {% if nav_active == 'business' %}class="active"{% endif %}><span class="ico">💼</span><span class="lbl">Business</span></a>
|
||||||
<a href="/bots" {% if nav_active == 'bots' %}class="active"{% endif %}><span class="ico">🤖</span><span class="lbl">Bots</span></a>
|
<a href="/bots" {% if nav_active == 'bots' %}class="active"{% endif %}><span class="ico">🤖</span><span class="lbl">Bots</span></a>
|
||||||
<a href="https://simplex.chat/file/" target="_blank" rel="noopener"><span class="ico">📁</span><span class="lbl">File upload</span></a>
|
<a href="https://simplex.chat/file/" target="_blank" rel="noopener"><span class="ico">📁</span><span class="lbl">File upload</span></a>
|
||||||
<a href="/notifications" class="nav-sep {% if nav_active == 'notifications' %}active{% endif %}"><span class="ico">🔔</span><span class="lbl">Notifications</span><span class="notif-badge" id="notif-badge" style="display:none;"></span></a>
|
<a href="/notifications" class="nav-sep {% if nav_active == 'notifications' %}active{% endif %}"><span class="ico">🔔</span><span class="lbl">Notifications</span><span class="notif-badge" id="notif-badge" style="display:none;"></span></a>
|
||||||
|
|||||||
@@ -23,13 +23,25 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% set new_label = 'User' if tab == 'users' else ('Business' if tab == 'business' else 'Bot') %}
|
||||||
<div class="flex-between" style="margin-bottom: 24px;">
|
<div class="flex-between" style="margin-bottom: 24px;">
|
||||||
<h1 style="margin:0;">{{ tab | title }}</h1>
|
<h1 style="margin:0;">{{ tab | title }}</h1>
|
||||||
<button class="btn btn-primary" onclick="openCreate()">
|
<button class="btn btn-primary" onclick="openCreate()">
|
||||||
+ New {{ 'User' if tab == 'users' else 'Bot' }}
|
+ New {{ new_label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if tab == 'business' %}
|
||||||
|
<div class="card bot-types-card" style="margin-bottom:24px;">
|
||||||
|
<h2 style="font-size:15px;margin-bottom:8px;">Business accounts</h2>
|
||||||
|
<p class="muted" style="font-size:13px;">
|
||||||
|
A business account uses a <strong>business address</strong>: each customer who connects gets
|
||||||
|
their own group chat (so teammates can be added). You handle those conversations here, the same
|
||||||
|
way you chat in a group. Set an optional welcome message to auto-greet new customers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if tab == 'bots' %}
|
{% if tab == 'bots' %}
|
||||||
<div class="card bot-types-card" style="margin-bottom:24px;">
|
<div class="card bot-types-card" style="margin-bottom:24px;">
|
||||||
<h2 style="font-size:15px;margin-bottom:12px;">Available bot types</h2>
|
<h2 style="font-size:15px;margin-bottom:12px;">Available bot types</h2>
|
||||||
@@ -87,6 +99,9 @@
|
|||||||
{% if tab == 'users' %}
|
{% if tab == 'users' %}
|
||||||
<strong>No users yet</strong>
|
<strong>No users yet</strong>
|
||||||
<p>Create a SimpleX user account to manage contacts and channels.</p>
|
<p>Create a SimpleX user account to manage contacts and channels.</p>
|
||||||
|
{% elif tab == 'business' %}
|
||||||
|
<strong>No business accounts yet</strong>
|
||||||
|
<p>Create a business account; each customer who connects gets their own group chat.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<strong>No bots yet</strong>
|
<strong>No bots yet</strong>
|
||||||
<p>Bots can echo messages, broadcast to subscribers, or run automated tasks.</p>
|
<p>Bots can echo messages, broadcast to subscribers, or run automated tasks.</p>
|
||||||
@@ -96,11 +111,11 @@
|
|||||||
|
|
||||||
<!-- Create dialog -->
|
<!-- Create dialog -->
|
||||||
<dialog id="create-dialog">
|
<dialog id="create-dialog">
|
||||||
<h2 style="margin-bottom:20px;">New {{ 'User' if tab == 'users' else 'Bot' }}</h2>
|
<h2 style="margin-bottom:20px;">New {{ new_label }}</h2>
|
||||||
<form id="create-form">
|
<form id="create-form">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Name</label>
|
<label>Name</label>
|
||||||
<input type="text" name="name" placeholder="{{ 'Alice' if tab == 'users' else 'My Bot' }}" required>
|
<input type="text" name="name" placeholder="{{ 'Alice' if tab == 'users' else ('Acme Inc' if tab == 'business' else 'My Bot') }}" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Bio / Description <span class="muted" style="font-weight:400;">(optional)</span></label>
|
<label>Bio / Description <span class="muted" style="font-weight:400;">(optional)</span></label>
|
||||||
@@ -113,6 +128,12 @@
|
|||||||
<input type="file" name="avatar_file" accept="image/*" onchange="onAvatarChange(this)" style="flex:1;">
|
<input type="file" name="avatar_file" accept="image/*" onchange="onAvatarChange(this)" style="flex:1;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if tab == 'business' %}
|
||||||
|
<div class="field">
|
||||||
|
<label>Welcome Message <span class="muted" style="font-weight:400;">(optional auto-reply to new customers)</span></label>
|
||||||
|
<input type="text" name="welcome_message" placeholder="Thanks for reaching out! How can we help?">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if tab == 'bots' %}
|
{% if tab == 'bots' %}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Bot Type</label>
|
<label>Bot Type</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user