mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-19 15:33:18 +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
|
|
@ -280,8 +280,33 @@ powershell -ExecutionPolicy Bypass -File .\runtime\operations\scripts\whatsapp_s
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- `whatsapp_login.ps1` 会在控制台打印二维码,请用 WhatsApp 手机端的“关联设备”扫码完成绑定。
|
- `whatsapp_login.ps1` 会在控制台打印二维码,请用 WhatsApp 手机端的“关联设备”扫码完成绑定。
|
||||||
- 登录态会落盘在 `data/channel_sidecar/whatsapp/state/auth/`,重启后无需重复扫码。
|
- 登录态会落盘在 `data/channel_sidecar/whatsapp/state/auth/`,重启 sidecar 后**一般无需重复扫码**。
|
||||||
- sidecar 收到消息后会调用本地网关 `POST /inbound/whatsapp` 获取 `replies[]` 并回发。
|
- sidecar 收到消息后会调用本地网关 `POST /inbound/whatsapp` 获取 `replies[]` 并回发。
|
||||||
|
- sidecar 日志出现 `logged out` 时,需删除上述 `auth` 目录后再执行 `whatsapp_login.ps1`(见下节)。
|
||||||
|
|
||||||
|
#### 重新绑定设备(换号 / 手机端已解除关联 / 要出新二维码)
|
||||||
|
|
||||||
|
与首次安装不同:**必须先清掉旧登录态**,否则 `whatsapp_login.ps1` 可能不会出现二维码。
|
||||||
|
|
||||||
|
在仓库根目录执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\runtime\operations\scripts\whatsapp_stop.ps1 -Force
|
||||||
|
Remove-Item -Recurse -Force .\data\channel_sidecar\whatsapp\state\auth
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\runtime\operations\scripts\whatsapp_login.ps1
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\runtime\operations\scripts\whatsapp_start.ps1
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\runtime\operations\scripts\whatsapp_status.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
可选:在手机 WhatsApp **设置 → 已关联的设备** 中删除旧的 “oclaw” 设备,再扫码。
|
||||||
|
|
||||||
|
#### 重新绑定 oclaw 用户(控制台渠道绑定)
|
||||||
|
|
||||||
|
设备已连上、只需把某个 WhatsApp 联系人归属到团队用户时:
|
||||||
|
|
||||||
|
1. 打开 `http://127.0.0.1:8787/admin` → **用户/渠道绑定**,渠道选 `whatsapp`。
|
||||||
|
2. 生成绑定码;用该 WhatsApp 向机器人发送:`bind <绑定码>`(与微信相同)。
|
||||||
|
3. 无需删除 `state/auth`(那是设备登录态,不是用户归属)。
|
||||||
|
|
||||||
Admin 可视化调度(新增):
|
Admin 可视化调度(新增):
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ _DISPATCH_REASON_LABELS_SETTING_KEY = "AIA_DISPATCH_REASON_LABELS_JSON"
|
||||||
_SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON"
|
_SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON"
|
||||||
_CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX = "channel.dispatch.interaction_mode."
|
_CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX = "channel.dispatch.interaction_mode."
|
||||||
_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX = "channel.dispatch.specialist."
|
_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX = "channel.dispatch.specialist."
|
||||||
|
_CHANNEL_DISPATCH_LANG_KEY_PREFIX = "channel.dispatch.lang."
|
||||||
|
|
||||||
|
|
||||||
def _channel_dispatch_interaction_key(channel: str) -> str:
|
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:
|
def _channel_dispatch_specialist_key(channel: str) -> str:
|
||||||
return f"{_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX}{str(channel or '').strip().lower()}"
|
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:
|
def _normalize_channel_dispatch_channel(raw: Any) -> str:
|
||||||
ch = str(raw or "").strip().lower()
|
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:
|
def _is_administrator_chat_viewer(ctx: dict[str, Any]) -> bool:
|
||||||
"""``administrator`` 账户:在单会话读写/导出等接口上可按租户打开任意会话(供审计与 Session Monitor)。
|
"""``administrator`` 账户:可跨租户列出/打开本人名下所有 ``ui_session_owner`` 会话(PG 迁移后历史可能在 smoke/Team 等多租户)。"""
|
||||||
|
|
||||||
``GET /chat/sessions`` **不再**使用租户全量列表,避免与普通用户会话混在同一侧边栏;
|
|
||||||
看全租户会话请用审计页、``GET /admin/api/chat/admin/sessions`` 等专用接口。
|
|
||||||
"""
|
|
||||||
return _chat_username(ctx) == "administrator"
|
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):
|
def _resolve_chat_session(store: SqliteStore, ctx: dict[str, Any], session_id: str):
|
||||||
tenant_id = str(ctx.get("tenant_id") or "")
|
tenant_id = str(ctx.get("tenant_id") or "")
|
||||||
user_id = str(ctx.get("user_id") or "")
|
user_id = str(ctx.get("user_id") or "")
|
||||||
|
sid = str(session_id or "").strip()
|
||||||
if _is_administrator_chat_viewer(ctx):
|
if _is_administrator_chat_viewer(ctx):
|
||||||
return store.get_session_in_tenant(session_id=str(session_id or "").strip(), tenant_id=tenant_id)
|
sess = store.get_session_for_administrator_username(
|
||||||
return store.get_session_for_user(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
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(
|
def _resolve_chat_session_allow_claim_orphan(
|
||||||
|
|
@ -875,8 +889,13 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
||||||
ctx = resolve_auth(store, authorization)
|
ctx = resolve_auth(store, authorization)
|
||||||
tenant_id = str(ctx.get("tenant_id") or "")
|
tenant_id = str(ctx.get("tenant_id") or "")
|
||||||
user_id = str(ctx.get("user_id") or "")
|
user_id = str(ctx.get("user_id") or "")
|
||||||
# 含 administrator:侧边栏只列「当前登录用户」名下会话,避免租户内他人会话出现在 /chat。
|
if _is_administrator_chat_viewer(ctx):
|
||||||
# 全租户会话列表见审计、GET /admin/api/chat/admin/sessions 等。
|
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)
|
meta = store.get_sessions_list_meta_for_user(tenant_id=tenant_id, user_id=user_id)
|
||||||
rows = store.list_sessions_for_user(
|
rows = store.list_sessions_for_user(
|
||||||
tenant_id=tenant_id, user_id=user_id, limit=limit, offset=offset
|
tenant_id=tenant_id, user_id=user_id, limit=limit, offset=offset
|
||||||
|
|
@ -962,9 +981,20 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
||||||
if not sess:
|
if not sess:
|
||||||
raise HTTPException(status_code=404, detail="session_not_found")
|
raise HTTPException(status_code=404, detail="session_not_found")
|
||||||
if _is_administrator_chat_viewer(ctx):
|
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:
|
||||||
|
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:
|
else:
|
||||||
store.delete_session_for_user(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
|
||||||
remaining = store.list_sessions_for_user(
|
remaining = store.list_sessions_for_user(
|
||||||
tenant_id=tenant_id, user_id=user_id, limit=1, offset=0
|
tenant_id=tenant_id, user_id=user_id, limit=1, offset=0
|
||||||
)
|
)
|
||||||
|
|
@ -1563,12 +1593,16 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
||||||
specialist = normalize_requested_specialist(
|
specialist = normalize_requested_specialist(
|
||||||
store.get_setting(_channel_dispatch_specialist_key(ch)) or "generalist"
|
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)
|
specialist = _apply_specialist_flags(store, specialist)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"channel": ch,
|
"channel": ch,
|
||||||
"interaction_mode": interaction_mode,
|
"interaction_mode": interaction_mode,
|
||||||
"specialist": specialist,
|
"specialist": specialist,
|
||||||
|
"lang": lang,
|
||||||
"available_specialists": [sid for sid in _chat_specialist_ids() if bool(_specialist_flags_with_overrides(store).get(sid, True))],
|
"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)
|
ch = _normalize_channel_dispatch_channel(channel)
|
||||||
interaction_mode = normalize_interaction_mode(body.get("interaction_mode") or "expert")
|
interaction_mode = normalize_interaction_mode(body.get("interaction_mode") or "expert")
|
||||||
specialist = normalize_requested_specialist(body.get("specialist") or "generalist")
|
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)
|
specialist = _apply_specialist_flags(store, specialist)
|
||||||
store.set_setting(_channel_dispatch_interaction_key(ch), interaction_mode)
|
store.set_setting(_channel_dispatch_interaction_key(ch), interaction_mode)
|
||||||
store.set_setting(_channel_dispatch_specialist_key(ch), specialist)
|
store.set_setting(_channel_dispatch_specialist_key(ch), specialist)
|
||||||
|
store.set_setting(_channel_dispatch_lang_key(ch), lang)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"channel": ch,
|
"channel": ch,
|
||||||
"interaction_mode": interaction_mode,
|
"interaction_mode": interaction_mode,
|
||||||
"specialist": specialist,
|
"specialist": specialist,
|
||||||
|
"lang": lang,
|
||||||
}
|
}
|
||||||
|
|
||||||
@chat.get("/settings/attachment-limits")
|
@chat.get("/settings/attachment-limits")
|
||||||
|
|
|
||||||
|
|
@ -333,6 +333,48 @@ def build_admin_router() -> APIRouter:
|
||||||
return parts[1].strip()
|
return parts[1].strip()
|
||||||
return ""
|
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]:
|
def _resolve_auth(store: SqliteStore, authorization: str | None) -> dict[str, Any]:
|
||||||
token = _extract_bearer(authorization)
|
token = _extract_bearer(authorization)
|
||||||
if not token:
|
if not token:
|
||||||
|
|
@ -3751,11 +3793,6 @@ def build_admin_router() -> APIRouter:
|
||||||
payload = payload or {}
|
payload = payload or {}
|
||||||
store = get_assistant_store()
|
store = get_assistant_store()
|
||||||
_ensure_admin_bootstrap(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()
|
purpose = str(payload.get("purpose") or "console").strip().lower()
|
||||||
if purpose not in {"console", "chat"}:
|
if purpose not in {"console", "chat"}:
|
||||||
purpose = "console"
|
purpose = "console"
|
||||||
|
|
@ -3763,6 +3800,11 @@ def build_admin_router() -> APIRouter:
|
||||||
username = str(payload.get("username") or "administrator").strip().lower()
|
username = str(payload.get("username") or "administrator").strip().lower()
|
||||||
else:
|
else:
|
||||||
username = str(payload.get("username") or "").strip().lower()
|
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()
|
password = str(payload.get("password") or "").strip()
|
||||||
if not tenant_id or not password:
|
if not tenant_id or not password:
|
||||||
return {"ok": False, "error": "tenant_id, username, password are required"}
|
return {"ok": False, "error": "tenant_id, username, password are required"}
|
||||||
|
|
|
||||||
|
|
@ -1528,20 +1528,28 @@ async function renderStack() {
|
||||||
const createChannelDispatchCard = (channel, title, initial) => {
|
const createChannelDispatchCard = (channel, title, initial) => {
|
||||||
const curMode = String((initial && initial.interaction_mode) || "expert").trim() || "expert";
|
const curMode = String((initial && initial.interaction_mode) || "expert").trim() || "expert";
|
||||||
const curSpecialist = String((initial && initial.specialist) || "generalist").trim() || "generalist";
|
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) =>
|
const specialistSel = el("select", { class: "input" }, availableDispatchSpecialists.map((sid) =>
|
||||||
el("option", { value: sid, text: sid, selected: sid === curSpecialist ? "selected" : undefined }),
|
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", {
|
const saveExpertBtn = el("button", {
|
||||||
class: "btn",
|
class: "btn",
|
||||||
text: currentLang === "zh" ? "绑定专家" : "Bind specialist",
|
text: currentLang === "zh" ? "绑定专家" : "Bind specialist",
|
||||||
onclick: async () => {
|
onclick: async () => {
|
||||||
const specialist = String(specialistSel.value || "generalist").trim() || "generalist";
|
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)}`, {
|
const resp = await apiPost(`/admin/api/chat/settings/channel-dispatch/${encodeURIComponent(channel)}`, {
|
||||||
interaction_mode: "expert",
|
interaction_mode: "expert",
|
||||||
specialist,
|
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", {
|
const saveComprehensiveBtn = el("button", {
|
||||||
|
|
@ -1549,11 +1557,13 @@ async function renderStack() {
|
||||||
text: currentLang === "zh" ? "综合" : "Comprehensive",
|
text: currentLang === "zh" ? "综合" : "Comprehensive",
|
||||||
onclick: async () => {
|
onclick: async () => {
|
||||||
const specialist = String(specialistSel.value || "generalist").trim() || "generalist";
|
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)}`, {
|
const resp = await apiPost(`/admin/api/chat/settings/channel-dispatch/${encodeURIComponent(channel)}`, {
|
||||||
interaction_mode: "comprehensive",
|
interaction_mode: "comprehensive",
|
||||||
specialist,
|
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" }, [
|
return el("div", { class: "card" }, [
|
||||||
|
|
@ -1561,6 +1571,8 @@ async function renderStack() {
|
||||||
el("div", { class: "row" }, [
|
el("div", { class: "row" }, [
|
||||||
el("label", { text: currentLang === "zh" ? "专家" : "Specialist" }),
|
el("label", { text: currentLang === "zh" ? "专家" : "Specialist" }),
|
||||||
specialistSel,
|
specialistSel,
|
||||||
|
el("label", { text: currentLang === "zh" ? "语言" : "Lang" }),
|
||||||
|
langSel,
|
||||||
saveExpertBtn,
|
saveExpertBtn,
|
||||||
saveComprehensiveBtn,
|
saveComprehensiveBtn,
|
||||||
]),
|
]),
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,40 @@ def _now_ms() -> int:
|
||||||
return int(time.time() * 1000)
|
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:
|
def _normalize_channel(raw: Any) -> str:
|
||||||
ch = str(raw or "wechat").strip().lower()
|
ch = str(raw or "wechat").strip().lower()
|
||||||
return ch if ch else "wechat"
|
return ch if ch else "wechat"
|
||||||
|
|
@ -468,7 +502,7 @@ async def weixin_native_reply(
|
||||||
return {"ok": False, "error": "missing user_id", "replies": []}
|
return {"ok": False, "error": "missing user_id", "replies": []}
|
||||||
if not str(payload.get("account_id") or "").strip():
|
if not str(payload.get("account_id") or "").strip():
|
||||||
return {"ok": False, "error": "missing account_id", "replies": []}
|
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 []
|
replies = out.get("replies") if isinstance(out, dict) else []
|
||||||
if not isinstance(replies, list):
|
if not isinstance(replies, list):
|
||||||
replies = []
|
replies = []
|
||||||
|
|
@ -490,7 +524,7 @@ async def whatsapp_native_reply(
|
||||||
return {"ok": False, "error": "missing user_id", "replies": []}
|
return {"ok": False, "error": "missing user_id", "replies": []}
|
||||||
if not str(payload.get("account_id") or "").strip():
|
if not str(payload.get("account_id") or "").strip():
|
||||||
return {"ok": False, "error": "missing account_id", "replies": []}
|
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 []
|
replies = out.get("replies") if isinstance(out, dict) else []
|
||||||
if not isinstance(replies, list):
|
if not isinstance(replies, list):
|
||||||
replies = []
|
replies = []
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from svc.persistence.assistant_store import get_assistant_store
|
||||||
|
|
||||||
_CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX = "channel.dispatch.interaction_mode."
|
_CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX = "channel.dispatch.interaction_mode."
|
||||||
_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX = "channel.dispatch.specialist."
|
_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX = "channel.dispatch.specialist."
|
||||||
|
_CHANNEL_DISPATCH_LANG_KEY_PREFIX = "channel.dispatch.lang."
|
||||||
|
|
||||||
|
|
||||||
def _channel_dispatch_interaction_key(channel: str) -> str:
|
def _channel_dispatch_interaction_key(channel: str) -> str:
|
||||||
|
|
@ -20,28 +21,65 @@ def _channel_dispatch_interaction_key(channel: str) -> str:
|
||||||
def _channel_dispatch_specialist_key(channel: str) -> str:
|
def _channel_dispatch_specialist_key(channel: str) -> str:
|
||||||
return f"{_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX}{str(channel or '').strip().lower()}"
|
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 _resolve_channel_dispatch(store: Any, *, channel: str, account: dict[str, Any] | None) -> tuple[str, str]:
|
|
||||||
|
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 _channel_dispatch_setting_aliases(channel: str) -> tuple[str, ...]:
|
||||||
ch = str(channel or "").strip().lower()
|
ch = str(channel or "").strip().lower()
|
||||||
interaction_mode = normalize_interaction_mode(store.get_setting(_channel_dispatch_interaction_key(ch)) or "expert")
|
if ch == "wechat":
|
||||||
specialist = normalize_requested_specialist(store.get_setting(_channel_dispatch_specialist_key(ch)) or "generalist")
|
return ("wechat", "weixin")
|
||||||
|
if ch == "weixin":
|
||||||
|
return ("weixin", "wechat")
|
||||||
|
return (ch,)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_channel_dispatch_setting(store: Any, key_prefix: str, channel: str) -> str:
|
||||||
|
for alias in _channel_dispatch_setting_aliases(channel):
|
||||||
|
val = str(store.get_setting(f"{key_prefix}{alias}") or "").strip()
|
||||||
|
if val:
|
||||||
|
return val
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_channel_dispatch(store: Any, *, channel: str, account: dict[str, Any] | None) -> tuple[str, str, str]:
|
||||||
|
ch = str(channel or "").strip().lower()
|
||||||
|
interaction_mode = normalize_interaction_mode(
|
||||||
|
_get_channel_dispatch_setting(store, _CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX, ch) or "expert"
|
||||||
|
)
|
||||||
|
specialist = normalize_requested_specialist(
|
||||||
|
_get_channel_dispatch_setting(store, _CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX, ch) or "generalist"
|
||||||
|
)
|
||||||
|
lang = _normalize_channel_dispatch_lang(
|
||||||
|
_get_channel_dispatch_setting(store, _CHANNEL_DISPATCH_LANG_KEY_PREFIX, ch) or "auto"
|
||||||
|
)
|
||||||
cfg = (account or {}).get("config")
|
cfg = (account or {}).get("config")
|
||||||
if isinstance(cfg, dict):
|
if isinstance(cfg, dict):
|
||||||
cfg_mode = cfg.get("interaction_mode")
|
cfg_mode = cfg.get("interaction_mode")
|
||||||
cfg_specialist = cfg.get("specialist")
|
cfg_specialist = cfg.get("specialist")
|
||||||
|
cfg_lang = cfg.get("lang")
|
||||||
if cfg_mode is not None:
|
if cfg_mode is not None:
|
||||||
interaction_mode = normalize_interaction_mode(cfg_mode)
|
interaction_mode = normalize_interaction_mode(cfg_mode)
|
||||||
if cfg_specialist is not None:
|
if cfg_specialist is not None:
|
||||||
specialist = normalize_requested_specialist(cfg_specialist)
|
specialist = normalize_requested_specialist(cfg_specialist)
|
||||||
return interaction_mode, specialist
|
if cfg_lang is not None:
|
||||||
|
lang = _normalize_channel_dispatch_lang(cfg_lang)
|
||||||
|
return interaction_mode, specialist, lang
|
||||||
|
|
||||||
|
|
||||||
def _build_admin_gateway_executor(store: Any, *, tenant_id: str, specialist: str, session_id: str) -> Any:
|
def _build_admin_gateway_executor(store: Any, *, tenant_id: str, specialist: str, session_id: str, lang: str) -> Any:
|
||||||
from runtime.agents.factory import build_gateway_executor
|
from runtime.agents.factory import build_gateway_executor
|
||||||
|
|
||||||
return build_gateway_executor(
|
return build_gateway_executor(
|
||||||
store,
|
store,
|
||||||
lang="zh",
|
lang=str(lang or "zh"),
|
||||||
specialist=specialist,
|
specialist=specialist,
|
||||||
viewer_user_id=None,
|
viewer_user_id=None,
|
||||||
viewer_username="administrator",
|
viewer_username="administrator",
|
||||||
|
|
@ -301,6 +339,45 @@ def _parse_generic_inbound(channel_name: str, payload: dict[str, Any]) -> Inboun
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_attachments_for_gateway(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Ingest sidecar local_path blobs into attachment store refs for handle_turn."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from svc.files.attachment_assets import AttachmentAssetStore
|
||||||
|
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
ast = AttachmentAssetStore()
|
||||||
|
for a in raw or []:
|
||||||
|
if not isinstance(a, dict):
|
||||||
|
continue
|
||||||
|
aid = str(a.get("attachment_id") or a.get("attachmentId") or "").strip().lower()
|
||||||
|
if aid:
|
||||||
|
t = str(a.get("type") or "").strip().lower() or "binary_ref"
|
||||||
|
out.append({"type": t, "attachment_id": aid})
|
||||||
|
continue
|
||||||
|
lp = str(a.get("local_path") or a.get("media_path") or "").strip()
|
||||||
|
if not lp:
|
||||||
|
continue
|
||||||
|
p = Path(lp)
|
||||||
|
if not p.is_file():
|
||||||
|
continue
|
||||||
|
kind = str(a.get("kind") or "").strip().lower()
|
||||||
|
mime = str(a.get("media_type") or a.get("mime") or a.get("mime_type") or "").strip()
|
||||||
|
if not mime:
|
||||||
|
mime = "application/octet-stream"
|
||||||
|
try:
|
||||||
|
meta = ast.save_bytes(p.read_bytes(), filename=p.name, mime=mime)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if kind == "image" or mime.startswith("image/"):
|
||||||
|
out.append({"type": "image_ref", "attachment_id": meta.attachment_id})
|
||||||
|
elif kind == "video" or mime.startswith("video/"):
|
||||||
|
out.append({"type": "video_ref", "attachment_id": meta.attachment_id})
|
||||||
|
else:
|
||||||
|
out.append({"type": "binary_ref", "attachment_id": meta.attachment_id})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _parse_message_attachments(raw: Any) -> list[dict[str, Any]]:
|
def _parse_message_attachments(raw: Any) -> list[dict[str, Any]]:
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return []
|
return []
|
||||||
|
|
@ -330,10 +407,22 @@ def _first_attachment_id(atts: list[dict[str, Any]]) -> str:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _collect_recent_tool_attachments(*, store: Any, session_id: str) -> list[dict[str, Any]]:
|
def _rows_since_last_user_message(rows: list[Any]) -> list[Any]:
|
||||||
"""Fallback for channel delivery: if assistant attachments are missing, reuse recent tool attachments.
|
"""Return messages after the most recent user turn (current inbound reply scope)."""
|
||||||
|
last_user_idx = -1
|
||||||
|
for i, row in enumerate(rows or []):
|
||||||
|
role = str(getattr(row, "role", "") or "").strip().lower()
|
||||||
|
if role == "user":
|
||||||
|
last_user_idx = i
|
||||||
|
if last_user_idx < 0:
|
||||||
|
return list(rows or [])
|
||||||
|
return list(rows[last_user_idx + 1 :])
|
||||||
|
|
||||||
This is intentionally conservative: only returns attachments when they look like media or refs.
|
|
||||||
|
def _collect_recent_tool_attachments(*, store: Any, session_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""Fallback for channel delivery: reuse tool media produced during the current user turn only.
|
||||||
|
|
||||||
|
Avoids re-sending images from earlier conversation turns when the latest assistant row has no attachments.
|
||||||
"""
|
"""
|
||||||
sid = str(session_id or "").strip()
|
sid = str(session_id or "").strip()
|
||||||
if not sid:
|
if not sid:
|
||||||
|
|
@ -342,15 +431,14 @@ def _collect_recent_tool_attachments(*, store: Any, session_id: str) -> list[dic
|
||||||
rows = store.get_messages(session_id=sid, limit=80)
|
rows = store.get_messages(session_id=sid, limit=80)
|
||||||
except Exception:
|
except Exception:
|
||||||
rows = []
|
rows = []
|
||||||
# Prefer newest tool attachments.
|
scoped = _rows_since_last_user_message(list(rows or []))
|
||||||
for row in reversed(list(rows or [])):
|
for row in reversed(scoped):
|
||||||
role = str(getattr(row, "role", "") or "").strip().lower()
|
role = str(getattr(row, "role", "") or "").strip().lower()
|
||||||
if role != "tool":
|
if role != "tool":
|
||||||
continue
|
continue
|
||||||
atts = _parse_message_attachments(getattr(row, "attachments", None))
|
atts = _parse_message_attachments(getattr(row, "attachments", None))
|
||||||
if not atts:
|
if not atts:
|
||||||
continue
|
continue
|
||||||
# Only accept common media/ref shapes.
|
|
||||||
ok = False
|
ok = False
|
||||||
for a in atts:
|
for a in atts:
|
||||||
if not isinstance(a, dict):
|
if not isinstance(a, dict):
|
||||||
|
|
@ -447,6 +535,11 @@ def _maybe_expand_reply_attachments_for_channel(reply: dict[str, Any]) -> None:
|
||||||
|
|
||||||
|
|
||||||
def _collect_reply_attachments_from_history(*, store: Any, session_id: str, reply_text: str) -> list[dict[str, Any]]:
|
def _collect_reply_attachments_from_history(*, store: Any, session_id: str, reply_text: str) -> list[dict[str, Any]]:
|
||||||
|
"""Attachments for the assistant message whose text equals ``reply_text`` (latest match only).
|
||||||
|
|
||||||
|
Does not fall back to older assistant rows with attachments — that caused WeChat to re-send stale images
|
||||||
|
on every later text-only reply in the same session.
|
||||||
|
"""
|
||||||
sid = str(session_id or "").strip()
|
sid = str(session_id or "").strip()
|
||||||
if not sid:
|
if not sid:
|
||||||
return []
|
return []
|
||||||
|
|
@ -455,19 +548,64 @@ def _collect_reply_attachments_from_history(*, store: Any, session_id: str, repl
|
||||||
except Exception:
|
except Exception:
|
||||||
rows = []
|
rows = []
|
||||||
target = str(reply_text or "").strip()
|
target = str(reply_text or "").strip()
|
||||||
fallback: list[dict[str, Any]] = []
|
if not target:
|
||||||
matched: list[dict[str, Any]] = []
|
return []
|
||||||
for row in rows or []:
|
for row in reversed(list(rows or [])):
|
||||||
role = str(getattr(row, "role", "") or "").strip().lower()
|
role = str(getattr(row, "role", "") or "").strip().lower()
|
||||||
if role != "assistant":
|
if role != "assistant":
|
||||||
continue
|
continue
|
||||||
atts = _parse_message_attachments(getattr(row, "attachments", None))
|
|
||||||
if atts:
|
|
||||||
fallback = atts
|
|
||||||
content = str(getattr(row, "content", "") or "").strip()
|
content = str(getattr(row, "content", "") or "").strip()
|
||||||
if target and content == target and atts:
|
if content != target:
|
||||||
matched = atts
|
continue
|
||||||
return matched or fallback
|
return _parse_message_attachments(getattr(row, "attachments", None))
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _user_facing_wechat_reply(*, reply: str) -> str:
|
||||||
|
"""Map empty/suppressed provider errors to a short user-visible wechat message."""
|
||||||
|
text = str(reply or "").strip()
|
||||||
|
if not text:
|
||||||
|
return "暂时无法回复,请稍后再试。"
|
||||||
|
if _should_suppress_channel_reply(channel="wechat", text=text):
|
||||||
|
return "模型 API 未配置或不可用,请联系管理员在后台检查大模型配置。"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_user_turn_uuid(store: Any, *, session_id: str) -> str:
|
||||||
|
rows = store.get_messages(session_id=str(session_id), limit=80)
|
||||||
|
for m in reversed(rows or []):
|
||||||
|
if str(getattr(m, "role", "") or "").lower() != "user":
|
||||||
|
continue
|
||||||
|
tu = str(getattr(m, "turn_uuid", "") or "").strip()
|
||||||
|
if tu:
|
||||||
|
return tu
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_channel_assistant_if_turn_missing(
|
||||||
|
*,
|
||||||
|
store: Any,
|
||||||
|
session_id: str,
|
||||||
|
turn_uuid: str,
|
||||||
|
final_text: str,
|
||||||
|
) -> None:
|
||||||
|
"""Channel inbound may return user-visible text without a persisted assistant row (LLM timeout)."""
|
||||||
|
from runtime.chat.persist_terminal_fallback import persist_assistant_text_if_turn_missing
|
||||||
|
|
||||||
|
sid = str(session_id or "").strip()
|
||||||
|
body = str(final_text or "").strip()
|
||||||
|
if not sid or not body:
|
||||||
|
return
|
||||||
|
tu = str(turn_uuid or "").strip() or _latest_user_turn_uuid(store, session_id=sid)
|
||||||
|
if not tu:
|
||||||
|
return
|
||||||
|
persist_assistant_text_if_turn_missing(
|
||||||
|
store=store,
|
||||||
|
session_id=sid,
|
||||||
|
turn_uuid=tu,
|
||||||
|
final_text=body,
|
||||||
|
log_prefix="channel_inbound_assistant_persist",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _should_suppress_channel_reply(*, channel: str, text: str) -> bool:
|
def _should_suppress_channel_reply(*, channel: str, text: str) -> bool:
|
||||||
|
|
@ -519,6 +657,8 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
|
||||||
text = inbound.text.strip()
|
text = inbound.text.strip()
|
||||||
preface = ""
|
preface = ""
|
||||||
|
channel_session_id = ""
|
||||||
|
channel_turn_uuid = ""
|
||||||
if text.lower().startswith("bind "):
|
if text.lower().startswith("bind "):
|
||||||
code = text.split(None, 1)[-1].strip()
|
code = text.split(None, 1)[-1].strip()
|
||||||
info = store.consume_bind_code(
|
info = store.consume_bind_code(
|
||||||
|
|
@ -591,6 +731,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
group_name=group_name,
|
group_name=group_name,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
channel_session_id = str(session_id)
|
||||||
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
||||||
scope = "group" if inbound.is_group else "direct"
|
scope = "group" if inbound.is_group else "direct"
|
||||||
pe = PolicyEngine()
|
pe = PolicyEngine()
|
||||||
|
|
@ -629,14 +770,25 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
reply = cmd_reply
|
reply = cmd_reply
|
||||||
elif not reply:
|
elif not reply:
|
||||||
user_text = (inbound.text or "").strip()
|
user_text = (inbound.text or "").strip()
|
||||||
if user_text:
|
gw_attachments = _channel_attachments_for_gateway(
|
||||||
|
list(inbound.attachments or [])
|
||||||
|
)
|
||||||
|
if not user_text and gw_attachments:
|
||||||
|
user_text = "用户发送了附件,请根据附件内容回复。"
|
||||||
|
if user_text or gw_attachments:
|
||||||
try:
|
try:
|
||||||
from runtime.gateway import OclawGateway
|
from runtime.gateway import OclawGateway
|
||||||
from runtime.types import StandardMessage
|
from runtime.types import StandardMessage
|
||||||
|
from runtime.lang import resolve_runtime_lang
|
||||||
|
|
||||||
interaction_mode, selected_specialist = _resolve_channel_dispatch(
|
interaction_mode, selected_specialist, dispatch_lang = _resolve_channel_dispatch(
|
||||||
store, channel=inbound.channel, account=account
|
store, channel=inbound.channel, account=account
|
||||||
)
|
)
|
||||||
|
lang = (
|
||||||
|
dispatch_lang
|
||||||
|
if dispatch_lang in {"zh", "en"}
|
||||||
|
else resolve_runtime_lang(store=store, user_text=user_text)
|
||||||
|
)
|
||||||
gw = OclawGateway(store=store)
|
gw = OclawGateway(store=store)
|
||||||
msg = StandardMessage(
|
msg = StandardMessage(
|
||||||
session_id=str(session_id),
|
session_id=str(session_id),
|
||||||
|
|
@ -645,7 +797,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
role=str(role or "member"),
|
role=str(role or "member"),
|
||||||
channel=str(inbound.channel or "inbound"),
|
channel=str(inbound.channel or "inbound"),
|
||||||
text=str(user_text or ""),
|
text=str(user_text or ""),
|
||||||
attachments=[],
|
attachments=gw_attachments,
|
||||||
metadata={
|
metadata={
|
||||||
"tenant_id": tenant_id,
|
"tenant_id": tenant_id,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
|
|
@ -661,22 +813,23 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
specialist="generalist",
|
specialist="generalist",
|
||||||
session_id=str(session_id),
|
session_id=str(session_id),
|
||||||
|
lang=lang,
|
||||||
)
|
)
|
||||||
specialist_factory = lambda sid: _build_admin_gateway_executor(
|
specialist_factory = lambda sid: _build_admin_gateway_executor(
|
||||||
store,
|
store,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
specialist=sid,
|
specialist=sid,
|
||||||
session_id=str(session_id),
|
session_id=str(session_id),
|
||||||
|
lang=lang,
|
||||||
)
|
)
|
||||||
reply = str(
|
turn_result = gw.handle_turn(
|
||||||
gw.handle_turn(
|
|
||||||
msg=msg,
|
msg=msg,
|
||||||
lang="zh",
|
lang=lang,
|
||||||
executor=manager,
|
executor=manager,
|
||||||
specialist_executor_factory=specialist_factory,
|
specialist_executor_factory=specialist_factory,
|
||||||
).reply_text
|
)
|
||||||
or ""
|
channel_turn_uuid = str(turn_result.turn_uuid or "").strip()
|
||||||
).strip()
|
reply = str(turn_result.reply_text or "").strip()
|
||||||
reply_attachments = _collect_reply_attachments_from_history(
|
reply_attachments = _collect_reply_attachments_from_history(
|
||||||
store=store,
|
store=store,
|
||||||
session_id=str(session_id),
|
session_id=str(session_id),
|
||||||
|
|
@ -694,9 +847,20 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
else:
|
else:
|
||||||
reply = f"{preface}\n\n{_menu_text()}"
|
reply = f"{preface}\n\n{_menu_text()}"
|
||||||
|
|
||||||
if _should_suppress_channel_reply(channel=inbound.channel, text=reply):
|
ch_lower = str(inbound.channel or "").strip().lower()
|
||||||
|
if ch_lower in {"wechat", "weixin"}:
|
||||||
|
reply = _user_facing_wechat_reply(reply=reply)
|
||||||
|
elif _should_suppress_channel_reply(channel=inbound.channel, text=reply):
|
||||||
return {"ok": True, "replies": []}
|
return {"ok": True, "replies": []}
|
||||||
|
|
||||||
|
if channel_session_id and reply:
|
||||||
|
_persist_channel_assistant_if_turn_missing(
|
||||||
|
store=store,
|
||||||
|
session_id=channel_session_id,
|
||||||
|
turn_uuid=channel_turn_uuid,
|
||||||
|
final_text=reply,
|
||||||
|
)
|
||||||
|
|
||||||
if adapter is not None:
|
if adapter is not None:
|
||||||
replies = [adapter.format_outbound(OutboundMessage(external_chat_id=inbound.external_chat_id, text=reply))]
|
replies = [adapter.format_outbound(OutboundMessage(external_chat_id=inbound.external_chat_id, text=reply))]
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ $sidecarRoot = Join-Path $oclawRoot "data\\channel_sidecar\\$ChannelId"
|
||||||
$stateDir = Join-Path $sidecarRoot "state"
|
$stateDir = Join-Path $sidecarRoot "state"
|
||||||
function Sync-WeixinBridgeRunners {
|
function Sync-WeixinBridgeRunners {
|
||||||
$bridgeSrc = Join-Path $oclawRoot "runtime\\operations\\weixin_bridge"
|
$bridgeSrc = Join-Path $oclawRoot "runtime\\operations\\weixin_bridge"
|
||||||
foreach ($name in @("official_runner.ts", "login.ts")) {
|
foreach ($name in @("official_runner.ts", "login.ts", "poll_diag.ts")) {
|
||||||
$srcPath = Join-Path $bridgeSrc $name
|
$srcPath = Join-Path $bridgeSrc $name
|
||||||
if (-not (Test-Path $srcPath)) {
|
if (-not (Test-Path $srcPath)) {
|
||||||
throw "missing bridge source file: $srcPath"
|
throw "missing bridge source file: $srcPath"
|
||||||
|
|
|
||||||
28
runtime/operations/scripts/weixin_poll_diag.ps1
Normal file
28
runtime/operations/scripts/weixin_poll_diag.ps1
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
param(
|
||||||
|
[switch]$ResetCursor = $false
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path
|
||||||
|
$sidecarRoot = Join-Path $repoRoot "data\channel_sidecar\oclaw-weixin"
|
||||||
|
$stateDir = Join-Path $sidecarRoot "state"
|
||||||
|
$bridgeSrc = Join-Path $repoRoot "runtime\operations\weixin_bridge\poll_diag.ts"
|
||||||
|
|
||||||
|
if (-not (Test-Path $bridgeSrc)) {
|
||||||
|
throw "missing $bridgeSrc"
|
||||||
|
}
|
||||||
|
Copy-Item -Path $bridgeSrc -Destination (Join-Path $sidecarRoot "poll_diag.ts") -Force
|
||||||
|
|
||||||
|
$args = @("exec", "--", "tsx", "poll_diag.ts")
|
||||||
|
if ($ResetCursor) {
|
||||||
|
$args += "--reset-cursor"
|
||||||
|
}
|
||||||
|
|
||||||
|
Push-Location $sidecarRoot
|
||||||
|
try {
|
||||||
|
$env:OCLAW_STATE_DIR = $stateDir
|
||||||
|
$env:OPENCLAW_STATE_DIR = $stateDir
|
||||||
|
npm.cmd @args
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
@ -84,7 +84,7 @@ $cleaned = Stop-SidecarProcesses
|
||||||
Remove-Item -Force $pidFile -ErrorAction SilentlyContinue
|
Remove-Item -Force $pidFile -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
if (Test-Path $bridgeSrc) {
|
if (Test-Path $bridgeSrc) {
|
||||||
foreach ($name in @("official_runner.ts", "login.ts")) {
|
foreach ($name in @("official_runner.ts", "login.ts", "poll_diag.ts")) {
|
||||||
$srcPath = Join-Path $bridgeSrc $name
|
$srcPath = Join-Path $bridgeSrc $name
|
||||||
if (Test-Path $srcPath) {
|
if (Test-Path $srcPath) {
|
||||||
Copy-Item -Path $srcPath -Destination (Join-Path $sidecarRoot $name) -Force
|
Copy-Item -Path $srcPath -Destination (Join-Path $sidecarRoot $name) -Force
|
||||||
|
|
@ -99,7 +99,7 @@ if (Test-Path (Join-Path $sidecarRoot "official_runner.ts")) {
|
||||||
$cmd = "cmd.exe"
|
$cmd = "cmd.exe"
|
||||||
$args = @(
|
$args = @(
|
||||||
"/c",
|
"/c",
|
||||||
"cd /d $sidecarRoot && set OCLAW_STATE_DIR=$stateDir&& set AIA_GATEWAY_BASE_URL=$GatewayBaseUrl&& set NODE_PATH=$sidecarRoot\node_modules&& npm.cmd exec -- tsx official_runner.ts"
|
"cd /d $sidecarRoot && set OCLAW_STATE_DIR=$stateDir&& set OPENCLAW_STATE_DIR=$stateDir&& set AIA_GATEWAY_BASE_URL=$GatewayBaseUrl&& set OCLAW_WEIXIN_LOG_FILE=$logPath&& set NODE_PATH=$sidecarRoot\node_modules&& npm.cmd exec -- tsx official_runner.ts"
|
||||||
)
|
)
|
||||||
$p = Start-Process -FilePath $cmd -ArgumentList $args -WorkingDirectory $sidecarRoot -PassThru -WindowStyle Hidden -RedirectStandardOutput $logPath -RedirectStandardError $errPath
|
$p = Start-Process -FilePath $cmd -ArgumentList $args -WorkingDirectory $sidecarRoot -PassThru -WindowStyle Hidden -RedirectStandardOutput $logPath -RedirectStandardError $errPath
|
||||||
Set-Content -Path $pidFile -Value $p.Id
|
Set-Content -Path $pidFile -Value $p.Id
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ type TokenMap = Record<string, string>;
|
||||||
const LOCAL_BASE_URL = (process.env.AIA_GATEWAY_BASE_URL || "http://127.0.0.1:8787").trim();
|
const LOCAL_BASE_URL = (process.env.AIA_GATEWAY_BASE_URL || "http://127.0.0.1:8787").trim();
|
||||||
const STATE_DIR = (process.env.OCLAW_STATE_DIR || path.resolve(process.cwd(), "state")).trim();
|
const STATE_DIR = (process.env.OCLAW_STATE_DIR || path.resolve(process.cwd(), "state")).trim();
|
||||||
const STATE_FILE = path.join(STATE_DIR, "official_bridge_state.json");
|
const STATE_FILE = path.join(STATE_DIR, "official_bridge_state.json");
|
||||||
|
const LOG_FILE = String(process.env.OCLAW_WEIXIN_LOG_FILE || "").trim();
|
||||||
const POLL_TIMEOUT_MS = 35_000;
|
const POLL_TIMEOUT_MS = 35_000;
|
||||||
const DEFAULT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
const DEFAULT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
||||||
|
|
||||||
|
|
@ -65,7 +66,21 @@ class HttpStatusError extends Error {
|
||||||
}
|
}
|
||||||
|
|
||||||
function log(msg: string): void {
|
function log(msg: string): void {
|
||||||
process.stdout.write(`${new Date().toISOString()} [official-weixin] ${msg}\n`);
|
const line = `${new Date().toISOString()} [official-weixin] ${msg}\n`;
|
||||||
|
// When started via Start-Process -RedirectStandardOutput, Node may block-buffer stdout;
|
||||||
|
// append directly so operators see poll/inbound lines in weixin_sidecar.log immediately.
|
||||||
|
if (LOG_FILE) {
|
||||||
|
try {
|
||||||
|
fs.appendFileSync(LOG_FILE, line, "utf8");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
process.stdout.write(line);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureDir(dir: string): void {
|
function ensureDir(dir: string): void {
|
||||||
|
|
@ -88,6 +103,18 @@ function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const timeout = new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error(`${label} timeout after ${ms}ms`)), ms);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await Promise.race([promise, timeout]);
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolvePluginRoot(): string {
|
function resolvePluginRoot(): string {
|
||||||
const configured = String(process.env.OCLAW_WEIXIN_PLUGIN_ROOT || "").trim();
|
const configured = String(process.env.OCLAW_WEIXIN_PLUGIN_ROOT || "").trim();
|
||||||
return configured || path.join(process.cwd(), "node_modules", "@tencent-weixin", "openclaw-weixin");
|
return configured || path.join(process.cwd(), "node_modules", "@tencent-weixin", "openclaw-weixin");
|
||||||
|
|
@ -167,10 +194,12 @@ function resolveHeaders(token: string): Record<string, string> {
|
||||||
|
|
||||||
async function postNativeReply(token: string, body: Json): Promise<Json> {
|
async function postNativeReply(token: string, body: Json): Promise<Json> {
|
||||||
const url = `${LOCAL_BASE_URL.replace(/\/+$/, "")}/weixin/native/reply`;
|
const url = `${LOCAL_BASE_URL.replace(/\/+$/, "")}/weixin/native/reply`;
|
||||||
|
const timeoutMs = Number(process.env.OCLAW_WEIXIN_NATIVE_REPLY_TIMEOUT_MS || "100000") || 100000;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: resolveHeaders(token),
|
headers: resolveHeaders(token),
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
});
|
});
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -330,9 +359,19 @@ async function handleInboundMessage(
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const fromUser = String(params.full.from_user_id || "").trim();
|
const fromUser = String(params.full.from_user_id || "").trim();
|
||||||
const toUser = String(params.full.to_user_id || "").trim();
|
const toUser = String(params.full.to_user_id || "").trim();
|
||||||
if (!fromUser) return params.localCursor;
|
if (!fromUser) {
|
||||||
if (toUser && toUser === fromUser) return params.localCursor;
|
log(`skip inbound: missing from_user_id`);
|
||||||
if (Number(params.full.message_type || 1) !== 1) return params.localCursor;
|
return params.localCursor;
|
||||||
|
}
|
||||||
|
if (toUser && toUser === fromUser) {
|
||||||
|
log(`skip inbound: self-message from=${fromUser}`);
|
||||||
|
return params.localCursor;
|
||||||
|
}
|
||||||
|
if (Number(params.full.message_type || 1) !== 1) {
|
||||||
|
log(`skip inbound: message_type=${String(params.full.message_type ?? "")} from=${fromUser}`);
|
||||||
|
return params.localCursor;
|
||||||
|
}
|
||||||
|
log(`inbound from=${fromUser} to=${toUser || "-"}`);
|
||||||
|
|
||||||
const contextToken = String(params.full.context_token || "").trim();
|
const contextToken = String(params.full.context_token || "").trim();
|
||||||
if (contextToken) {
|
if (contextToken) {
|
||||||
|
|
@ -341,17 +380,33 @@ async function handleInboundMessage(
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaItem = pickDownloadableMedia(params.full);
|
const mediaItem = pickDownloadableMedia(params.full);
|
||||||
const mediaOpts = mediaItem
|
let mediaOpts: Json = {};
|
||||||
? await modules.downloadMediaFromItem(mediaItem, {
|
if (mediaItem) {
|
||||||
|
log("media download start");
|
||||||
|
try {
|
||||||
|
mediaOpts = await withTimeout(
|
||||||
|
modules.downloadMediaFromItem(mediaItem, {
|
||||||
cdnBaseUrl: DEFAULT_CDN_BASE_URL,
|
cdnBaseUrl: DEFAULT_CDN_BASE_URL,
|
||||||
saveMedia: saveMediaBuffer,
|
saveMedia: saveMediaBuffer,
|
||||||
log: (msg: string) => log(`media ${msg}`),
|
log: (msg: string) => log(`media ${msg}`),
|
||||||
errLog: (msg: string) => log(`media-error ${msg}`),
|
errLog: (msg: string) => log(`media-error ${msg}`),
|
||||||
label: "inbound",
|
label: "inbound",
|
||||||
})
|
}),
|
||||||
: {};
|
Number(process.env.OCLAW_WEIXIN_MEDIA_TIMEOUT_MS || "90000") || 90000,
|
||||||
|
"media download",
|
||||||
|
);
|
||||||
|
log("media download done");
|
||||||
|
} catch (err) {
|
||||||
|
log(`media download failed err=${String(err)}`);
|
||||||
|
mediaOpts = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
const ctx = modules.weixinMessageToMsgContext(params.full, params.accountId, mediaOpts);
|
const ctx = modules.weixinMessageToMsgContext(params.full, params.accountId, mediaOpts);
|
||||||
|
const bodyText = String((ctx as Json).Body || "").trim();
|
||||||
|
const attachCount = buildAttachmentsFromMedia(mediaOpts).length;
|
||||||
|
log(`native call bodyLen=${bodyText.length} attachments=${attachCount}`);
|
||||||
let replies: Json[] = [];
|
let replies: Json[] = [];
|
||||||
|
const tNative0 = Date.now();
|
||||||
try {
|
try {
|
||||||
const native = await postNativeReply(params.token, {
|
const native = await postNativeReply(params.token, {
|
||||||
channel: "wechat",
|
channel: "wechat",
|
||||||
|
|
@ -366,6 +421,11 @@ async function handleInboundMessage(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
replies = Array.isArray(native.replies) ? (native.replies as Json[]) : [];
|
replies = Array.isArray(native.replies) ? (native.replies as Json[]) : [];
|
||||||
|
const firstText = replies.length ? String((replies[0] as Json).text || "").trim() : "";
|
||||||
|
log(`native done ms=${Date.now() - tNative0} replies=${replies.length} firstTextLen=${firstText.length}`);
|
||||||
|
if (!replies.length) {
|
||||||
|
log("native returned 0 replies (empty inbound, gateway suppress, or LLM produced no text)");
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`native reply failed; no fallback enabled err=${String(err)}`);
|
log(`native reply failed; no fallback enabled err=${String(err)}`);
|
||||||
await safeSendNativeFailureNotice(modules, {
|
await safeSendNativeFailureNotice(modules, {
|
||||||
|
|
@ -447,6 +507,13 @@ async function main(): Promise<void> {
|
||||||
timeoutMs: POLL_TIMEOUT_MS + 5000,
|
timeoutMs: POLL_TIMEOUT_MS + 5000,
|
||||||
});
|
});
|
||||||
const msgs = Array.isArray(out.msgs) ? (out.msgs as Json[]) : [];
|
const msgs = Array.isArray(out.msgs) ? (out.msgs as Json[]) : [];
|
||||||
|
const ret = Number(out.ret ?? 0);
|
||||||
|
const errcode = out.errcode;
|
||||||
|
if (ret !== 0 || errcode != null) {
|
||||||
|
log(`poll ret=${ret} errcode=${String(errcode ?? "")} errmsg=${String(out.errmsg ?? "")}`);
|
||||||
|
} else if (msgs.length > 0) {
|
||||||
|
log(`poll batch=${msgs.length}`);
|
||||||
|
}
|
||||||
const nextCloudCursor = String(out.get_updates_buf || cloudCursor || "").trim();
|
const nextCloudCursor = String(out.get_updates_buf || cloudCursor || "").trim();
|
||||||
if (nextCloudCursor) {
|
if (nextCloudCursor) {
|
||||||
cloudCursor = nextCloudCursor;
|
cloudCursor = nextCloudCursor;
|
||||||
|
|
|
||||||
109
runtime/operations/weixin_bridge/poll_diag.ts
Normal file
109
runtime/operations/weixin_bridge/poll_diag.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
/**
|
||||||
|
* One-shot ilink getUpdates probe (diagnose "sidecar started but no messages").
|
||||||
|
* Run from repo: runtime/operations/scripts/weixin_poll_diag.ps1
|
||||||
|
*/
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const STATE_DIR = (process.env.OCLAW_STATE_DIR || path.resolve(process.cwd(), "state")).trim();
|
||||||
|
const STATE_FILE = path.join(STATE_DIR, "official_bridge_state.json");
|
||||||
|
|
||||||
|
function resolvePluginRoot(): string {
|
||||||
|
const configured = String(process.env.OCLAW_WEIXIN_PLUGIN_ROOT || "").trim();
|
||||||
|
return configured || path.join(process.cwd(), "node_modules", "@tencent-weixin", "openclaw-weixin");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAccount(): Promise<{ accountId: string; token: string; cloudBaseUrl: string }> {
|
||||||
|
if (!String(process.env.OPENCLAW_STATE_DIR || "").trim()) {
|
||||||
|
process.env.OPENCLAW_STATE_DIR = STATE_DIR;
|
||||||
|
}
|
||||||
|
const pluginRoot = resolvePluginRoot();
|
||||||
|
const srcRoot = path.join(pluginRoot, "src");
|
||||||
|
const importTs = async (relativePath: string) => import(pathToFileURL(path.join(srcRoot, relativePath)).href);
|
||||||
|
const accountsMod = await importTs(path.join("auth", "accounts.ts"));
|
||||||
|
const listIds = accountsMod.listIndexedWeixinAccountIds as () => string[];
|
||||||
|
const loadAcc = accountsMod.loadWeixinAccount as (id: string) => Record<string, unknown>;
|
||||||
|
const accountId = String((listIds() || [])[0] || "").trim();
|
||||||
|
if (!accountId) throw new Error("no weixin account; run weixin_login.ps1");
|
||||||
|
const data = loadAcc(accountId) || {};
|
||||||
|
const token = String(data.token || "").trim();
|
||||||
|
if (!token) throw new Error(`missing token for ${accountId}; run weixin_login.ps1`);
|
||||||
|
const envCloud = String(process.env.OCLAW_WEIXIN_CLOUD_BASE_URL || "").trim();
|
||||||
|
const cfgCloud = String(data.baseUrl || "").trim();
|
||||||
|
const cloudBaseUrl = (envCloud || cfgCloud || "https://ilinkai.weixin.qq.com").trim();
|
||||||
|
return { accountId, token, cloudBaseUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const reset = process.argv.includes("--reset-cursor");
|
||||||
|
const stateRaw = (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(STATE_FILE, "utf8")) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
let cursor = reset ? "" : String(stateRaw.cloud_cursor || "").trim();
|
||||||
|
const { accountId, token, cloudBaseUrl } = await resolveAccount();
|
||||||
|
const pluginRoot = resolvePluginRoot();
|
||||||
|
const apiMod = await import(pathToFileURL(path.join(pluginRoot, "src", "api", "api.ts")).href);
|
||||||
|
const getUpdates = apiMod.getUpdates as (p: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
cloudBaseUrl,
|
||||||
|
cursorPrefix: cursor.slice(0, 48),
|
||||||
|
resetCursor: reset,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const out = await getUpdates({
|
||||||
|
baseUrl: cloudBaseUrl,
|
||||||
|
token,
|
||||||
|
get_updates_buf: cursor,
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
});
|
||||||
|
const msgs = Array.isArray(out.msgs) ? out.msgs : [];
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ret: out.ret,
|
||||||
|
errcode: out.errcode,
|
||||||
|
errmsg: out.errmsg,
|
||||||
|
msgCount: msgs.length,
|
||||||
|
nextCursorPrefix: String(out.get_updates_buf || "").slice(0, 48),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (msgs.length > 0) {
|
||||||
|
const m = msgs[0] as Record<string, unknown>;
|
||||||
|
console.log(
|
||||||
|
"first_msg",
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
from_user_id: m.from_user_id,
|
||||||
|
to_user_id: m.to_user_id,
|
||||||
|
message_type: m.message_type,
|
||||||
|
has_text: Boolean(m.text || (m as { content?: unknown }).content),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("hint=send a NEW text message to the bot now, then re-run this script within 30s");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch((err) => {
|
||||||
|
console.error("poll_diag_failed", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -37,6 +37,27 @@ def scrub_nul_bytes_from_jsonable(obj: Any) -> Any:
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def escape_percent_in_sql_literals(sql: str) -> str:
|
||||||
|
"""Double ``%`` inside single-quoted literals (psycopg treats ``%`` as placeholder syntax)."""
|
||||||
|
out: list[str] = []
|
||||||
|
i = 0
|
||||||
|
in_single = False
|
||||||
|
while i < len(sql):
|
||||||
|
ch = sql[i]
|
||||||
|
if ch == "'" and (i == 0 or sql[i - 1] != "\\"):
|
||||||
|
in_single = not in_single
|
||||||
|
out.append(ch)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if ch == "%" and in_single:
|
||||||
|
out.append("%%")
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
out.append(ch)
|
||||||
|
i += 1
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
def qmarks_to_percent(sql: str) -> str:
|
def qmarks_to_percent(sql: str) -> str:
|
||||||
"""Replace ``?`` placeholders outside single-quoted strings with ``%s`` (psycopg)."""
|
"""Replace ``?`` placeholders outside single-quoted strings with ``%s`` (psycopg)."""
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
|
|
@ -218,11 +239,13 @@ def rewrite_sqlite_extensions_for_postgres(sql: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def adapt_sql_for_postgres(sql: str) -> str:
|
def adapt_sql_for_postgres(sql: str) -> str:
|
||||||
return qmarks_to_percent(rewrite_sqlite_extensions_for_postgres(sql))
|
rewritten = rewrite_sqlite_extensions_for_postgres(sql)
|
||||||
|
return escape_percent_in_sql_literals(qmarks_to_percent(rewritten))
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"adapt_sql_for_postgres",
|
"adapt_sql_for_postgres",
|
||||||
|
"escape_percent_in_sql_literals",
|
||||||
"qmarks_to_percent",
|
"qmarks_to_percent",
|
||||||
"rewrite_sqlite_extensions_for_postgres",
|
"rewrite_sqlite_extensions_for_postgres",
|
||||||
"scrub_nul_bytes_from_jsonable",
|
"scrub_nul_bytes_from_jsonable",
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,110 @@ class ChatSessionsSaRepository:
|
||||||
else None,
|
else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _administrator_username_predicate(self, username: str) -> Any:
|
||||||
|
uname = str(username or "").strip().lower()
|
||||||
|
return func.lower(app_user.c.username) == uname
|
||||||
|
|
||||||
|
def list_chat_sessions_for_administrator_username(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
limit: int | None,
|
||||||
|
offset: int,
|
||||||
|
) -> list[ChatSession]:
|
||||||
|
"""All sessions owned by any ``app_user`` row with this login name (cross-tenant)."""
|
||||||
|
pred = self._administrator_username_predicate(username)
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
chat_session.c.id,
|
||||||
|
chat_session.c.title,
|
||||||
|
chat_session.c.created_at,
|
||||||
|
chat_session.c.last_message_at,
|
||||||
|
)
|
||||||
|
.select_from(
|
||||||
|
chat_session.join(
|
||||||
|
ui_session_owner,
|
||||||
|
ui_session_owner.c.session_id == chat_session.c.id,
|
||||||
|
).join(
|
||||||
|
app_user,
|
||||||
|
and_(
|
||||||
|
app_user.c.id == ui_session_owner.c.user_id,
|
||||||
|
app_user.c.tenant_id == ui_session_owner.c.tenant_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(pred)
|
||||||
|
.order_by(*_activity_order())
|
||||||
|
)
|
||||||
|
if limit is not None:
|
||||||
|
stmt = stmt.limit(int(limit)).offset(int(offset))
|
||||||
|
with self._engine.connect() as conn:
|
||||||
|
rows = conn.execute(stmt).mappings().all()
|
||||||
|
return [_session_from_row(r) for r in rows]
|
||||||
|
|
||||||
|
def sessions_list_meta_for_administrator_username(self, *, username: str) -> SessionsListMeta:
|
||||||
|
pred = self._administrator_username_predicate(username)
|
||||||
|
with self._engine.connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
select(
|
||||||
|
func.count(distinct(chat_session.c.id)).label("c"),
|
||||||
|
func.max(
|
||||||
|
func.coalesce(chat_session.c.last_message_at, chat_session.c.created_at)
|
||||||
|
).label("latest_activity_at"),
|
||||||
|
)
|
||||||
|
.select_from(
|
||||||
|
chat_session.join(
|
||||||
|
ui_session_owner,
|
||||||
|
ui_session_owner.c.session_id == chat_session.c.id,
|
||||||
|
).join(
|
||||||
|
app_user,
|
||||||
|
and_(
|
||||||
|
app_user.c.id == ui_session_owner.c.user_id,
|
||||||
|
app_user.c.tenant_id == ui_session_owner.c.tenant_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(pred)
|
||||||
|
).mappings().first()
|
||||||
|
return SessionsListMeta(
|
||||||
|
session_count=int(row["c"] or 0) if row else 0,
|
||||||
|
latest_activity_at=str(row["latest_activity_at"])
|
||||||
|
if row and row.get("latest_activity_at") is not None
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch_chat_session_for_administrator_username(
|
||||||
|
self, *, session_id: str, username: str
|
||||||
|
) -> ChatSession | None:
|
||||||
|
sid = str(session_id or "").strip()
|
||||||
|
if not sid:
|
||||||
|
return None
|
||||||
|
pred = self._administrator_username_predicate(username)
|
||||||
|
with self._engine.connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
select(
|
||||||
|
chat_session.c.id,
|
||||||
|
chat_session.c.title,
|
||||||
|
chat_session.c.created_at,
|
||||||
|
chat_session.c.last_message_at,
|
||||||
|
)
|
||||||
|
.select_from(
|
||||||
|
chat_session.join(
|
||||||
|
ui_session_owner,
|
||||||
|
ui_session_owner.c.session_id == chat_session.c.id,
|
||||||
|
).join(
|
||||||
|
app_user,
|
||||||
|
and_(
|
||||||
|
app_user.c.id == ui_session_owner.c.user_id,
|
||||||
|
app_user.c.tenant_id == ui_session_owner.c.tenant_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(chat_session.c.id == sid, pred)
|
||||||
|
.limit(1)
|
||||||
|
).mappings().first()
|
||||||
|
return _session_from_row(row) if row else None
|
||||||
|
|
||||||
def sessions_list_meta_for_user(self, *, tenant_id: str, user_id: str) -> SessionsListMeta:
|
def sessions_list_meta_for_user(self, *, tenant_id: str, user_id: str) -> SessionsListMeta:
|
||||||
tid, uid = str(tenant_id), str(user_id)
|
tid, uid = str(tenant_id), str(user_id)
|
||||||
with self._engine.connect() as conn:
|
with self._engine.connect() as conn:
|
||||||
|
|
@ -378,6 +482,36 @@ class ChatSessionsSaRepository:
|
||||||
with self._engine.begin() as conn:
|
with self._engine.begin() as conn:
|
||||||
conn.execute(delete(chat_session).where(chat_session.c.id == sid))
|
conn.execute(delete(chat_session).where(chat_session.c.id == sid))
|
||||||
|
|
||||||
|
def try_delete_chat_session_for_administrator_username(
|
||||||
|
self, *, session_id: str, username: str
|
||||||
|
) -> bool:
|
||||||
|
sid = str(session_id or "").strip()
|
||||||
|
if not sid:
|
||||||
|
return False
|
||||||
|
pred = self._administrator_username_predicate(username)
|
||||||
|
with self._engine.begin() as conn:
|
||||||
|
chk = conn.execute(
|
||||||
|
select(1)
|
||||||
|
.select_from(
|
||||||
|
chat_session.join(
|
||||||
|
ui_session_owner,
|
||||||
|
ui_session_owner.c.session_id == chat_session.c.id,
|
||||||
|
).join(
|
||||||
|
app_user,
|
||||||
|
and_(
|
||||||
|
app_user.c.id == ui_session_owner.c.user_id,
|
||||||
|
app_user.c.tenant_id == ui_session_owner.c.tenant_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(chat_session.c.id == sid, pred)
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
if not chk:
|
||||||
|
return False
|
||||||
|
conn.execute(delete(chat_session).where(chat_session.c.id == sid))
|
||||||
|
return True
|
||||||
|
|
||||||
def try_delete_chat_session_for_tenant(self, *, session_id: str, tenant_id: str) -> bool:
|
def try_delete_chat_session_for_tenant(self, *, session_id: str, tenant_id: str) -> bool:
|
||||||
sid, tid = str(session_id or "").strip(), str(tenant_id)
|
sid, tid = str(session_id or "").strip(), str(tenant_id)
|
||||||
if not sid:
|
if not sid:
|
||||||
|
|
|
||||||
|
|
@ -1384,6 +1384,32 @@ class SqliteStore:
|
||||||
tenant_id=tenant_id, user_id=user_id
|
tenant_id=tenant_id, user_id=user_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def list_sessions_for_administrator_username(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
limit: int | None = None,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[ChatSession]:
|
||||||
|
return self._chat_sessions_repo().list_chat_sessions_for_administrator_username(
|
||||||
|
username=username,
|
||||||
|
limit=limit,
|
||||||
|
offset=int(offset),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_sessions_list_meta_for_administrator_username(self, *, username: str) -> SessionsListMeta:
|
||||||
|
return self._chat_sessions_repo().sessions_list_meta_for_administrator_username(
|
||||||
|
username=username
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_session_for_administrator_username(
|
||||||
|
self, *, session_id: str, username: str
|
||||||
|
) -> Optional[ChatSession]:
|
||||||
|
return self._chat_sessions_repo().fetch_chat_session_for_administrator_username(
|
||||||
|
session_id=session_id,
|
||||||
|
username=username,
|
||||||
|
)
|
||||||
|
|
||||||
def list_sessions_for_tenant(
|
def list_sessions_for_tenant(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -1813,11 +1839,19 @@ class SqliteStore:
|
||||||
return int(total_row_c or 0), users, totals
|
return int(total_row_c or 0), users, totals
|
||||||
|
|
||||||
def delete_session_in_tenant(self, *, session_id: str, tenant_id: str) -> bool:
|
def delete_session_in_tenant(self, *, session_id: str, tenant_id: str) -> bool:
|
||||||
"""Delete session if it belongs to tenant (used by administrator account)."""
|
"""Delete session if it belongs to tenant."""
|
||||||
return self._chat_sessions_repo().try_delete_chat_session_for_tenant(
|
return self._chat_sessions_repo().try_delete_chat_session_for_tenant(
|
||||||
session_id=session_id, tenant_id=tenant_id
|
session_id=session_id, tenant_id=tenant_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def delete_session_for_administrator_username(
|
||||||
|
self, *, session_id: str, username: str
|
||||||
|
) -> bool:
|
||||||
|
"""Delete session owned by any ``app_user`` row with this login name (cross-tenant)."""
|
||||||
|
return self._chat_sessions_repo().try_delete_chat_session_for_administrator_username(
|
||||||
|
session_id=session_id, username=username
|
||||||
|
)
|
||||||
|
|
||||||
def delete_session(self, session_id: str) -> None:
|
def delete_session(self, session_id: str) -> None:
|
||||||
self._chat_sessions_repo().delete_chat_session_by_id(session_id=session_id)
|
self._chat_sessions_repo().delete_chat_session_by_id(session_id=session_id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from fastapi.testclient import TestClient
|
||||||
from interfaces.http.fastapi_app import create_app
|
from interfaces.http.fastapi_app import create_app
|
||||||
from svc.persistence.db.engine import clear_assistant_engine_cache
|
from svc.persistence.db.engine import clear_assistant_engine_cache
|
||||||
from svc.persistence.sqlite_store import SqliteStore
|
from svc.persistence.sqlite_store import SqliteStore
|
||||||
from svc.persistence.assistant_store import get_assistant_store
|
from svc.persistence.assistant_store import get_assistant_store, reset_assistant_store_singleton
|
||||||
|
|
||||||
|
|
||||||
class AdminAuthRBACTests(unittest.TestCase):
|
class AdminAuthRBACTests(unittest.TestCase):
|
||||||
|
|
@ -21,6 +21,10 @@ class AdminAuthRBACTests(unittest.TestCase):
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(db)
|
os.environ["OPS_ASSISTANT_DB_PATH"] = str(db)
|
||||||
|
os.environ["AIA_ASSISTANT_DB_PATH"] = str(db)
|
||||||
|
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
|
||||||
|
os.environ.pop("AIA_ASSISTANT_DATABASE_URL", None)
|
||||||
|
reset_assistant_store_singleton()
|
||||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||||
store = SqliteStore(str(db))
|
store = SqliteStore(str(db))
|
||||||
t = store.create_tenant("Team")
|
t = store.create_tenant("Team")
|
||||||
|
|
@ -38,6 +42,7 @@ class AdminAuthRBACTests(unittest.TestCase):
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
clear_assistant_engine_cache()
|
clear_assistant_engine_cache()
|
||||||
|
reset_assistant_store_singleton()
|
||||||
self._tmp.cleanup()
|
self._tmp.cleanup()
|
||||||
|
|
||||||
def _login(self, username: str = "administrator", password: str = "test-admin-pass") -> str:
|
def _login(self, username: str = "administrator", password: str = "test-admin-pass") -> str:
|
||||||
|
|
@ -123,6 +128,31 @@ class AdminAuthRBACTests(unittest.TestCase):
|
||||||
self.assertTrue(data.get("ok"), data)
|
self.assertTrue(data.get("ok"), data)
|
||||||
self.assertEqual(str((data.get("session") or {}).get("username") or ""), "carol")
|
self.assertEqual(str((data.get("session") or {}).get("username") or ""), "carol")
|
||||||
|
|
||||||
|
def test_administrator_login_without_tenant_id_prefers_team(self) -> None:
|
||||||
|
store = get_assistant_store()
|
||||||
|
newer = store.create_tenant("pg-smoke-newer")
|
||||||
|
store.create_user_account(
|
||||||
|
tenant_id=str(newer["id"]),
|
||||||
|
username="administrator",
|
||||||
|
display_name="Admin",
|
||||||
|
role="admin",
|
||||||
|
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||||
|
resp = self.client.post(
|
||||||
|
"/admin/api/auth/login",
|
||||||
|
json={
|
||||||
|
"tenant_id": "",
|
||||||
|
"username": "administrator",
|
||||||
|
"password": "test-admin-pass",
|
||||||
|
"purpose": "chat",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
self.assertTrue(data.get("ok"), data)
|
||||||
|
self.assertEqual(str((data.get("session") or {}).get("tenant_id") or ""), self.tenant_id)
|
||||||
|
|
||||||
def test_console_login_allows_member_username_with_admin_read(self) -> None:
|
def test_console_login_allows_member_username_with_admin_read(self) -> None:
|
||||||
store = get_assistant_store()
|
store = get_assistant_store()
|
||||||
store.create_user_account(
|
store.create_user_account(
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,10 @@ class _DummyStore:
|
||||||
|
|
||||||
def test_channel_dispatch_defaults_to_expert_and_generalist() -> None:
|
def test_channel_dispatch_defaults_to_expert_and_generalist() -> None:
|
||||||
store = _DummyStore()
|
store = _DummyStore()
|
||||||
interaction_mode, specialist = _resolve_channel_dispatch(store, channel="weixin", account=None)
|
interaction_mode, specialist, lang = _resolve_channel_dispatch(store, channel="weixin", account=None)
|
||||||
assert interaction_mode == "expert"
|
assert interaction_mode == "expert"
|
||||||
assert specialist == "generalist"
|
assert specialist == "generalist"
|
||||||
|
assert lang == "auto"
|
||||||
|
|
||||||
|
|
||||||
def test_channel_dispatch_uses_global_settings() -> None:
|
def test_channel_dispatch_uses_global_settings() -> None:
|
||||||
|
|
@ -25,11 +26,13 @@ def test_channel_dispatch_uses_global_settings() -> None:
|
||||||
{
|
{
|
||||||
"channel.dispatch.interaction_mode.whatsapp": "comprehensive",
|
"channel.dispatch.interaction_mode.whatsapp": "comprehensive",
|
||||||
"channel.dispatch.specialist.whatsapp": "ops",
|
"channel.dispatch.specialist.whatsapp": "ops",
|
||||||
|
"channel.dispatch.lang.whatsapp": "en",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
interaction_mode, specialist = _resolve_channel_dispatch(store, channel="whatsapp", account=None)
|
interaction_mode, specialist, lang = _resolve_channel_dispatch(store, channel="whatsapp", account=None)
|
||||||
assert interaction_mode == "comprehensive"
|
assert interaction_mode == "comprehensive"
|
||||||
assert specialist == "ops"
|
assert specialist == "ops"
|
||||||
|
assert lang == "en"
|
||||||
|
|
||||||
|
|
||||||
def test_channel_dispatch_account_config_overrides_global() -> None:
|
def test_channel_dispatch_account_config_overrides_global() -> None:
|
||||||
|
|
@ -37,15 +40,18 @@ def test_channel_dispatch_account_config_overrides_global() -> None:
|
||||||
{
|
{
|
||||||
"channel.dispatch.interaction_mode.weixin": "comprehensive",
|
"channel.dispatch.interaction_mode.weixin": "comprehensive",
|
||||||
"channel.dispatch.specialist.weixin": "ops",
|
"channel.dispatch.specialist.weixin": "ops",
|
||||||
|
"channel.dispatch.lang.weixin": "zh",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
account = {
|
account = {
|
||||||
"config": {
|
"config": {
|
||||||
"interaction_mode": "expert",
|
"interaction_mode": "expert",
|
||||||
"specialist": "generalist",
|
"specialist": "generalist",
|
||||||
|
"lang": "en",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
interaction_mode, specialist = _resolve_channel_dispatch(store, channel="weixin", account=account)
|
interaction_mode, specialist, lang = _resolve_channel_dispatch(store, channel="weixin", account=account)
|
||||||
assert interaction_mode == "expert"
|
assert interaction_mode == "expert"
|
||||||
assert specialist == "generalist"
|
assert specialist == "generalist"
|
||||||
|
assert lang == "en"
|
||||||
|
|
||||||
|
|
|
||||||
78
tests/test_chat_administrator_cross_tenant_sessions.py
Normal file
78
tests/test_chat_administrator_cross_tenant_sessions.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from svc.persistence.db.engine import clear_assistant_engine_cache
|
||||||
|
from svc.persistence.sqlite_store import SqliteStore
|
||||||
|
from svc.persistence.assistant_store import reset_assistant_store_singleton
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store_two_tenants(monkeypatch: pytest.MonkeyPatch, tmp_path) -> SqliteStore:
|
||||||
|
monkeypatch.delenv("AIA_ASSISTANT_DATABASE_URL", raising=False)
|
||||||
|
monkeypatch.setenv("AIA_ASSISTANT_DB_BACKEND", "sqlite")
|
||||||
|
dbfile = tmp_path / "admin_xtenant.sqlite"
|
||||||
|
monkeypatch.setenv("AIA_ASSISTANT_DB_PATH", str(dbfile))
|
||||||
|
clear_assistant_engine_cache()
|
||||||
|
reset_assistant_store_singleton()
|
||||||
|
s = SqliteStore(str(dbfile))
|
||||||
|
pwd_hash = hashlib.sha256(b"pass").hexdigest()
|
||||||
|
t1 = s.create_tenant("Team")
|
||||||
|
t2 = s.create_tenant("Other")
|
||||||
|
u1 = s.create_user_account(
|
||||||
|
tenant_id=str(t1["id"]),
|
||||||
|
username="administrator",
|
||||||
|
display_name="Admin",
|
||||||
|
role="admin",
|
||||||
|
password_hash=pwd_hash,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
s.create_user_account(
|
||||||
|
tenant_id=str(t2["id"]),
|
||||||
|
username="administrator",
|
||||||
|
display_name="Admin",
|
||||||
|
role="admin",
|
||||||
|
password_hash=pwd_hash,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
s.create_session_for_user(title="team-sess", tenant_id=str(t1["id"]), user_id=str(u1["id"]))
|
||||||
|
s.create_session_for_user(
|
||||||
|
title="other-sess",
|
||||||
|
tenant_id=str(t2["id"]),
|
||||||
|
user_id=str(s.get_user_by_username(tenant_id=str(t2["id"]), username="administrator")["id"]),
|
||||||
|
)
|
||||||
|
yield s
|
||||||
|
clear_assistant_engine_cache()
|
||||||
|
reset_assistant_store_singleton()
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_sessions_for_administrator_username_cross_tenant(
|
||||||
|
store_two_tenants: SqliteStore,
|
||||||
|
) -> None:
|
||||||
|
s = store_two_tenants
|
||||||
|
rows = s.list_sessions_for_administrator_username(username="administrator", limit=50)
|
||||||
|
titles = {x.title for x in rows}
|
||||||
|
assert "team-sess" in titles
|
||||||
|
assert "other-sess" in titles
|
||||||
|
meta = s.get_sessions_list_meta_for_administrator_username(username="administrator")
|
||||||
|
assert int(meta.session_count or 0) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_session_for_administrator_username_cross_tenant(
|
||||||
|
store_two_tenants: SqliteStore,
|
||||||
|
) -> None:
|
||||||
|
s = store_two_tenants
|
||||||
|
rows = s.list_sessions_for_administrator_username(username="administrator", limit=50)
|
||||||
|
other = next(x for x in rows if x.title == "other-sess")
|
||||||
|
team = next(x for x in rows if x.title == "team-sess")
|
||||||
|
team_owner = s.get_ui_session_owner(session_id=str(team.id))
|
||||||
|
login_tid = str((team_owner or {}).get("tenant_id") or "")
|
||||||
|
assert login_tid
|
||||||
|
assert s.delete_session_in_tenant(session_id=str(other.id), tenant_id=login_tid) is False
|
||||||
|
assert s.get_session_for_administrator_username(session_id=str(other.id), username="administrator") is not None
|
||||||
|
assert s.delete_session_for_administrator_username(session_id=str(other.id), username="administrator") is True
|
||||||
|
assert s.get_session_for_administrator_username(session_id=str(other.id), username="administrator") is None
|
||||||
|
meta = s.get_sessions_list_meta_for_administrator_username(username="administrator")
|
||||||
|
assert int(meta.session_count or 0) == 1
|
||||||
|
|
@ -5,10 +5,15 @@ from dataclasses import dataclass
|
||||||
from runtime.application.gateway.inbound_service import (
|
from runtime.application.gateway.inbound_service import (
|
||||||
_collect_reply_attachments_from_history,
|
_collect_reply_attachments_from_history,
|
||||||
_collect_recent_tool_attachments,
|
_collect_recent_tool_attachments,
|
||||||
|
_get_channel_dispatch_setting,
|
||||||
|
_latest_user_turn_uuid,
|
||||||
_maybe_add_media_path_for_wechat_reply,
|
_maybe_add_media_path_for_wechat_reply,
|
||||||
_maybe_expand_reply_attachments_for_channel,
|
_maybe_expand_reply_attachments_for_channel,
|
||||||
_parse_message_attachments,
|
_parse_message_attachments,
|
||||||
|
_persist_channel_assistant_if_turn_missing,
|
||||||
|
_resolve_channel_dispatch,
|
||||||
_should_suppress_channel_reply,
|
_should_suppress_channel_reply,
|
||||||
|
_user_facing_wechat_reply,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -23,6 +28,88 @@ def test_should_not_suppress_non_weixin_channel() -> None:
|
||||||
assert _should_suppress_channel_reply(channel="admin_chat", text=text) is False
|
assert _should_suppress_channel_reply(channel="admin_chat", text=text) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_facing_wechat_reply_maps_empty_and_api_key_errors() -> None:
|
||||||
|
assert _user_facing_wechat_reply(reply="") == "暂时无法回复,请稍后再试。"
|
||||||
|
assert "API" in _user_facing_wechat_reply(
|
||||||
|
reply='Missing API key for provider "openai". Configure the gateway auth for that provider.'
|
||||||
|
)
|
||||||
|
assert _user_facing_wechat_reply(reply="你好") == "你好"
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_channel_assistant_if_turn_missing_inserts_once() -> None:
|
||||||
|
class _Msg:
|
||||||
|
def __init__(self, role: str, turn_uuid: str) -> None:
|
||||||
|
self.role = role
|
||||||
|
self.turn_uuid = turn_uuid
|
||||||
|
|
||||||
|
class _Store:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.rows: list[_Msg] = [_Msg("user", "turn-1")]
|
||||||
|
self.added: list[tuple[str, str, str]] = []
|
||||||
|
|
||||||
|
def get_messages(self, *, session_id: str, limit: int = 80) -> list[_Msg]:
|
||||||
|
_ = (session_id, limit)
|
||||||
|
return list(self.rows)
|
||||||
|
|
||||||
|
def add_message(self, **kwargs: object) -> None:
|
||||||
|
self.added.append(
|
||||||
|
(
|
||||||
|
str(kwargs.get("session_id") or ""),
|
||||||
|
str(kwargs.get("turn_uuid") or ""),
|
||||||
|
str(kwargs.get("content") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.rows.append(_Msg("assistant", str(kwargs.get("turn_uuid") or "")))
|
||||||
|
|
||||||
|
store = _Store()
|
||||||
|
_persist_channel_assistant_if_turn_missing(
|
||||||
|
store=store,
|
||||||
|
session_id="s1",
|
||||||
|
turn_uuid="",
|
||||||
|
final_text="暂时无法回复,请稍后再试。",
|
||||||
|
)
|
||||||
|
assert len(store.added) == 1
|
||||||
|
assert store.added[0] == ("s1", "turn-1", "暂时无法回复,请稍后再试。")
|
||||||
|
_persist_channel_assistant_if_turn_missing(
|
||||||
|
store=store,
|
||||||
|
session_id="s1",
|
||||||
|
turn_uuid="turn-1",
|
||||||
|
final_text="暂时无法回复,请稍后再试。",
|
||||||
|
)
|
||||||
|
assert len(store.added) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_latest_user_turn_uuid() -> None:
|
||||||
|
class _Msg:
|
||||||
|
def __init__(self, role: str, turn_uuid: str) -> None:
|
||||||
|
self.role = role
|
||||||
|
self.turn_uuid = turn_uuid
|
||||||
|
|
||||||
|
class _Store:
|
||||||
|
def get_messages(self, *, session_id: str, limit: int = 80) -> list[_Msg]:
|
||||||
|
_ = (session_id, limit)
|
||||||
|
return [_Msg("assistant", "a1"), _Msg("user", "u2")]
|
||||||
|
|
||||||
|
assert _latest_user_turn_uuid(_Store(), session_id="s") == "u2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_dispatch_wechat_reads_weixin_settings() -> None:
|
||||||
|
class _Store:
|
||||||
|
def get_setting(self, key: str) -> str:
|
||||||
|
data = {
|
||||||
|
"channel.dispatch.interaction_mode.weixin": "comprehensive",
|
||||||
|
"channel.dispatch.specialist.weixin": "ops",
|
||||||
|
"channel.dispatch.lang.weixin": "zh",
|
||||||
|
}
|
||||||
|
return str(data.get(key) or "")
|
||||||
|
|
||||||
|
mode, spec, lang = _resolve_channel_dispatch(_Store(), channel="wechat", account=None)
|
||||||
|
assert mode == "comprehensive"
|
||||||
|
assert spec == "ops"
|
||||||
|
assert lang == "zh"
|
||||||
|
assert _get_channel_dispatch_setting(_Store(), "channel.dispatch.specialist.", "wechat") == "ops"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_message_attachments_accepts_json_string() -> None:
|
def test_parse_message_attachments_accepts_json_string() -> None:
|
||||||
out = _parse_message_attachments('[{"type":"image_ref","attachment_id":"a1"}]')
|
out = _parse_message_attachments('[{"type":"image_ref","attachment_id":"a1"}]')
|
||||||
assert len(out) == 1
|
assert len(out) == 1
|
||||||
|
|
@ -55,16 +142,38 @@ def test_collect_reply_attachments_prefers_matching_assistant_text() -> None:
|
||||||
assert out[0].get("attachment_id") == "new"
|
assert out[0].get("attachment_id") == "new"
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_reply_attachments_does_not_reuse_stale_images_on_text_only_reply() -> None:
|
||||||
|
rows = [
|
||||||
|
_Row(role="assistant", content="here is a chart", attachments='[{"attachment_id":"old"}]'),
|
||||||
|
_Row(role="assistant", content="ok", attachments=None),
|
||||||
|
]
|
||||||
|
out = _collect_reply_attachments_from_history(store=_FakeStore(rows), session_id="s1", reply_text="ok")
|
||||||
|
assert out == []
|
||||||
|
|
||||||
|
|
||||||
def test_collect_recent_tool_attachments_falls_back_to_tool_media() -> None:
|
def test_collect_recent_tool_attachments_falls_back_to_tool_media() -> None:
|
||||||
rows = [
|
rows = [
|
||||||
_Row(role="assistant", content="x", attachments=None),
|
_Row(role="user", content="draw", attachments=None),
|
||||||
_Row(role="tool", content="{}", attachments='[{"type":"image_ref","attachment_id":"a1"}]'),
|
_Row(role="tool", content="{}", attachments='[{"type":"image_ref","attachment_id":"a1"}]'),
|
||||||
|
_Row(role="assistant", content="x", attachments=None),
|
||||||
]
|
]
|
||||||
out = _collect_recent_tool_attachments(store=_FakeStore(rows), session_id="s1")
|
out = _collect_recent_tool_attachments(store=_FakeStore(rows), session_id="s1")
|
||||||
assert len(out) == 1
|
assert len(out) == 1
|
||||||
assert out[0].get("attachment_id") == "a1"
|
assert out[0].get("attachment_id") == "a1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_recent_tool_attachments_ignores_media_from_prior_turn() -> None:
|
||||||
|
rows = [
|
||||||
|
_Row(role="user", content="old question", attachments=None),
|
||||||
|
_Row(role="tool", content="{}", attachments='[{"type":"image_ref","attachment_id":"stale"}]'),
|
||||||
|
_Row(role="assistant", content="old answer", attachments=None),
|
||||||
|
_Row(role="user", content="new question", attachments=None),
|
||||||
|
_Row(role="assistant", content="new answer", attachments=None),
|
||||||
|
]
|
||||||
|
out = _collect_recent_tool_attachments(store=_FakeStore(rows), session_id="s1")
|
||||||
|
assert out == []
|
||||||
|
|
||||||
|
|
||||||
def test_maybe_add_media_path_for_wechat_reply_sets_media_path(monkeypatch) -> None:
|
def test_maybe_add_media_path_for_wechat_reply_sets_media_path(monkeypatch) -> None:
|
||||||
# Avoid touching disk: stub AttachmentAssetStore.get_local_path.
|
# Avoid touching disk: stub AttachmentAssetStore.get_local_path.
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
|
||||||
|
|
@ -44,3 +44,9 @@ def test_scrub_nul_bytes_from_text() -> None:
|
||||||
|
|
||||||
def test_scrub_nul_bytes_from_jsonable_nested() -> None:
|
def test_scrub_nul_bytes_from_jsonable_nested() -> None:
|
||||||
assert pg_compat.scrub_nul_bytes_from_jsonable({"x": "y\x00z"}) == {"x": "yz"}
|
assert pg_compat.scrub_nul_bytes_from_jsonable({"x": "y\x00z"}) == {"x": "yz"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_percent_in_sql_literals_for_like_b64_prefix() -> None:
|
||||||
|
sql = "SELECT 1 FROM llm_profile WHERE api_key LIKE 'b64:%'"
|
||||||
|
adapted = pg_compat.adapt_sql_for_postgres(sql)
|
||||||
|
assert "LIKE 'b64:%%'" in adapted
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
@ -132,6 +134,36 @@ class WeixinIlinkApiTests(unittest.TestCase):
|
||||||
finally:
|
finally:
|
||||||
weixin_ilink_api._process_inbound_payload_usecase = old_usecase # type: ignore[assignment]
|
weixin_ilink_api._process_inbound_payload_usecase = old_usecase # type: ignore[assignment]
|
||||||
|
|
||||||
|
def test_native_reply_timeout_returns_user_message(self) -> None:
|
||||||
|
old_usecase = weixin_ilink_api._process_inbound_payload_usecase
|
||||||
|
old_timeout = weixin_ilink_api._native_reply_timeout_sec
|
||||||
|
|
||||||
|
def _slow_usecase(payload: dict[str, object]) -> dict[str, object]:
|
||||||
|
_ = payload
|
||||||
|
time.sleep(1.2)
|
||||||
|
return {"ok": True, "replies": [{"text": "late"}]}
|
||||||
|
|
||||||
|
try:
|
||||||
|
weixin_ilink_api._process_inbound_payload_usecase = _slow_usecase # type: ignore[assignment]
|
||||||
|
weixin_ilink_api._native_reply_timeout_sec = lambda: 0.2 # type: ignore[assignment]
|
||||||
|
r = self.client.post(
|
||||||
|
"/weixin/native/reply",
|
||||||
|
headers=self.headers,
|
||||||
|
json={
|
||||||
|
"channel": "wechat",
|
||||||
|
"account_id": "bot-1",
|
||||||
|
"ctx": {"From": "wxid_u4", "To": "wxid_u4", "Body": "hi"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 200, r.text)
|
||||||
|
data = r.json() or {}
|
||||||
|
replies = data.get("replies") if isinstance(data.get("replies"), list) else []
|
||||||
|
self.assertEqual(len(replies), 1, data)
|
||||||
|
self.assertIn("超时", str((replies[0] or {}).get("text") or ""))
|
||||||
|
finally:
|
||||||
|
weixin_ilink_api._process_inbound_payload_usecase = old_usecase # type: ignore[assignment]
|
||||||
|
weixin_ilink_api._native_reply_timeout_sec = old_timeout # type: ignore[assignment]
|
||||||
|
|
||||||
def test_whatsapp_native_reply_returns_sync_replies(self) -> None:
|
def test_whatsapp_native_reply_returns_sync_replies(self) -> None:
|
||||||
old_usecase = weixin_ilink_api._process_inbound_payload_usecase
|
old_usecase = weixin_ilink_api._process_inbound_payload_usecase
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue