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:
oliver 2026-06-03 01:36:45 +08:00
parent 39fd1905b6
commit bc1b45a8fd
20 changed files with 1056 additions and 86 deletions

View file

@ -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 可视化调度(新增):

View file

@ -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")

View file

@ -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"}

View file

@ -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,
]),

View file

@ -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 = []

View file

@ -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:

View file

@ -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"

View 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
}

View file

@ -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

View file

@ -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;

View 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);
});

View file

@ -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",

View file

@ -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:

View file

@ -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)

View file

@ -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(

View file

@ -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"

View 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

View file

@ -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

View file

@ -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

View file

@ -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