mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:30:48 +08:00
fix(chat,channels): admin session delete, weixin inbound, PG and docs
- List/delete chat sessions for administrator by username (cross-tenant UUID) - Return 404 when delete does not remove a session; add tests - Weixin: dispatch lang, inbound attachments, reply persist, native reply timeout - PG compat scrub and administrator session delete repo path - RUNBOOK: WhatsApp re-bind with Remove-Item auth; weixin poll diag scripts Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
39fd1905b6
commit
bc1b45a8fd
20 changed files with 1056 additions and 86 deletions
|
|
@ -113,6 +113,7 @@ _DISPATCH_REASON_LABELS_SETTING_KEY = "AIA_DISPATCH_REASON_LABELS_JSON"
|
|||
_SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON"
|
||||
_CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX = "channel.dispatch.interaction_mode."
|
||||
_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX = "channel.dispatch.specialist."
|
||||
_CHANNEL_DISPATCH_LANG_KEY_PREFIX = "channel.dispatch.lang."
|
||||
|
||||
|
||||
def _channel_dispatch_interaction_key(channel: str) -> str:
|
||||
|
|
@ -122,6 +123,16 @@ def _channel_dispatch_interaction_key(channel: str) -> str:
|
|||
def _channel_dispatch_specialist_key(channel: str) -> str:
|
||||
return f"{_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX}{str(channel or '').strip().lower()}"
|
||||
|
||||
def _channel_dispatch_lang_key(channel: str) -> str:
|
||||
return f"{_CHANNEL_DISPATCH_LANG_KEY_PREFIX}{str(channel or '').strip().lower()}"
|
||||
|
||||
|
||||
def _normalize_channel_dispatch_lang(raw: Any) -> str:
|
||||
v = str(raw or "").strip().lower()
|
||||
if v in {"auto", "zh", "en"}:
|
||||
return v
|
||||
return "auto"
|
||||
|
||||
|
||||
def _normalize_channel_dispatch_channel(raw: Any) -> str:
|
||||
ch = str(raw or "").strip().lower()
|
||||
|
|
@ -361,11 +372,7 @@ def _chat_username(ctx: dict[str, Any]) -> str:
|
|||
|
||||
|
||||
def _is_administrator_chat_viewer(ctx: dict[str, Any]) -> bool:
|
||||
"""``administrator`` 账户:在单会话读写/导出等接口上可按租户打开任意会话(供审计与 Session Monitor)。
|
||||
|
||||
``GET /chat/sessions`` **不再**使用租户全量列表,避免与普通用户会话混在同一侧边栏;
|
||||
看全租户会话请用审计页、``GET /admin/api/chat/admin/sessions`` 等专用接口。
|
||||
"""
|
||||
"""``administrator`` 账户:可跨租户列出/打开本人名下所有 ``ui_session_owner`` 会话(PG 迁移后历史可能在 smoke/Team 等多租户)。"""
|
||||
return _chat_username(ctx) == "administrator"
|
||||
|
||||
|
||||
|
|
@ -377,9 +384,16 @@ def _require_administrator_chat_viewer(ctx: dict[str, Any]) -> None:
|
|||
def _resolve_chat_session(store: SqliteStore, ctx: dict[str, Any], session_id: str):
|
||||
tenant_id = str(ctx.get("tenant_id") or "")
|
||||
user_id = str(ctx.get("user_id") or "")
|
||||
sid = str(session_id or "").strip()
|
||||
if _is_administrator_chat_viewer(ctx):
|
||||
return store.get_session_in_tenant(session_id=str(session_id or "").strip(), tenant_id=tenant_id)
|
||||
return store.get_session_for_user(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
||||
sess = store.get_session_for_administrator_username(
|
||||
session_id=sid,
|
||||
username=_chat_username(ctx),
|
||||
)
|
||||
if sess is not None:
|
||||
return sess
|
||||
return store.get_session_in_tenant(session_id=sid, tenant_id=tenant_id)
|
||||
return store.get_session_for_user(session_id=sid, tenant_id=tenant_id, user_id=user_id)
|
||||
|
||||
|
||||
def _resolve_chat_session_allow_claim_orphan(
|
||||
|
|
@ -875,12 +889,17 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
ctx = resolve_auth(store, authorization)
|
||||
tenant_id = str(ctx.get("tenant_id") or "")
|
||||
user_id = str(ctx.get("user_id") or "")
|
||||
# 含 administrator:侧边栏只列「当前登录用户」名下会话,避免租户内他人会话出现在 /chat。
|
||||
# 全租户会话列表见审计、GET /admin/api/chat/admin/sessions 等。
|
||||
meta = store.get_sessions_list_meta_for_user(tenant_id=tenant_id, user_id=user_id)
|
||||
rows = store.list_sessions_for_user(
|
||||
tenant_id=tenant_id, user_id=user_id, limit=limit, offset=offset
|
||||
)
|
||||
if _is_administrator_chat_viewer(ctx):
|
||||
uname = _chat_username(ctx)
|
||||
meta = store.get_sessions_list_meta_for_administrator_username(username=uname)
|
||||
rows = store.list_sessions_for_administrator_username(
|
||||
username=uname, limit=limit, offset=offset
|
||||
)
|
||||
else:
|
||||
meta = store.get_sessions_list_meta_for_user(tenant_id=tenant_id, user_id=user_id)
|
||||
rows = store.list_sessions_for_user(
|
||||
tenant_id=tenant_id, user_id=user_id, limit=limit, offset=offset
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"total": int(meta.session_count or 0),
|
||||
|
|
@ -962,12 +981,23 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
if not sess:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
if _is_administrator_chat_viewer(ctx):
|
||||
store.delete_session_in_tenant(session_id=session_id, tenant_id=tenant_id)
|
||||
deleted = store.delete_session_for_administrator_username(
|
||||
session_id=session_id, username=_chat_username(ctx)
|
||||
)
|
||||
else:
|
||||
store.delete_session_for_user(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
||||
remaining = store.list_sessions_for_user(
|
||||
tenant_id=tenant_id, user_id=user_id, limit=1, offset=0
|
||||
)
|
||||
deleted = store.delete_session_for_user(
|
||||
session_id=session_id, tenant_id=tenant_id, user_id=user_id
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
if _is_administrator_chat_viewer(ctx):
|
||||
remaining = store.list_sessions_for_administrator_username(
|
||||
username=_chat_username(ctx), limit=1, offset=0
|
||||
)
|
||||
else:
|
||||
remaining = store.list_sessions_for_user(
|
||||
tenant_id=tenant_id, user_id=user_id, limit=1, offset=0
|
||||
)
|
||||
next_id = str(remaining[0].id) if remaining else ""
|
||||
if not next_id:
|
||||
lang = _api_lang(store)
|
||||
|
|
@ -1563,12 +1593,16 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
specialist = normalize_requested_specialist(
|
||||
store.get_setting(_channel_dispatch_specialist_key(ch)) or "generalist"
|
||||
)
|
||||
lang = _normalize_channel_dispatch_lang(
|
||||
store.get_setting(_channel_dispatch_lang_key(ch)) or "auto"
|
||||
)
|
||||
specialist = _apply_specialist_flags(store, specialist)
|
||||
return {
|
||||
"ok": True,
|
||||
"channel": ch,
|
||||
"interaction_mode": interaction_mode,
|
||||
"specialist": specialist,
|
||||
"lang": lang,
|
||||
"available_specialists": [sid for sid in _chat_specialist_ids() if bool(_specialist_flags_with_overrides(store).get(sid, True))],
|
||||
}
|
||||
|
||||
|
|
@ -1585,14 +1619,17 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
ch = _normalize_channel_dispatch_channel(channel)
|
||||
interaction_mode = normalize_interaction_mode(body.get("interaction_mode") or "expert")
|
||||
specialist = normalize_requested_specialist(body.get("specialist") or "generalist")
|
||||
lang = _normalize_channel_dispatch_lang(body.get("lang") or "auto")
|
||||
specialist = _apply_specialist_flags(store, specialist)
|
||||
store.set_setting(_channel_dispatch_interaction_key(ch), interaction_mode)
|
||||
store.set_setting(_channel_dispatch_specialist_key(ch), specialist)
|
||||
store.set_setting(_channel_dispatch_lang_key(ch), lang)
|
||||
return {
|
||||
"ok": True,
|
||||
"channel": ch,
|
||||
"interaction_mode": interaction_mode,
|
||||
"specialist": specialist,
|
||||
"lang": lang,
|
||||
}
|
||||
|
||||
@chat.get("/settings/attachment-limits")
|
||||
|
|
|
|||
|
|
@ -333,6 +333,48 @@ def build_admin_router() -> APIRouter:
|
|||
return parts[1].strip()
|
||||
return ""
|
||||
|
||||
def _resolve_login_tenant_id(store: SqliteStore, *, username: str, tenant_id: str) -> str:
|
||||
"""Pick tenant when login body omits tenant_id.
|
||||
|
||||
Chat login sends tenant_id=\"\". Using list_tenants(limit=1) alone picks the newest tenant
|
||||
(often pg-smoke), which has no chat history. For administrator, prefer the Team tenant,
|
||||
then the tenant with the most owned sessions.
|
||||
"""
|
||||
explicit = str(tenant_id or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
uname = str(username or "").strip().lower()
|
||||
tenants = store.list_tenants(limit=500)
|
||||
if not tenants:
|
||||
return ""
|
||||
if uname == "administrator":
|
||||
for row in tenants:
|
||||
tid = str((row or {}).get("id") or "").strip()
|
||||
if not tid:
|
||||
continue
|
||||
if str((row or {}).get("name") or "").strip().lower() != "team":
|
||||
continue
|
||||
if store.get_user_by_username(tenant_id=tid, username="administrator"):
|
||||
return tid
|
||||
best_tid = ""
|
||||
best_n = -1
|
||||
for row in tenants:
|
||||
tid = str((row or {}).get("id") or "").strip()
|
||||
if not tid:
|
||||
continue
|
||||
user = store.get_user_by_username(tenant_id=tid, username="administrator")
|
||||
if not user:
|
||||
continue
|
||||
uid = str(user.get("id") or "")
|
||||
meta = store.get_sessions_list_meta_for_user(tenant_id=tid, user_id=uid)
|
||||
n = int(meta.session_count or 0)
|
||||
if n > best_n:
|
||||
best_n = n
|
||||
best_tid = tid
|
||||
if best_tid:
|
||||
return best_tid
|
||||
return str((tenants[0] or {}).get("id") or "").strip()
|
||||
|
||||
def _resolve_auth(store: SqliteStore, authorization: str | None) -> dict[str, Any]:
|
||||
token = _extract_bearer(authorization)
|
||||
if not token:
|
||||
|
|
@ -3751,11 +3793,6 @@ def build_admin_router() -> APIRouter:
|
|||
payload = payload or {}
|
||||
store = get_assistant_store()
|
||||
_ensure_admin_bootstrap(store)
|
||||
tenant_id = str(payload.get("tenant_id") or "").strip()
|
||||
if not tenant_id:
|
||||
tenants = store.list_tenants(limit=1)
|
||||
if tenants:
|
||||
tenant_id = str((tenants[0] or {}).get("id") or "").strip()
|
||||
purpose = str(payload.get("purpose") or "console").strip().lower()
|
||||
if purpose not in {"console", "chat"}:
|
||||
purpose = "console"
|
||||
|
|
@ -3763,6 +3800,11 @@ def build_admin_router() -> APIRouter:
|
|||
username = str(payload.get("username") or "administrator").strip().lower()
|
||||
else:
|
||||
username = str(payload.get("username") or "").strip().lower()
|
||||
tenant_id = _resolve_login_tenant_id(
|
||||
store,
|
||||
username=username,
|
||||
tenant_id=str(payload.get("tenant_id") or "").strip(),
|
||||
)
|
||||
password = str(payload.get("password") or "").strip()
|
||||
if not tenant_id or not password:
|
||||
return {"ok": False, "error": "tenant_id, username, password are required"}
|
||||
|
|
|
|||
|
|
@ -1528,20 +1528,28 @@ async function renderStack() {
|
|||
const createChannelDispatchCard = (channel, title, initial) => {
|
||||
const curMode = String((initial && initial.interaction_mode) || "expert").trim() || "expert";
|
||||
const curSpecialist = String((initial && initial.specialist) || "generalist").trim() || "generalist";
|
||||
const curLang = String((initial && initial.lang) || "auto").trim().toLowerCase() || "auto";
|
||||
const specialistSel = el("select", { class: "input" }, availableDispatchSpecialists.map((sid) =>
|
||||
el("option", { value: sid, text: sid, selected: sid === curSpecialist ? "selected" : undefined }),
|
||||
));
|
||||
const status = el("div", { class: "muted", text: `mode=${curMode} specialist=${curSpecialist}` });
|
||||
const langSel = el("select", { class: "input" }, [
|
||||
el("option", { value: "auto", text: currentLang === "zh" ? "自动" : "Auto", selected: curLang === "auto" ? "selected" : undefined }),
|
||||
el("option", { value: "zh", text: currentLang === "zh" ? "中文" : "Chinese", selected: curLang === "zh" ? "selected" : undefined }),
|
||||
el("option", { value: "en", text: currentLang === "zh" ? "英文" : "English", selected: curLang === "en" ? "selected" : undefined }),
|
||||
]);
|
||||
const status = el("div", { class: "muted", text: `mode=${curMode} specialist=${curSpecialist} lang=${curLang}` });
|
||||
const saveExpertBtn = el("button", {
|
||||
class: "btn",
|
||||
text: currentLang === "zh" ? "绑定专家" : "Bind specialist",
|
||||
onclick: async () => {
|
||||
const specialist = String(specialistSel.value || "generalist").trim() || "generalist";
|
||||
const lang = String(langSel.value || "auto").trim().toLowerCase() || "auto";
|
||||
const resp = await apiPost(`/admin/api/chat/settings/channel-dispatch/${encodeURIComponent(channel)}`, {
|
||||
interaction_mode: "expert",
|
||||
specialist,
|
||||
lang,
|
||||
});
|
||||
status.textContent = `mode=${String(resp.interaction_mode || "expert")} specialist=${String(resp.specialist || specialist)}`;
|
||||
status.textContent = `mode=${String(resp.interaction_mode || "expert")} specialist=${String(resp.specialist || specialist)} lang=${String(resp.lang || lang)}`;
|
||||
},
|
||||
});
|
||||
const saveComprehensiveBtn = el("button", {
|
||||
|
|
@ -1549,11 +1557,13 @@ async function renderStack() {
|
|||
text: currentLang === "zh" ? "综合" : "Comprehensive",
|
||||
onclick: async () => {
|
||||
const specialist = String(specialistSel.value || "generalist").trim() || "generalist";
|
||||
const lang = String(langSel.value || "auto").trim().toLowerCase() || "auto";
|
||||
const resp = await apiPost(`/admin/api/chat/settings/channel-dispatch/${encodeURIComponent(channel)}`, {
|
||||
interaction_mode: "comprehensive",
|
||||
specialist,
|
||||
lang,
|
||||
});
|
||||
status.textContent = `mode=${String(resp.interaction_mode || "comprehensive")} specialist=${String(resp.specialist || specialist)}`;
|
||||
status.textContent = `mode=${String(resp.interaction_mode || "comprehensive")} specialist=${String(resp.specialist || specialist)} lang=${String(resp.lang || lang)}`;
|
||||
},
|
||||
});
|
||||
return el("div", { class: "card" }, [
|
||||
|
|
@ -1561,6 +1571,8 @@ async function renderStack() {
|
|||
el("div", { class: "row" }, [
|
||||
el("label", { text: currentLang === "zh" ? "专家" : "Specialist" }),
|
||||
specialistSel,
|
||||
el("label", { text: currentLang === "zh" ? "语言" : "Lang" }),
|
||||
langSel,
|
||||
saveExpertBtn,
|
||||
saveComprehensiveBtn,
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -49,6 +49,40 @@ def _now_ms() -> int:
|
|||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _native_reply_timeout_sec() -> float:
|
||||
raw = str(os.getenv("AIA_CHANNEL_NATIVE_REPLY_TIMEOUT_SEC") or "90").strip()
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
value = 90.0
|
||||
return max(10.0, min(value, 600.0))
|
||||
|
||||
|
||||
async def _run_native_reply_usecase(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
timeout = _native_reply_timeout_sec()
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
asyncio.to_thread(_process_inbound_payload_usecase, payload),
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
channel = _normalize_channel(payload.get("channel"))
|
||||
chat_id = str(payload.get("chat_id") or payload.get("user_id") or "").strip()
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "native_reply_timeout",
|
||||
"replies": [
|
||||
{
|
||||
"channel": channel,
|
||||
"chat_id": chat_id,
|
||||
"text": "处理超时,请稍后再试。",
|
||||
"attachments": [],
|
||||
"metadata": {},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _normalize_channel(raw: Any) -> str:
|
||||
ch = str(raw or "wechat").strip().lower()
|
||||
return ch if ch else "wechat"
|
||||
|
|
@ -468,7 +502,7 @@ async def weixin_native_reply(
|
|||
return {"ok": False, "error": "missing user_id", "replies": []}
|
||||
if not str(payload.get("account_id") or "").strip():
|
||||
return {"ok": False, "error": "missing account_id", "replies": []}
|
||||
out = await asyncio.to_thread(_process_inbound_payload_usecase, payload)
|
||||
out = await _run_native_reply_usecase(payload)
|
||||
replies = out.get("replies") if isinstance(out, dict) else []
|
||||
if not isinstance(replies, list):
|
||||
replies = []
|
||||
|
|
@ -490,7 +524,7 @@ async def whatsapp_native_reply(
|
|||
return {"ok": False, "error": "missing user_id", "replies": []}
|
||||
if not str(payload.get("account_id") or "").strip():
|
||||
return {"ok": False, "error": "missing account_id", "replies": []}
|
||||
out = await asyncio.to_thread(_process_inbound_payload_usecase, payload)
|
||||
out = await _run_native_reply_usecase(payload)
|
||||
replies = out.get("replies") if isinstance(out, dict) else []
|
||||
if not isinstance(replies, list):
|
||||
replies = []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue