mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 01:50:44 +08:00
- 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>
892 lines
36 KiB
Python
892 lines
36 KiB
Python
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import re
|
||
from typing import Any
|
||
|
||
from interfaces.channels.base import InboundMessage, OutboundMessage
|
||
from interfaces.channels.wecom.wecom_bridge import WeComAdapter
|
||
from runtime.types import normalize_interaction_mode, normalize_requested_specialist
|
||
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:
|
||
return f"{_CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX}{str(channel or '').strip().lower()}"
|
||
|
||
|
||
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 _channel_dispatch_setting_aliases(channel: str) -> tuple[str, ...]:
|
||
ch = str(channel or "").strip().lower()
|
||
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)
|
||
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, lang: str) -> Any:
|
||
from runtime.agents.factory import build_gateway_executor
|
||
|
||
return build_gateway_executor(
|
||
store,
|
||
lang=str(lang or "zh"),
|
||
specialist=specialist,
|
||
viewer_user_id=None,
|
||
viewer_username="administrator",
|
||
viewer_tenant_id=(tenant_id or None),
|
||
policy_session_id=session_id,
|
||
path_policy_tenant_id=(tenant_id or None),
|
||
path_policy_user_id=None,
|
||
)
|
||
|
||
|
||
def _menu_text() -> str:
|
||
return (
|
||
"已绑定成功,常用命令:\n"
|
||
"1) 帮助 / 菜单\n"
|
||
"2) 记待办 <内容>\n"
|
||
"3) 查待办\n"
|
||
"4) 完成待办 <todo_id>\n"
|
||
"5) 指派待办 <todo_id> <assignee_user_id>\n"
|
||
"6) 加知识 <内容>\n"
|
||
"7) 查知识 <关键词>"
|
||
)
|
||
|
||
|
||
def _handle_productivity_commands(*, text: str, tenant_id: str, user_id: str) -> str | None:
|
||
t = (text or "").strip()
|
||
if not t:
|
||
return None
|
||
if t in ("帮助", "菜单", "help", "/help"):
|
||
return _menu_text()
|
||
|
||
from svc.config.paths import db_path
|
||
from svc.persistence.sqlite_store import SqliteStore
|
||
|
||
store = get_assistant_store()
|
||
|
||
if t.startswith("记待办 "):
|
||
title = t[len("记待办 ") :].strip()
|
||
if not title:
|
||
return "待办内容不能为空。示例:记待办 明天10点开会"
|
||
row = store.todo_create(tenant_id=tenant_id, owner_user_id=user_id, title=title)
|
||
return f"已创建待办:{row['id'][:8]} | {row['title']}"
|
||
|
||
if t in ("查待办", "todo", "todos"):
|
||
rows = store.todo_list(tenant_id=tenant_id, assignee_user_id=None, status="open", limit=10)
|
||
if not rows:
|
||
return "当前没有未完成待办。"
|
||
lines = [f"- {r['id'][:8]} | {r['title']}" for r in rows]
|
||
return "未完成待办:\n" + "\n".join(lines)
|
||
|
||
if t.startswith("完成待办 "):
|
||
tid = t[len("完成待办 ") :].strip()
|
||
if not tid:
|
||
return "请提供 todo_id。示例:完成待办 1234abcd"
|
||
rows = store.todo_list(tenant_id=tenant_id, assignee_user_id=None, status=None, limit=200)
|
||
full = next((r["id"] for r in rows if str(r["id"]).startswith(tid)), tid)
|
||
ok = store.todo_set_status(tenant_id=tenant_id, todo_id=full, status="done")
|
||
return "已完成。" if ok else "未找到该待办。"
|
||
|
||
if t.startswith("指派待办 "):
|
||
body = t[len("指派待办 ") :].strip()
|
||
parts = body.split()
|
||
if len(parts) < 2:
|
||
return "格式:指派待办 <todo_id> <assignee_user_id>"
|
||
tid, assignee = parts[0], parts[1]
|
||
rows = store.todo_list(tenant_id=tenant_id, assignee_user_id=None, status=None, limit=200)
|
||
full = next((r["id"] for r in rows if str(r["id"]).startswith(tid)), tid)
|
||
ok = store.todo_assign(tenant_id=tenant_id, todo_id=full, assignee_user_id=assignee)
|
||
return "已指派。" if ok else "未找到该待办或用户。"
|
||
|
||
if t.startswith("加知识 "):
|
||
content = t[len("加知识 ") :].strip()
|
||
if not content:
|
||
return "知识内容不能为空。示例:加知识 办公室WiFi密码是12345678"
|
||
from runtime.tools.experts.productivity.kb_tools import kb_add_tool
|
||
|
||
res = kb_add_tool().handler({"tenant_id": tenant_id, "user_id": user_id, "text": content})
|
||
if not res.get("ok"):
|
||
return f"写入失败:{res.get('error')}"
|
||
return f"已写入知识:{str(res.get('chunk_id') or '')[:8]}"
|
||
|
||
if t.startswith("查知识 "):
|
||
q = t[len("查知识 ") :].strip()
|
||
if not q:
|
||
return "请提供关键词。示例:查知识 WiFi 密码"
|
||
from runtime.tools.experts.productivity.kb_tools import kb_search_tool
|
||
|
||
res = kb_search_tool().handler({"tenant_id": tenant_id, "query": q, "limit": 5})
|
||
if not res.get("ok"):
|
||
return f"查询失败:{res.get('error')}"
|
||
hits = res.get("hits") if isinstance(res.get("hits"), list) else []
|
||
if not hits:
|
||
return "未找到相关知识。"
|
||
lines = [f"- {str(h.get('source') or '')}: {str(h.get('snippet') or '')}" for h in hits[:5] if isinstance(h, dict)]
|
||
return "知识检索结果:\n" + "\n".join(lines)
|
||
|
||
return None
|
||
|
||
|
||
def _role_can_write(role: str, text: str) -> bool:
|
||
low = (text or "").strip().lower()
|
||
if not low:
|
||
return True
|
||
if role in ("owner", "admin", "member"):
|
||
return True
|
||
write_prefixes = ("记待办 ", "完成待办 ", "指派待办 ", "加知识 ")
|
||
return not any((text or "").startswith(p) for p in write_prefixes)
|
||
|
||
|
||
def _resolve_wecom_account_id(inbound: Any, payload: dict[str, Any]) -> str:
|
||
if isinstance(inbound.metadata, dict):
|
||
for key in ("aibotid", "bot_id", "account_id"):
|
||
val = inbound.metadata.get(key)
|
||
if val:
|
||
return str(val).strip()
|
||
raw = inbound.metadata.get("raw")
|
||
if isinstance(raw, dict):
|
||
for key in ("aibotid", "bot_id", "account_id"):
|
||
val = raw.get(key)
|
||
if val:
|
||
return str(val).strip()
|
||
for key in ("aibotid", "bot_id", "account_id"):
|
||
val = payload.get(key)
|
||
if val:
|
||
return str(val).strip()
|
||
raw_payload = payload.get("raw")
|
||
if isinstance(raw_payload, dict) and raw_payload.get("aibotid"):
|
||
return str(raw_payload.get("aibotid")).strip()
|
||
return ""
|
||
|
||
|
||
def _resolve_generic_account_id(inbound: InboundMessage, payload: dict[str, Any]) -> str:
|
||
if isinstance(inbound.metadata, dict):
|
||
for key in ("account_id", "bot_id", "app_id", "agent_id"):
|
||
val = inbound.metadata.get(key)
|
||
if val:
|
||
return str(val).strip()
|
||
for key in ("account_id", "bot_id", "app_id", "agent_id"):
|
||
val = payload.get(key)
|
||
if val:
|
||
return str(val).strip()
|
||
raw_payload = payload.get("raw")
|
||
if isinstance(raw_payload, dict):
|
||
for key in ("account_id", "bot_id", "app_id", "agent_id"):
|
||
val = raw_payload.get(key)
|
||
if val:
|
||
return str(val).strip()
|
||
return ""
|
||
|
||
|
||
def _ensure_administrator_owner(store: Any) -> dict[str, Any] | None:
|
||
tenant_name = str(store.get_setting("wecom_auto_bind_tenant_name") or "Team").strip() or "Team"
|
||
tenants = store.list_tenants(limit=200)
|
||
tenant = next((t for t in tenants if str(t.get("name") or "") == tenant_name), None)
|
||
if tenant is None:
|
||
tenant = store.create_tenant(tenant_name)
|
||
tenant_id = str(tenant.get("id") or "")
|
||
if not tenant_id:
|
||
return None
|
||
user = store.get_user_by_username(tenant_id=tenant_id, username="administrator")
|
||
if not user:
|
||
try:
|
||
from svc.config.passwords import load_expected_password
|
||
except Exception:
|
||
load_expected_password = None # type: ignore
|
||
pwd = load_expected_password(store) if callable(load_expected_password) else None
|
||
if not pwd:
|
||
return None
|
||
user = store.create_user_account(
|
||
tenant_id=tenant_id,
|
||
username="administrator",
|
||
password_hash=hashlib.sha256(pwd.encode("utf-8")).hexdigest(),
|
||
display_name="Administrator",
|
||
role="owner",
|
||
is_active=True,
|
||
)
|
||
user_id = str((user or {}).get("id") or "")
|
||
if not user_id:
|
||
return None
|
||
return {
|
||
"tenant_id": tenant_id,
|
||
"user_id": user_id,
|
||
"display_name": (user or {}).get("display_name") or "Administrator",
|
||
"role": str((user or {}).get("role") or "owner"),
|
||
}
|
||
|
||
|
||
def _extract_group_name(inbound: Any) -> str:
|
||
if not isinstance(inbound.metadata, dict):
|
||
return ""
|
||
cands: list[str] = []
|
||
for key in ("chat_name", "group_name", "room_name", "conversation_name"):
|
||
v = inbound.metadata.get(key)
|
||
if v is not None:
|
||
cands.append(str(v).strip())
|
||
raw = inbound.metadata.get("raw")
|
||
if isinstance(raw, dict):
|
||
for key in ("chat_name", "group_name", "room_name", "conversation_name", "chatname"):
|
||
v = raw.get(key)
|
||
if v is not None:
|
||
cands.append(str(v).strip())
|
||
chat_obj = raw.get("chat")
|
||
if isinstance(chat_obj, dict):
|
||
for key in ("name", "chat_name", "group_name"):
|
||
v = chat_obj.get(key)
|
||
if v is not None:
|
||
cands.append(str(v).strip())
|
||
for s in cands:
|
||
if s:
|
||
return s
|
||
return ""
|
||
|
||
|
||
def _build_wecom_session_title(*, account_name: str, external_user_id: str, is_group: bool, group_name: str) -> str:
|
||
base = f"{str(account_name or '').strip() or 'WeCom'}+{str(external_user_id or '').strip() or 'unknown'}"
|
||
if is_group and str(group_name or "").strip():
|
||
body = f"{base}+{str(group_name).strip()}"
|
||
else:
|
||
body = base
|
||
return f"wechat|{body}"
|
||
|
||
|
||
def _build_channel_session_title(*, channel: str, account_name: str, external_user_id: str, is_group: bool, group_name: str) -> str:
|
||
ch = str(channel or "").strip().lower() or "channel"
|
||
if ch == "wecom":
|
||
return _build_wecom_session_title(
|
||
account_name=account_name,
|
||
external_user_id=external_user_id,
|
||
is_group=is_group,
|
||
group_name=group_name,
|
||
)
|
||
base = f"{str(account_name or '').strip() or ch}+{str(external_user_id or '').strip() or 'unknown'}"
|
||
body = f"{base}+{str(group_name or '').strip()}" if is_group and str(group_name or "").strip() else base
|
||
return f"{ch}|{body}"
|
||
|
||
|
||
def _parse_generic_inbound(channel_name: str, payload: dict[str, Any]) -> InboundMessage:
|
||
meta = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||
user_id = str(payload.get("user_id") or payload.get("external_user_id") or "").strip()
|
||
chat_id = str(payload.get("chat_id") or payload.get("external_chat_id") or user_id).strip()
|
||
text = str(payload.get("text") or "").strip()
|
||
if not user_id:
|
||
raise ValueError("missing user_id")
|
||
if not chat_id:
|
||
chat_id = user_id
|
||
is_group = bool(payload.get("is_group"))
|
||
mentions = payload.get("mentions") if isinstance(payload.get("mentions"), list) else []
|
||
attachments = payload.get("attachments") if isinstance(payload.get("attachments"), list) else []
|
||
return InboundMessage(
|
||
channel=str(channel_name or "unknown"),
|
||
external_user_id=user_id,
|
||
external_chat_id=chat_id,
|
||
text=text,
|
||
is_group=is_group,
|
||
mentions=[str(x).strip() for x in mentions if str(x).strip()],
|
||
attachments=[a for a in attachments if isinstance(a, dict)],
|
||
metadata={str(k): v for k, v in meta.items()},
|
||
)
|
||
|
||
|
||
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 []
|
||
obj = raw
|
||
if isinstance(raw, str):
|
||
s = str(raw or "").strip()
|
||
if not s:
|
||
return []
|
||
try:
|
||
import json
|
||
|
||
obj = json.loads(s)
|
||
except Exception:
|
||
return []
|
||
if not isinstance(obj, list):
|
||
return []
|
||
return [x for x in obj if isinstance(x, dict)]
|
||
|
||
|
||
def _first_attachment_id(atts: list[dict[str, Any]]) -> str:
|
||
for a in atts or []:
|
||
if not isinstance(a, dict):
|
||
continue
|
||
aid = str(a.get("attachment_id") or a.get("attachmentId") or "").strip().lower()
|
||
if aid:
|
||
return aid
|
||
return ""
|
||
|
||
|
||
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 :])
|
||
|
||
|
||
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:
|
||
return []
|
||
try:
|
||
rows = store.get_messages(session_id=sid, limit=80)
|
||
except Exception:
|
||
rows = []
|
||
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
|
||
ok = False
|
||
for a in atts:
|
||
if not isinstance(a, dict):
|
||
continue
|
||
t = str(a.get("type") or "").strip().lower()
|
||
if t in {"image_ref", "video_ref", "binary_ref", "text_ref", "image", "input_image", "image_url"}:
|
||
ok = True
|
||
break
|
||
if ok:
|
||
return atts
|
||
return []
|
||
|
||
|
||
def _maybe_add_media_path_for_wechat_reply(reply: dict[str, Any]) -> None:
|
||
"""For wechat/weixin sidecar, prefer a local file path for media send."""
|
||
try:
|
||
from svc.files.attachment_assets import AttachmentAssetStore
|
||
except Exception:
|
||
return
|
||
if not isinstance(reply, dict):
|
||
return
|
||
if str(reply.get("media_path") or reply.get("mediaPath") or "").strip():
|
||
return
|
||
if str(reply.get("media_url") or reply.get("mediaUrl") or "").strip():
|
||
return
|
||
atts = reply.get("attachments") if isinstance(reply.get("attachments"), list) else []
|
||
aid = _first_attachment_id([a for a in atts if isinstance(a, dict)])
|
||
if not aid:
|
||
return
|
||
p = AttachmentAssetStore().get_local_path(aid)
|
||
if not p:
|
||
return
|
||
# use the existing runner field name
|
||
reply["media_path"] = str(p)
|
||
|
||
|
||
def _maybe_expand_reply_attachments_for_channel(reply: dict[str, Any]) -> None:
|
||
"""For chat sidecar delivery, convert attachment refs to base64 payloads.
|
||
|
||
The runner supports base64 keys like data_base64/media_base64/image_base64/data.
|
||
This avoids relying on sidecar being able to access gateway-local disk paths.
|
||
"""
|
||
if not isinstance(reply, dict):
|
||
return
|
||
raw = reply.get("attachments")
|
||
if not isinstance(raw, list) or not raw:
|
||
return
|
||
# If already has base64 payloads, keep as-is.
|
||
for a in raw:
|
||
if not isinstance(a, dict):
|
||
continue
|
||
if str(
|
||
a.get("data_base64")
|
||
or a.get("media_base64")
|
||
or a.get("image_base64")
|
||
or a.get("video_base64")
|
||
or a.get("audio_base64")
|
||
or a.get("data")
|
||
or ""
|
||
).strip():
|
||
return
|
||
# Expand first attachment ref only (avoid large payload).
|
||
a0 = next((a for a in raw if isinstance(a, dict)), None)
|
||
if not isinstance(a0, dict):
|
||
return
|
||
aid = str(a0.get("attachment_id") or a0.get("attachmentId") or "").strip().lower()
|
||
if not aid:
|
||
return
|
||
try:
|
||
import base64
|
||
|
||
from svc.files.attachment_assets import AttachmentAssetStore
|
||
except Exception:
|
||
return
|
||
ast = AttachmentAssetStore()
|
||
blob, meta = ast.load_bytes(aid)
|
||
if not blob:
|
||
return
|
||
# Conservative cap to avoid blowing up channel payload.
|
||
max_bytes = 8 * 1024 * 1024
|
||
if len(blob) > max_bytes:
|
||
return
|
||
mime = (meta.mime if meta else "") or str(a0.get("mime") or a0.get("mime_type") or "application/octet-stream")
|
||
name = (meta.name if meta else "") or str(a0.get("name") or "attachment")
|
||
b64 = base64.b64encode(blob).decode("ascii")
|
||
reply["attachments"] = [
|
||
{
|
||
"name": name,
|
||
"mime": mime,
|
||
"media_type": mime,
|
||
"data_base64": b64,
|
||
}
|
||
]
|
||
|
||
|
||
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 []
|
||
try:
|
||
rows = store.get_messages(session_id=sid, limit=120)
|
||
except Exception:
|
||
rows = []
|
||
target = str(reply_text or "").strip()
|
||
if not target:
|
||
return []
|
||
for row in reversed(list(rows or [])):
|
||
role = str(getattr(row, "role", "") or "").strip().lower()
|
||
if role != "assistant":
|
||
continue
|
||
content = str(getattr(row, "content", "") or "").strip()
|
||
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:
|
||
ch = str(channel or "").strip().lower()
|
||
if ch not in {"wechat", "weixin"}:
|
||
return False
|
||
t = str(text or "").strip()
|
||
if not t:
|
||
return True
|
||
low = t.lower()
|
||
# Silence gateway/provider credential errors for weixin user-facing channel.
|
||
# Match variants like:
|
||
# - Missing API key for provider "openai"
|
||
# - Missing API key for provider 'openai'
|
||
# - Missing API key ... provider openai
|
||
if (
|
||
("missing api key" in low and "provider" in low and "openai" in low)
|
||
or bool(re.search(r'missing\s+api\s+key.*provider\s+[\'"]?openai[\'"]?', low))
|
||
):
|
||
return True
|
||
if ("openai / 兼容 api" in t or "openai 兼容 api" in t) and "api key" in low:
|
||
return True
|
||
if "configure the gateway auth for that provider" in low and "openai" in low:
|
||
return True
|
||
return False
|
||
|
||
|
||
def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||
from runtime.operations.mcp_env import apply_gateway_mcp_env_to_os
|
||
|
||
apply_gateway_mcp_env_to_os()
|
||
channel_name = str(payload.get("channel") or "wecom").strip().lower()
|
||
if channel_name in ("wecom", "wechat_work", "wxwork"):
|
||
adapter = WeComAdapter()
|
||
inbound = adapter.parse_inbound(payload)
|
||
else:
|
||
adapter = None
|
||
inbound = _parse_generic_inbound(channel_name, payload)
|
||
from svc.persistence.sqlite_store import SqliteStore
|
||
from svc.config.paths import db_path
|
||
|
||
store = get_assistant_store()
|
||
if channel_name == "wecom":
|
||
account_id = _resolve_wecom_account_id(inbound, payload) or str(store.get_setting("wecom_bot_id") or "").strip()
|
||
else:
|
||
account_id = _resolve_generic_account_id(inbound, payload)
|
||
if not account_id:
|
||
raise ValueError(f"missing {channel_name} account_id")
|
||
|
||
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(
|
||
code=code,
|
||
channel=inbound.channel,
|
||
external_user_id=inbound.external_user_id,
|
||
display_name=(
|
||
str(inbound.metadata.get("display_name")).strip()
|
||
if isinstance(inbound.metadata, dict) and inbound.metadata.get("display_name") is not None
|
||
else None
|
||
),
|
||
)
|
||
reply = ("绑定成功。\n\n" + _menu_text()) if info else "绑定失败:无效或已使用的绑定码。"
|
||
else:
|
||
reply = ""
|
||
reply_attachments: list[dict[str, Any]] = []
|
||
ident = store.resolve_user_by_channel_identity_v2(
|
||
channel=inbound.channel,
|
||
account_id=account_id,
|
||
external_user_id=inbound.external_user_id,
|
||
)
|
||
if not ident:
|
||
owner = _ensure_administrator_owner(store)
|
||
if owner:
|
||
store.upsert_user_channel_account(
|
||
tenant_id=str(owner.get("tenant_id") or ""),
|
||
user_id=str(owner.get("user_id") or ""),
|
||
channel=inbound.channel,
|
||
account_id=account_id,
|
||
name=account_id,
|
||
config={"mode": "single-bot-upgraded"},
|
||
is_active=True,
|
||
)
|
||
store.upsert_channel_identity_v2(
|
||
tenant_id=str(owner.get("tenant_id") or ""),
|
||
channel=inbound.channel,
|
||
account_id=account_id,
|
||
external_user_id=inbound.external_user_id,
|
||
user_id=str(owner.get("user_id") or ""),
|
||
)
|
||
ident = store.resolve_user_by_channel_identity_v2(
|
||
channel=inbound.channel,
|
||
account_id=account_id,
|
||
external_user_id=inbound.external_user_id,
|
||
)
|
||
preface = "当前 Bot 已升级归属 administrator。"
|
||
if not ident:
|
||
reply = "账号初始化失败,请检查 administrator/tenant 配置。"
|
||
if ident:
|
||
from runtime.orchestration.policy import ActionPolicyContext, PolicyEngine
|
||
from runtime.orchestration.security import has_explicit_confirmation_token
|
||
|
||
tenant_id = str(ident.get("tenant_id") or "")
|
||
user_id = str(ident.get("user_id") or "")
|
||
role = str(ident.get("role") or "member")
|
||
account = store.find_user_by_channel_account(channel=inbound.channel, account_id=account_id) or {}
|
||
account_name = str(account.get("name") or "").strip() or account_id
|
||
group_name = _extract_group_name(inbound)
|
||
session_id = store.get_or_create_channel_session_v2(
|
||
tenant_id=tenant_id,
|
||
channel=inbound.channel,
|
||
account_id=account_id,
|
||
external_user_id=inbound.external_user_id,
|
||
external_chat_id=inbound.external_chat_id,
|
||
session_title=_build_channel_session_title(
|
||
channel=inbound.channel,
|
||
account_name=account_name,
|
||
external_user_id=inbound.external_user_id,
|
||
is_group=inbound.is_group,
|
||
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()
|
||
blob = (inbound.text or "").lower()
|
||
mention_all = ("@all" in blob) or ("全体" in inbound.text) or ("@所有" in inbound.text)
|
||
act = ActionPolicyContext(
|
||
session_id=session_id,
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
channel=inbound.channel,
|
||
user_text=inbound.text,
|
||
action="send_message",
|
||
target={"is_group": bool(inbound.is_group), "mention_all": bool(mention_all)},
|
||
)
|
||
d = pe.decide_action(ctx=act)
|
||
if d.needs_confirmation:
|
||
token_key = f"confirm_token:{session_id}"
|
||
token = (store.get_setting(token_key) or "").strip()
|
||
if not token:
|
||
token = pe.new_confirmation_token()
|
||
store.set_setting(token_key, token)
|
||
if not has_explicit_confirmation_token(inbound.text, token):
|
||
reply = f"该动作需要确认。请回复 `confirm {token}` 或包含 `[confirm:{token}]`。"
|
||
else:
|
||
reply = f"[assistant] ok scope={scope} session={session_id[:8]} (confirmed)"
|
||
else:
|
||
if not _role_can_write(role, inbound.text):
|
||
reply = "你的角色暂无写入权限。请联系管理员提升权限。"
|
||
else:
|
||
cmd_reply = _handle_productivity_commands(
|
||
text=inbound.text,
|
||
tenant_id=tenant_id,
|
||
user_id=user_id,
|
||
)
|
||
if cmd_reply is not None:
|
||
reply = cmd_reply
|
||
elif not reply:
|
||
user_text = (inbound.text or "").strip()
|
||
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, 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),
|
||
tenant_id=str(tenant_id or ""),
|
||
user_id=str(user_id or ""),
|
||
role=str(role or "member"),
|
||
channel=str(inbound.channel or "inbound"),
|
||
text=str(user_text or ""),
|
||
attachments=gw_attachments,
|
||
metadata={
|
||
"tenant_id": tenant_id,
|
||
"user_id": user_id,
|
||
"channel": inbound.channel,
|
||
"role": role,
|
||
"account_id": account_id,
|
||
"interaction_mode": interaction_mode,
|
||
"selected_specialist": selected_specialist,
|
||
},
|
||
)
|
||
manager = _build_admin_gateway_executor(
|
||
store,
|
||
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,
|
||
)
|
||
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),
|
||
reply_text=reply,
|
||
)
|
||
except Exception as e:
|
||
reply = f"抱歉,处理消息时出错:{type(e).__name__}: {e}"
|
||
reply_attachments = []
|
||
else:
|
||
reply = "收到消息,但内容为空。请直接发送文本。"
|
||
reply_attachments = []
|
||
if preface:
|
||
if reply:
|
||
reply = f"{preface}\n\n{reply}"
|
||
else:
|
||
reply = f"{preface}\n\n{_menu_text()}"
|
||
|
||
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:
|
||
if not reply_attachments:
|
||
# If assistant didn't persist attachments, fall back to recent tool-produced media.
|
||
reply_attachments = _collect_recent_tool_attachments(store=store, session_id=str(session_id))
|
||
replies = [
|
||
{
|
||
"channel": inbound.channel,
|
||
"chat_id": inbound.external_chat_id,
|
||
"text": reply,
|
||
"attachments": list(reply_attachments or []),
|
||
"metadata": {},
|
||
}
|
||
]
|
||
# For wechat/weixin sidecar delivery, add a local media_path when reply attachments refer to attachment_id.
|
||
for r in replies or []:
|
||
if not isinstance(r, dict):
|
||
continue
|
||
ch = str(r.get("channel") or inbound.channel or "").strip().lower()
|
||
if ch in {"wechat", "weixin", "whatsapp"}:
|
||
_maybe_expand_reply_attachments_for_channel(r)
|
||
_maybe_add_media_path_for_wechat_reply(r)
|
||
out = {"ok": True, "replies": replies}
|
||
return out
|
||
|
||
|
||
__all__ = ["process_inbound_payload"]
|
||
|