mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-08 23:33:16 +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 手机端的“关联设备”扫码完成绑定。
|
||||
- 登录态会落盘在 `data/channel_sidecar/whatsapp/state/auth/`,重启后无需重复扫码。
|
||||
- 登录态会落盘在 `data/channel_sidecar/whatsapp/state/auth/`,重启 sidecar 后**一般无需重复扫码**。
|
||||
- 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 可视化调度(新增):
|
||||
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ _DISPATCH_REASON_LABELS_SETTING_KEY = "AIA_DISPATCH_REASON_LABELS_JSON"
|
|||
_SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON"
|
||||
_CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX = "channel.dispatch.interaction_mode."
|
||||
_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX = "channel.dispatch.specialist."
|
||||
_CHANNEL_DISPATCH_LANG_KEY_PREFIX = "channel.dispatch.lang."
|
||||
|
||||
|
||||
def _channel_dispatch_interaction_key(channel: str) -> str:
|
||||
|
|
@ -122,6 +123,16 @@ def _channel_dispatch_interaction_key(channel: str) -> str:
|
|||
def _channel_dispatch_specialist_key(channel: str) -> str:
|
||||
return f"{_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX}{str(channel or '').strip().lower()}"
|
||||
|
||||
def _channel_dispatch_lang_key(channel: str) -> str:
|
||||
return f"{_CHANNEL_DISPATCH_LANG_KEY_PREFIX}{str(channel or '').strip().lower()}"
|
||||
|
||||
|
||||
def _normalize_channel_dispatch_lang(raw: Any) -> str:
|
||||
v = str(raw or "").strip().lower()
|
||||
if v in {"auto", "zh", "en"}:
|
||||
return v
|
||||
return "auto"
|
||||
|
||||
|
||||
def _normalize_channel_dispatch_channel(raw: Any) -> str:
|
||||
ch = str(raw or "").strip().lower()
|
||||
|
|
@ -361,11 +372,7 @@ def _chat_username(ctx: dict[str, Any]) -> str:
|
|||
|
||||
|
||||
def _is_administrator_chat_viewer(ctx: dict[str, Any]) -> bool:
|
||||
"""``administrator`` 账户:在单会话读写/导出等接口上可按租户打开任意会话(供审计与 Session Monitor)。
|
||||
|
||||
``GET /chat/sessions`` **不再**使用租户全量列表,避免与普通用户会话混在同一侧边栏;
|
||||
看全租户会话请用审计页、``GET /admin/api/chat/admin/sessions`` 等专用接口。
|
||||
"""
|
||||
"""``administrator`` 账户:可跨租户列出/打开本人名下所有 ``ui_session_owner`` 会话(PG 迁移后历史可能在 smoke/Team 等多租户)。"""
|
||||
return _chat_username(ctx) == "administrator"
|
||||
|
||||
|
||||
|
|
@ -377,9 +384,16 @@ def _require_administrator_chat_viewer(ctx: dict[str, Any]) -> None:
|
|||
def _resolve_chat_session(store: SqliteStore, ctx: dict[str, Any], session_id: str):
|
||||
tenant_id = str(ctx.get("tenant_id") or "")
|
||||
user_id = str(ctx.get("user_id") or "")
|
||||
sid = str(session_id or "").strip()
|
||||
if _is_administrator_chat_viewer(ctx):
|
||||
return store.get_session_in_tenant(session_id=str(session_id or "").strip(), tenant_id=tenant_id)
|
||||
return store.get_session_for_user(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
||||
sess = store.get_session_for_administrator_username(
|
||||
session_id=sid,
|
||||
username=_chat_username(ctx),
|
||||
)
|
||||
if sess is not None:
|
||||
return sess
|
||||
return store.get_session_in_tenant(session_id=sid, tenant_id=tenant_id)
|
||||
return store.get_session_for_user(session_id=sid, tenant_id=tenant_id, user_id=user_id)
|
||||
|
||||
|
||||
def _resolve_chat_session_allow_claim_orphan(
|
||||
|
|
@ -875,12 +889,17 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
ctx = resolve_auth(store, authorization)
|
||||
tenant_id = str(ctx.get("tenant_id") or "")
|
||||
user_id = str(ctx.get("user_id") or "")
|
||||
# 含 administrator:侧边栏只列「当前登录用户」名下会话,避免租户内他人会话出现在 /chat。
|
||||
# 全租户会话列表见审计、GET /admin/api/chat/admin/sessions 等。
|
||||
meta = store.get_sessions_list_meta_for_user(tenant_id=tenant_id, user_id=user_id)
|
||||
rows = store.list_sessions_for_user(
|
||||
tenant_id=tenant_id, user_id=user_id, limit=limit, offset=offset
|
||||
)
|
||||
if _is_administrator_chat_viewer(ctx):
|
||||
uname = _chat_username(ctx)
|
||||
meta = store.get_sessions_list_meta_for_administrator_username(username=uname)
|
||||
rows = store.list_sessions_for_administrator_username(
|
||||
username=uname, limit=limit, offset=offset
|
||||
)
|
||||
else:
|
||||
meta = store.get_sessions_list_meta_for_user(tenant_id=tenant_id, user_id=user_id)
|
||||
rows = store.list_sessions_for_user(
|
||||
tenant_id=tenant_id, user_id=user_id, limit=limit, offset=offset
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"total": int(meta.session_count or 0),
|
||||
|
|
@ -962,12 +981,23 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
if not sess:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
if _is_administrator_chat_viewer(ctx):
|
||||
store.delete_session_in_tenant(session_id=session_id, tenant_id=tenant_id)
|
||||
deleted = store.delete_session_for_administrator_username(
|
||||
session_id=session_id, username=_chat_username(ctx)
|
||||
)
|
||||
else:
|
||||
store.delete_session_for_user(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
||||
remaining = store.list_sessions_for_user(
|
||||
tenant_id=tenant_id, user_id=user_id, limit=1, offset=0
|
||||
)
|
||||
deleted = store.delete_session_for_user(
|
||||
session_id=session_id, tenant_id=tenant_id, user_id=user_id
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
if _is_administrator_chat_viewer(ctx):
|
||||
remaining = store.list_sessions_for_administrator_username(
|
||||
username=_chat_username(ctx), limit=1, offset=0
|
||||
)
|
||||
else:
|
||||
remaining = store.list_sessions_for_user(
|
||||
tenant_id=tenant_id, user_id=user_id, limit=1, offset=0
|
||||
)
|
||||
next_id = str(remaining[0].id) if remaining else ""
|
||||
if not next_id:
|
||||
lang = _api_lang(store)
|
||||
|
|
@ -1563,12 +1593,16 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
specialist = normalize_requested_specialist(
|
||||
store.get_setting(_channel_dispatch_specialist_key(ch)) or "generalist"
|
||||
)
|
||||
lang = _normalize_channel_dispatch_lang(
|
||||
store.get_setting(_channel_dispatch_lang_key(ch)) or "auto"
|
||||
)
|
||||
specialist = _apply_specialist_flags(store, specialist)
|
||||
return {
|
||||
"ok": True,
|
||||
"channel": ch,
|
||||
"interaction_mode": interaction_mode,
|
||||
"specialist": specialist,
|
||||
"lang": lang,
|
||||
"available_specialists": [sid for sid in _chat_specialist_ids() if bool(_specialist_flags_with_overrides(store).get(sid, True))],
|
||||
}
|
||||
|
||||
|
|
@ -1585,14 +1619,17 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
ch = _normalize_channel_dispatch_channel(channel)
|
||||
interaction_mode = normalize_interaction_mode(body.get("interaction_mode") or "expert")
|
||||
specialist = normalize_requested_specialist(body.get("specialist") or "generalist")
|
||||
lang = _normalize_channel_dispatch_lang(body.get("lang") or "auto")
|
||||
specialist = _apply_specialist_flags(store, specialist)
|
||||
store.set_setting(_channel_dispatch_interaction_key(ch), interaction_mode)
|
||||
store.set_setting(_channel_dispatch_specialist_key(ch), specialist)
|
||||
store.set_setting(_channel_dispatch_lang_key(ch), lang)
|
||||
return {
|
||||
"ok": True,
|
||||
"channel": ch,
|
||||
"interaction_mode": interaction_mode,
|
||||
"specialist": specialist,
|
||||
"lang": lang,
|
||||
}
|
||||
|
||||
@chat.get("/settings/attachment-limits")
|
||||
|
|
|
|||
|
|
@ -333,6 +333,48 @@ def build_admin_router() -> APIRouter:
|
|||
return parts[1].strip()
|
||||
return ""
|
||||
|
||||
def _resolve_login_tenant_id(store: SqliteStore, *, username: str, tenant_id: str) -> str:
|
||||
"""Pick tenant when login body omits tenant_id.
|
||||
|
||||
Chat login sends tenant_id=\"\". Using list_tenants(limit=1) alone picks the newest tenant
|
||||
(often pg-smoke), which has no chat history. For administrator, prefer the Team tenant,
|
||||
then the tenant with the most owned sessions.
|
||||
"""
|
||||
explicit = str(tenant_id or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
uname = str(username or "").strip().lower()
|
||||
tenants = store.list_tenants(limit=500)
|
||||
if not tenants:
|
||||
return ""
|
||||
if uname == "administrator":
|
||||
for row in tenants:
|
||||
tid = str((row or {}).get("id") or "").strip()
|
||||
if not tid:
|
||||
continue
|
||||
if str((row or {}).get("name") or "").strip().lower() != "team":
|
||||
continue
|
||||
if store.get_user_by_username(tenant_id=tid, username="administrator"):
|
||||
return tid
|
||||
best_tid = ""
|
||||
best_n = -1
|
||||
for row in tenants:
|
||||
tid = str((row or {}).get("id") or "").strip()
|
||||
if not tid:
|
||||
continue
|
||||
user = store.get_user_by_username(tenant_id=tid, username="administrator")
|
||||
if not user:
|
||||
continue
|
||||
uid = str(user.get("id") or "")
|
||||
meta = store.get_sessions_list_meta_for_user(tenant_id=tid, user_id=uid)
|
||||
n = int(meta.session_count or 0)
|
||||
if n > best_n:
|
||||
best_n = n
|
||||
best_tid = tid
|
||||
if best_tid:
|
||||
return best_tid
|
||||
return str((tenants[0] or {}).get("id") or "").strip()
|
||||
|
||||
def _resolve_auth(store: SqliteStore, authorization: str | None) -> dict[str, Any]:
|
||||
token = _extract_bearer(authorization)
|
||||
if not token:
|
||||
|
|
@ -3751,11 +3793,6 @@ def build_admin_router() -> APIRouter:
|
|||
payload = payload or {}
|
||||
store = get_assistant_store()
|
||||
_ensure_admin_bootstrap(store)
|
||||
tenant_id = str(payload.get("tenant_id") or "").strip()
|
||||
if not tenant_id:
|
||||
tenants = store.list_tenants(limit=1)
|
||||
if tenants:
|
||||
tenant_id = str((tenants[0] or {}).get("id") or "").strip()
|
||||
purpose = str(payload.get("purpose") or "console").strip().lower()
|
||||
if purpose not in {"console", "chat"}:
|
||||
purpose = "console"
|
||||
|
|
@ -3763,6 +3800,11 @@ def build_admin_router() -> APIRouter:
|
|||
username = str(payload.get("username") or "administrator").strip().lower()
|
||||
else:
|
||||
username = str(payload.get("username") or "").strip().lower()
|
||||
tenant_id = _resolve_login_tenant_id(
|
||||
store,
|
||||
username=username,
|
||||
tenant_id=str(payload.get("tenant_id") or "").strip(),
|
||||
)
|
||||
password = str(payload.get("password") or "").strip()
|
||||
if not tenant_id or not password:
|
||||
return {"ok": False, "error": "tenant_id, username, password are required"}
|
||||
|
|
|
|||
|
|
@ -1528,20 +1528,28 @@ async function renderStack() {
|
|||
const createChannelDispatchCard = (channel, title, initial) => {
|
||||
const curMode = String((initial && initial.interaction_mode) || "expert").trim() || "expert";
|
||||
const curSpecialist = String((initial && initial.specialist) || "generalist").trim() || "generalist";
|
||||
const curLang = String((initial && initial.lang) || "auto").trim().toLowerCase() || "auto";
|
||||
const specialistSel = el("select", { class: "input" }, availableDispatchSpecialists.map((sid) =>
|
||||
el("option", { value: sid, text: sid, selected: sid === curSpecialist ? "selected" : undefined }),
|
||||
));
|
||||
const status = el("div", { class: "muted", text: `mode=${curMode} specialist=${curSpecialist}` });
|
||||
const langSel = el("select", { class: "input" }, [
|
||||
el("option", { value: "auto", text: currentLang === "zh" ? "自动" : "Auto", selected: curLang === "auto" ? "selected" : undefined }),
|
||||
el("option", { value: "zh", text: currentLang === "zh" ? "中文" : "Chinese", selected: curLang === "zh" ? "selected" : undefined }),
|
||||
el("option", { value: "en", text: currentLang === "zh" ? "英文" : "English", selected: curLang === "en" ? "selected" : undefined }),
|
||||
]);
|
||||
const status = el("div", { class: "muted", text: `mode=${curMode} specialist=${curSpecialist} lang=${curLang}` });
|
||||
const saveExpertBtn = el("button", {
|
||||
class: "btn",
|
||||
text: currentLang === "zh" ? "绑定专家" : "Bind specialist",
|
||||
onclick: async () => {
|
||||
const specialist = String(specialistSel.value || "generalist").trim() || "generalist";
|
||||
const lang = String(langSel.value || "auto").trim().toLowerCase() || "auto";
|
||||
const resp = await apiPost(`/admin/api/chat/settings/channel-dispatch/${encodeURIComponent(channel)}`, {
|
||||
interaction_mode: "expert",
|
||||
specialist,
|
||||
lang,
|
||||
});
|
||||
status.textContent = `mode=${String(resp.interaction_mode || "expert")} specialist=${String(resp.specialist || specialist)}`;
|
||||
status.textContent = `mode=${String(resp.interaction_mode || "expert")} specialist=${String(resp.specialist || specialist)} lang=${String(resp.lang || lang)}`;
|
||||
},
|
||||
});
|
||||
const saveComprehensiveBtn = el("button", {
|
||||
|
|
@ -1549,11 +1557,13 @@ async function renderStack() {
|
|||
text: currentLang === "zh" ? "综合" : "Comprehensive",
|
||||
onclick: async () => {
|
||||
const specialist = String(specialistSel.value || "generalist").trim() || "generalist";
|
||||
const lang = String(langSel.value || "auto").trim().toLowerCase() || "auto";
|
||||
const resp = await apiPost(`/admin/api/chat/settings/channel-dispatch/${encodeURIComponent(channel)}`, {
|
||||
interaction_mode: "comprehensive",
|
||||
specialist,
|
||||
lang,
|
||||
});
|
||||
status.textContent = `mode=${String(resp.interaction_mode || "comprehensive")} specialist=${String(resp.specialist || specialist)}`;
|
||||
status.textContent = `mode=${String(resp.interaction_mode || "comprehensive")} specialist=${String(resp.specialist || specialist)} lang=${String(resp.lang || lang)}`;
|
||||
},
|
||||
});
|
||||
return el("div", { class: "card" }, [
|
||||
|
|
@ -1561,6 +1571,8 @@ async function renderStack() {
|
|||
el("div", { class: "row" }, [
|
||||
el("label", { text: currentLang === "zh" ? "专家" : "Specialist" }),
|
||||
specialistSel,
|
||||
el("label", { text: currentLang === "zh" ? "语言" : "Lang" }),
|
||||
langSel,
|
||||
saveExpertBtn,
|
||||
saveComprehensiveBtn,
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -49,6 +49,40 @@ def _now_ms() -> int:
|
|||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _native_reply_timeout_sec() -> float:
|
||||
raw = str(os.getenv("AIA_CHANNEL_NATIVE_REPLY_TIMEOUT_SEC") or "90").strip()
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
value = 90.0
|
||||
return max(10.0, min(value, 600.0))
|
||||
|
||||
|
||||
async def _run_native_reply_usecase(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
timeout = _native_reply_timeout_sec()
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
asyncio.to_thread(_process_inbound_payload_usecase, payload),
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
channel = _normalize_channel(payload.get("channel"))
|
||||
chat_id = str(payload.get("chat_id") or payload.get("user_id") or "").strip()
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "native_reply_timeout",
|
||||
"replies": [
|
||||
{
|
||||
"channel": channel,
|
||||
"chat_id": chat_id,
|
||||
"text": "处理超时,请稍后再试。",
|
||||
"attachments": [],
|
||||
"metadata": {},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _normalize_channel(raw: Any) -> str:
|
||||
ch = str(raw or "wechat").strip().lower()
|
||||
return ch if ch else "wechat"
|
||||
|
|
@ -468,7 +502,7 @@ async def weixin_native_reply(
|
|||
return {"ok": False, "error": "missing user_id", "replies": []}
|
||||
if not str(payload.get("account_id") or "").strip():
|
||||
return {"ok": False, "error": "missing account_id", "replies": []}
|
||||
out = await asyncio.to_thread(_process_inbound_payload_usecase, payload)
|
||||
out = await _run_native_reply_usecase(payload)
|
||||
replies = out.get("replies") if isinstance(out, dict) else []
|
||||
if not isinstance(replies, list):
|
||||
replies = []
|
||||
|
|
@ -490,7 +524,7 @@ async def whatsapp_native_reply(
|
|||
return {"ok": False, "error": "missing user_id", "replies": []}
|
||||
if not str(payload.get("account_id") or "").strip():
|
||||
return {"ok": False, "error": "missing account_id", "replies": []}
|
||||
out = await asyncio.to_thread(_process_inbound_payload_usecase, payload)
|
||||
out = await _run_native_reply_usecase(payload)
|
||||
replies = out.get("replies") if isinstance(out, dict) else []
|
||||
if not isinstance(replies, list):
|
||||
replies = []
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from svc.persistence.assistant_store import get_assistant_store
|
|||
|
||||
_CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX = "channel.dispatch.interaction_mode."
|
||||
_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX = "channel.dispatch.specialist."
|
||||
_CHANNEL_DISPATCH_LANG_KEY_PREFIX = "channel.dispatch.lang."
|
||||
|
||||
|
||||
def _channel_dispatch_interaction_key(channel: str) -> str:
|
||||
|
|
@ -20,28 +21,65 @@ def _channel_dispatch_interaction_key(channel: str) -> str:
|
|||
def _channel_dispatch_specialist_key(channel: str) -> str:
|
||||
return f"{_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX}{str(channel or '').strip().lower()}"
|
||||
|
||||
def _channel_dispatch_lang_key(channel: str) -> str:
|
||||
return f"{_CHANNEL_DISPATCH_LANG_KEY_PREFIX}{str(channel or '').strip().lower()}"
|
||||
|
||||
def _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()
|
||||
interaction_mode = normalize_interaction_mode(store.get_setting(_channel_dispatch_interaction_key(ch)) or "expert")
|
||||
specialist = normalize_requested_specialist(store.get_setting(_channel_dispatch_specialist_key(ch)) or "generalist")
|
||||
if ch == "wechat":
|
||||
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")
|
||||
if isinstance(cfg, dict):
|
||||
cfg_mode = cfg.get("interaction_mode")
|
||||
cfg_specialist = cfg.get("specialist")
|
||||
cfg_lang = cfg.get("lang")
|
||||
if cfg_mode is not None:
|
||||
interaction_mode = normalize_interaction_mode(cfg_mode)
|
||||
if cfg_specialist is not None:
|
||||
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
|
||||
|
||||
return build_gateway_executor(
|
||||
store,
|
||||
lang="zh",
|
||||
lang=str(lang or "zh"),
|
||||
specialist=specialist,
|
||||
viewer_user_id=None,
|
||||
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]]:
|
||||
if raw is None:
|
||||
return []
|
||||
|
|
@ -330,10 +407,22 @@ def _first_attachment_id(atts: list[dict[str, Any]]) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
def _collect_recent_tool_attachments(*, store: Any, session_id: str) -> list[dict[str, Any]]:
|
||||
"""Fallback for channel delivery: if assistant attachments are missing, reuse recent tool attachments.
|
||||
def _rows_since_last_user_message(rows: list[Any]) -> list[Any]:
|
||||
"""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()
|
||||
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)
|
||||
except Exception:
|
||||
rows = []
|
||||
# Prefer newest tool attachments.
|
||||
for row in reversed(list(rows or [])):
|
||||
scoped = _rows_since_last_user_message(list(rows or []))
|
||||
for row in reversed(scoped):
|
||||
role = str(getattr(row, "role", "") or "").strip().lower()
|
||||
if role != "tool":
|
||||
continue
|
||||
atts = _parse_message_attachments(getattr(row, "attachments", None))
|
||||
if not atts:
|
||||
continue
|
||||
# Only accept common media/ref shapes.
|
||||
ok = False
|
||||
for a in atts:
|
||||
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]]:
|
||||
"""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()
|
||||
if not sid:
|
||||
return []
|
||||
|
|
@ -455,19 +548,64 @@ def _collect_reply_attachments_from_history(*, store: Any, session_id: str, repl
|
|||
except Exception:
|
||||
rows = []
|
||||
target = str(reply_text or "").strip()
|
||||
fallback: list[dict[str, Any]] = []
|
||||
matched: list[dict[str, Any]] = []
|
||||
for row in rows or []:
|
||||
if not target:
|
||||
return []
|
||||
for row in reversed(list(rows or [])):
|
||||
role = str(getattr(row, "role", "") or "").strip().lower()
|
||||
if role != "assistant":
|
||||
continue
|
||||
atts = _parse_message_attachments(getattr(row, "attachments", None))
|
||||
if atts:
|
||||
fallback = atts
|
||||
content = str(getattr(row, "content", "") or "").strip()
|
||||
if target and content == target and atts:
|
||||
matched = atts
|
||||
return matched or fallback
|
||||
if content != target:
|
||||
continue
|
||||
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:
|
||||
|
|
@ -519,6 +657,8 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
text = inbound.text.strip()
|
||||
preface = ""
|
||||
channel_session_id = ""
|
||||
channel_turn_uuid = ""
|
||||
if text.lower().startswith("bind "):
|
||||
code = text.split(None, 1)[-1].strip()
|
||||
info = store.consume_bind_code(
|
||||
|
|
@ -591,6 +731,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|||
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)
|
||||
scope = "group" if inbound.is_group else "direct"
|
||||
pe = PolicyEngine()
|
||||
|
|
@ -629,14 +770,25 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|||
reply = cmd_reply
|
||||
elif not reply:
|
||||
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:
|
||||
from runtime.gateway import OclawGateway
|
||||
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
|
||||
)
|
||||
lang = (
|
||||
dispatch_lang
|
||||
if dispatch_lang in {"zh", "en"}
|
||||
else resolve_runtime_lang(store=store, user_text=user_text)
|
||||
)
|
||||
gw = OclawGateway(store=store)
|
||||
msg = StandardMessage(
|
||||
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"),
|
||||
channel=str(inbound.channel or "inbound"),
|
||||
text=str(user_text or ""),
|
||||
attachments=[],
|
||||
attachments=gw_attachments,
|
||||
metadata={
|
||||
"tenant_id": tenant_id,
|
||||
"user_id": user_id,
|
||||
|
|
@ -661,22 +813,23 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|||
tenant_id=tenant_id,
|
||||
specialist="generalist",
|
||||
session_id=str(session_id),
|
||||
lang=lang,
|
||||
)
|
||||
specialist_factory = lambda sid: _build_admin_gateway_executor(
|
||||
store,
|
||||
tenant_id=tenant_id,
|
||||
specialist=sid,
|
||||
session_id=str(session_id),
|
||||
lang=lang,
|
||||
)
|
||||
reply = str(
|
||||
gw.handle_turn(
|
||||
msg=msg,
|
||||
lang="zh",
|
||||
executor=manager,
|
||||
specialist_executor_factory=specialist_factory,
|
||||
).reply_text
|
||||
or ""
|
||||
).strip()
|
||||
turn_result = gw.handle_turn(
|
||||
msg=msg,
|
||||
lang=lang,
|
||||
executor=manager,
|
||||
specialist_executor_factory=specialist_factory,
|
||||
)
|
||||
channel_turn_uuid = str(turn_result.turn_uuid or "").strip()
|
||||
reply = str(turn_result.reply_text or "").strip()
|
||||
reply_attachments = _collect_reply_attachments_from_history(
|
||||
store=store,
|
||||
session_id=str(session_id),
|
||||
|
|
@ -694,9 +847,20 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|||
else:
|
||||
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": []}
|
||||
|
||||
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:
|
||||
replies = [adapter.format_outbound(OutboundMessage(external_chat_id=inbound.external_chat_id, text=reply))]
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ $sidecarRoot = Join-Path $oclawRoot "data\\channel_sidecar\\$ChannelId"
|
|||
$stateDir = Join-Path $sidecarRoot "state"
|
||||
function Sync-WeixinBridgeRunners {
|
||||
$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
|
||||
if (-not (Test-Path $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
|
||||
|
||||
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
|
||||
if (Test-Path $srcPath) {
|
||||
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"
|
||||
$args = @(
|
||||
"/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
|
||||
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 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 LOG_FILE = String(process.env.OCLAW_WEIXIN_LOG_FILE || "").trim();
|
||||
const POLL_TIMEOUT_MS = 35_000;
|
||||
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 {
|
||||
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 {
|
||||
|
|
@ -88,6 +103,18 @@ function sleep(ms: number): Promise<void> {
|
|||
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 {
|
||||
const configured = String(process.env.OCLAW_WEIXIN_PLUGIN_ROOT || "").trim();
|
||||
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> {
|
||||
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, {
|
||||
method: "POST",
|
||||
headers: resolveHeaders(token),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
|
|
@ -330,9 +359,19 @@ async function handleInboundMessage(
|
|||
): Promise<string> {
|
||||
const fromUser = String(params.full.from_user_id || "").trim();
|
||||
const toUser = String(params.full.to_user_id || "").trim();
|
||||
if (!fromUser) return params.localCursor;
|
||||
if (toUser && toUser === fromUser) return params.localCursor;
|
||||
if (Number(params.full.message_type || 1) !== 1) return params.localCursor;
|
||||
if (!fromUser) {
|
||||
log(`skip inbound: missing from_user_id`);
|
||||
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();
|
||||
if (contextToken) {
|
||||
|
|
@ -341,17 +380,33 @@ async function handleInboundMessage(
|
|||
}
|
||||
|
||||
const mediaItem = pickDownloadableMedia(params.full);
|
||||
const mediaOpts = mediaItem
|
||||
? await modules.downloadMediaFromItem(mediaItem, {
|
||||
cdnBaseUrl: DEFAULT_CDN_BASE_URL,
|
||||
saveMedia: saveMediaBuffer,
|
||||
log: (msg: string) => log(`media ${msg}`),
|
||||
errLog: (msg: string) => log(`media-error ${msg}`),
|
||||
label: "inbound",
|
||||
})
|
||||
: {};
|
||||
let mediaOpts: Json = {};
|
||||
if (mediaItem) {
|
||||
log("media download start");
|
||||
try {
|
||||
mediaOpts = await withTimeout(
|
||||
modules.downloadMediaFromItem(mediaItem, {
|
||||
cdnBaseUrl: DEFAULT_CDN_BASE_URL,
|
||||
saveMedia: saveMediaBuffer,
|
||||
log: (msg: string) => log(`media ${msg}`),
|
||||
errLog: (msg: string) => log(`media-error ${msg}`),
|
||||
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 bodyText = String((ctx as Json).Body || "").trim();
|
||||
const attachCount = buildAttachmentsFromMedia(mediaOpts).length;
|
||||
log(`native call bodyLen=${bodyText.length} attachments=${attachCount}`);
|
||||
let replies: Json[] = [];
|
||||
const tNative0 = Date.now();
|
||||
try {
|
||||
const native = await postNativeReply(params.token, {
|
||||
channel: "wechat",
|
||||
|
|
@ -366,6 +421,11 @@ async function handleInboundMessage(
|
|||
},
|
||||
});
|
||||
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) {
|
||||
log(`native reply failed; no fallback enabled err=${String(err)}`);
|
||||
await safeSendNativeFailureNotice(modules, {
|
||||
|
|
@ -447,6 +507,13 @@ async function main(): Promise<void> {
|
|||
timeoutMs: POLL_TIMEOUT_MS + 5000,
|
||||
});
|
||||
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();
|
||||
if (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
|
||||
|
||||
|
||||
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:
|
||||
"""Replace ``?`` placeholders outside single-quoted strings with ``%s`` (psycopg)."""
|
||||
out: list[str] = []
|
||||
|
|
@ -218,11 +239,13 @@ def rewrite_sqlite_extensions_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__ = [
|
||||
"adapt_sql_for_postgres",
|
||||
"escape_percent_in_sql_literals",
|
||||
"qmarks_to_percent",
|
||||
"rewrite_sqlite_extensions_for_postgres",
|
||||
"scrub_nul_bytes_from_jsonable",
|
||||
|
|
|
|||
|
|
@ -193,6 +193,110 @@ class ChatSessionsSaRepository:
|
|||
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:
|
||||
tid, uid = str(tenant_id), str(user_id)
|
||||
with self._engine.connect() as conn:
|
||||
|
|
@ -378,6 +482,36 @@ class ChatSessionsSaRepository:
|
|||
with self._engine.begin() as conn:
|
||||
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:
|
||||
sid, tid = str(session_id or "").strip(), str(tenant_id)
|
||||
if not sid:
|
||||
|
|
|
|||
|
|
@ -1384,6 +1384,32 @@ class SqliteStore:
|
|||
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(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -1813,11 +1839,19 @@ class SqliteStore:
|
|||
return int(total_row_c or 0), users, totals
|
||||
|
||||
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(
|
||||
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:
|
||||
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 svc.persistence.db.engine import clear_assistant_engine_cache
|
||||
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):
|
||||
|
|
@ -21,6 +21,10 @@ class AdminAuthRBACTests(unittest.TestCase):
|
|||
import os
|
||||
|
||||
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"
|
||||
store = SqliteStore(str(db))
|
||||
t = store.create_tenant("Team")
|
||||
|
|
@ -38,6 +42,7 @@ class AdminAuthRBACTests(unittest.TestCase):
|
|||
|
||||
def tearDown(self) -> None:
|
||||
clear_assistant_engine_cache()
|
||||
reset_assistant_store_singleton()
|
||||
self._tmp.cleanup()
|
||||
|
||||
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.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:
|
||||
store = get_assistant_store()
|
||||
store.create_user_account(
|
||||
|
|
|
|||
|
|
@ -15,9 +15,10 @@ class _DummyStore:
|
|||
|
||||
def test_channel_dispatch_defaults_to_expert_and_generalist() -> None:
|
||||
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 specialist == "generalist"
|
||||
assert lang == "auto"
|
||||
|
||||
|
||||
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.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 specialist == "ops"
|
||||
assert lang == "en"
|
||||
|
||||
|
||||
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.specialist.weixin": "ops",
|
||||
"channel.dispatch.lang.weixin": "zh",
|
||||
}
|
||||
)
|
||||
account = {
|
||||
"config": {
|
||||
"interaction_mode": "expert",
|
||||
"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 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 (
|
||||
_collect_reply_attachments_from_history,
|
||||
_collect_recent_tool_attachments,
|
||||
_get_channel_dispatch_setting,
|
||||
_latest_user_turn_uuid,
|
||||
_maybe_add_media_path_for_wechat_reply,
|
||||
_maybe_expand_reply_attachments_for_channel,
|
||||
_parse_message_attachments,
|
||||
_persist_channel_assistant_if_turn_missing,
|
||||
_resolve_channel_dispatch,
|
||||
_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
|
||||
|
||||
|
||||
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:
|
||||
out = _parse_message_attachments('[{"type":"image_ref","attachment_id":"a1"}]')
|
||||
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"
|
||||
|
||||
|
||||
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:
|
||||
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="assistant", content="x", attachments=None),
|
||||
]
|
||||
out = _collect_recent_tool_attachments(store=_FakeStore(rows), session_id="s1")
|
||||
assert len(out) == 1
|
||||
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:
|
||||
# Avoid touching disk: stub AttachmentAssetStore.get_local_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:
|
||||
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
|
||||
|
||||
import os
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
|
@ -132,6 +134,36 @@ class WeixinIlinkApiTests(unittest.TestCase):
|
|||
finally:
|
||||
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:
|
||||
old_usecase = weixin_ilink_api._process_inbound_payload_usecase
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue