mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-11 05:20:44 +08:00
Harden attachment access controls and stabilize the Weixin bridge.
This persists referenced media safely, adds explicit attachment ACL/backfill flows, and fixes the Weixin sidecar so official login state can bridge reliably into oclaw without duplicate replays or dropped replies. Made-with: Cursor
This commit is contained in:
parent
6cfaff06f6
commit
31e3962c74
22 changed files with 1885 additions and 55 deletions
|
|
@ -7,6 +7,8 @@ import json
|
|||
import queue
|
||||
import threading
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from typing import Any
|
||||
from pathlib import Path
|
||||
|
|
@ -66,6 +68,11 @@ _CHAT_MSG_LIMIT = 256
|
|||
_SESSION_TITLE_MAX_LEN = 120
|
||||
_AVATAR_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
|
||||
_AVATAR_MIMES = frozenset({"image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"})
|
||||
_ATTACHMENT_ID_RE = re.compile(r"^[a-f0-9]{64}$")
|
||||
_ATT_DOWNLOAD_BUCKET: dict[str, tuple[float, float]] = {}
|
||||
_ATT_DOWNLOAD_LOCK = threading.Lock()
|
||||
_ATT_DOWNLOAD_RATE_PER_SEC = 1.0 # 60/min
|
||||
_ATT_DOWNLOAD_BURST = 20.0
|
||||
|
||||
_CHAT_STOP_EVENTS: dict[str, threading.Event] = {}
|
||||
_CHAT_STOP_LOCK = threading.Lock()
|
||||
|
|
@ -252,6 +259,58 @@ def _parse_attachments_payload(raw: Any) -> list[dict[str, Any]] | None:
|
|||
return out if out else None
|
||||
|
||||
|
||||
def _is_valid_attachment_id(raw: str) -> bool:
|
||||
return bool(_ATTACHMENT_ID_RE.fullmatch(str(raw or "").strip().lower()))
|
||||
|
||||
|
||||
def _can_access_attachment(store: SqliteStore, ctx: dict[str, Any], *, attachment_id: str) -> bool:
|
||||
aid = str(attachment_id or "").strip().lower()
|
||||
if not _is_valid_attachment_id(aid):
|
||||
return False
|
||||
tenant_id = str(ctx.get("tenant_id") or "").strip()
|
||||
user_id = str(ctx.get("user_id") or "").strip()
|
||||
if not tenant_id:
|
||||
return False
|
||||
if _is_administrator_chat_viewer(ctx):
|
||||
if store.attachment_acl_allows_tenant(tenant_id=tenant_id, attachment_id=aid):
|
||||
return True
|
||||
if (not _attachment_acl_strict()) and store.attachment_referenced_in_tenant(tenant_id=tenant_id, attachment_id=aid):
|
||||
return True
|
||||
# Keep profile/avatar behavior compatible for administrator account.
|
||||
admin = store.get_user_by_username(tenant_id=tenant_id, username="administrator") or {}
|
||||
return str(admin.get("avatar_attachment_id") or "").strip().lower() == aid
|
||||
if user_id and store.attachment_acl_allows_user(tenant_id=tenant_id, user_id=user_id, attachment_id=aid):
|
||||
return True
|
||||
if (not _attachment_acl_strict()) and user_id and store.attachment_referenced_by_user(tenant_id=tenant_id, user_id=user_id, attachment_id=aid):
|
||||
return True
|
||||
user = store.get_user_by_id(tenant_id=tenant_id, user_id=user_id) if user_id else None
|
||||
return str((user or {}).get("avatar_attachment_id") or "").strip().lower() == aid
|
||||
|
||||
|
||||
def _rate_limit_attachment_download(*, actor_tenant_id: str, actor_user_id: str) -> bool:
|
||||
tid = str(actor_tenant_id or "").strip()
|
||||
uid = str(actor_user_id or "").strip()
|
||||
if not tid or not uid:
|
||||
return False
|
||||
key = f"{tid}:{uid}"
|
||||
now = time.time()
|
||||
with _ATT_DOWNLOAD_LOCK:
|
||||
tokens, last = _ATT_DOWNLOAD_BUCKET.get(key, (_ATT_DOWNLOAD_BURST, now))
|
||||
dt = max(0.0, now - float(last or now))
|
||||
tokens = min(_ATT_DOWNLOAD_BURST, float(tokens) + dt * _ATT_DOWNLOAD_RATE_PER_SEC)
|
||||
if tokens < 1.0:
|
||||
_ATT_DOWNLOAD_BUCKET[key] = (tokens, now)
|
||||
return False
|
||||
tokens -= 1.0
|
||||
_ATT_DOWNLOAD_BUCKET[key] = (tokens, now)
|
||||
return True
|
||||
|
||||
|
||||
def _attachment_acl_strict() -> bool:
|
||||
raw = str(os.getenv("AIA_ATTACHMENT_ACL_STRICT") or "").strip().lower()
|
||||
return raw in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _chat_username(ctx: dict[str, Any]) -> str:
|
||||
return str(ctx.get("username") or "").strip().lower()
|
||||
|
||||
|
|
@ -1448,18 +1507,86 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
authorization: str | None = Header(default=None),
|
||||
) -> Response:
|
||||
store = SqliteStore(db_path())
|
||||
_ = resolve_auth(store, authorization)
|
||||
aid = str(attachment_id or "").strip()
|
||||
ctx = resolve_auth(store, authorization)
|
||||
tenant_id = str(ctx.get("tenant_id") or "").strip()
|
||||
user_id = str(ctx.get("user_id") or "").strip()
|
||||
aid = str(attachment_id or "").strip().lower()
|
||||
if not aid:
|
||||
raise HTTPException(status_code=400, detail="attachment_id_required")
|
||||
if not _is_valid_attachment_id(aid):
|
||||
raise HTTPException(status_code=400, detail="attachment_id_invalid")
|
||||
if not _rate_limit_attachment_download(actor_tenant_id=tenant_id, actor_user_id=user_id):
|
||||
store.add_admin_audit_log(
|
||||
actor_tenant_id=tenant_id,
|
||||
actor_user_id=user_id,
|
||||
action="chat_attachment_download",
|
||||
target_type="attachment",
|
||||
target_id=aid,
|
||||
status="rate_limited",
|
||||
detail={"path": "chat_api.attachment_bytes"},
|
||||
)
|
||||
raise HTTPException(status_code=429, detail="rate_limited")
|
||||
if not _can_access_attachment(store, ctx, attachment_id=aid):
|
||||
store.add_admin_audit_log(
|
||||
actor_tenant_id=tenant_id,
|
||||
actor_user_id=user_id,
|
||||
action="chat_attachment_download",
|
||||
target_type="attachment",
|
||||
target_id=aid,
|
||||
status="forbidden",
|
||||
detail={"path": "chat_api.attachment_bytes"},
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="attachment_forbidden")
|
||||
ast = AttachmentAssetStore()
|
||||
try:
|
||||
blob, meta = ast.load_bytes(aid)
|
||||
except Exception:
|
||||
store.add_admin_audit_log(
|
||||
actor_tenant_id=tenant_id,
|
||||
actor_user_id=user_id,
|
||||
action="chat_attachment_download",
|
||||
target_type="attachment",
|
||||
target_id=aid,
|
||||
status="not_found",
|
||||
detail={"path": "chat_api.attachment_bytes"},
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="attachment_not_found") from None
|
||||
mime = (meta.mime if meta else None) or "application/octet-stream"
|
||||
store.add_admin_audit_log(
|
||||
actor_tenant_id=tenant_id,
|
||||
actor_user_id=user_id,
|
||||
action="chat_attachment_download",
|
||||
target_type="attachment",
|
||||
target_id=aid,
|
||||
status="ok",
|
||||
detail={"path": "chat_api.attachment_bytes", "mime": mime, "bytes": int(meta.bytes if meta else len(blob))},
|
||||
)
|
||||
return Response(content=blob, media_type=mime)
|
||||
|
||||
@chat.post("/admin/attachments/acl/backfill")
|
||||
def api_chat_admin_backfill_attachment_acl(
|
||||
limit_messages: int = Query(default=50_000, ge=1, le=500_000),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_administrator_chat_viewer(ctx)
|
||||
tenant_id = str(ctx.get("tenant_id") or "").strip()
|
||||
user_id = str(ctx.get("user_id") or "").strip()
|
||||
res = store.backfill_attachment_acl_from_messages(tenant_id=tenant_id, limit_messages=int(limit_messages))
|
||||
if bool(res.get("ok")):
|
||||
res.setdefault("recommended_next", "Set AIA_ATTACHMENT_ACL_STRICT=1 after verifying downloads.")
|
||||
store.add_admin_audit_log(
|
||||
actor_tenant_id=tenant_id,
|
||||
actor_user_id=user_id,
|
||||
action="attachment_acl_backfill",
|
||||
target_type="tenant",
|
||||
target_id=tenant_id,
|
||||
status="ok" if bool(res.get("ok")) else "error",
|
||||
detail=res,
|
||||
)
|
||||
return res
|
||||
|
||||
@chat.post("/sessions/{session_id}/messages")
|
||||
def api_chat_send(
|
||||
session_id: str,
|
||||
|
|
@ -1507,6 +1634,20 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
text = _effective_user_text(text=text_raw, attachments=attachments, store=store)
|
||||
if not _is_administrator_chat_viewer(ctx) and tenant_id and user_id:
|
||||
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
||||
# Record attachment ownership for access control (download endpoint).
|
||||
if attachments and tenant_id and user_id:
|
||||
for a in attachments:
|
||||
if not isinstance(a, dict):
|
||||
continue
|
||||
aid = str(a.get("attachment_id") or "").strip().lower()
|
||||
if aid and _is_valid_attachment_id(aid):
|
||||
store.link_attachment_acl(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
session_id=str(session_id),
|
||||
attachment_id=aid,
|
||||
source="user_upload",
|
||||
)
|
||||
lang = _api_lang(store)
|
||||
apply_gateway_mcp_env_to_os()
|
||||
manager_agent = _init_gateway_executor(
|
||||
|
|
@ -1611,6 +1752,19 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
text = _effective_user_text(text=text_raw, attachments=attachments, store=store)
|
||||
if not _is_administrator_chat_viewer(ctx) and tenant_id and user_id:
|
||||
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
||||
if attachments and tenant_id and user_id:
|
||||
for a in attachments:
|
||||
if not isinstance(a, dict):
|
||||
continue
|
||||
aid = str(a.get("attachment_id") or "").strip().lower()
|
||||
if aid and _is_valid_attachment_id(aid):
|
||||
store.link_attachment_acl(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
session_id=str(session_id),
|
||||
attachment_id=aid,
|
||||
source="user_upload",
|
||||
)
|
||||
lang = _api_lang(store)
|
||||
apply_gateway_mcp_env_to_os()
|
||||
manager_agent = _init_gateway_executor(
|
||||
|
|
|
|||
|
|
@ -130,6 +130,11 @@
|
|||
.chat-sess-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.chat-sess-menu-sep {
|
||||
height: 1px;
|
||||
margin: 4px 6px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.chat-msg__md {
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
|
|
|
|||
|
|
@ -148,6 +148,11 @@ const I18N = {
|
|||
"chat.attachment.previewLoading": "加载中…",
|
||||
"chat.attachment.previewError": "预览失败",
|
||||
"chat.attachment.previewEmpty": "(空内容)",
|
||||
"chat.attachmentAcl": "附件 ACL",
|
||||
"chat.attachmentAclBackfill": "回填 ACL",
|
||||
"chat.attachmentAclBackfillPrompt": "回填 attachment_acl(扫描历史消息 attachments)?建议先在低峰期执行。",
|
||||
"chat.attachmentAclBackfillOk": "回填完成:插入 {inserted} 条(扫描 {scanned_messages} 条消息)",
|
||||
"chat.attachmentAclBackfillFail": "回填失败:{error}",
|
||||
},
|
||||
en: {
|
||||
"chat.pageTitle": "oliver",
|
||||
|
|
@ -294,6 +299,11 @@ const I18N = {
|
|||
"chat.attachment.previewLoading": "Loading…",
|
||||
"chat.attachment.previewError": "Preview failed",
|
||||
"chat.attachment.previewEmpty": "(empty)",
|
||||
"chat.attachmentAcl": "Attachment ACL",
|
||||
"chat.attachmentAclBackfill": "Backfill ACL",
|
||||
"chat.attachmentAclBackfillPrompt": "Backfill attachment_acl by scanning historical message attachments? Recommended during off-peak hours.",
|
||||
"chat.attachmentAclBackfillOk": "Backfill done: inserted {inserted} rows (scanned {scanned_messages} messages)",
|
||||
"chat.attachmentAclBackfillFail": "Backfill failed: {error}",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -2164,6 +2174,9 @@ function syncAuthUserLabel() {
|
|||
if (!user) return;
|
||||
user.innerHTML = "";
|
||||
const name = String((authSession && (authSession.display_name || authSession.username || authSession.user_id)) || "");
|
||||
const isAdminViewer = String((authSession && authSession.username) || "")
|
||||
.trim()
|
||||
.toLowerCase() === "administrator";
|
||||
if (!name) return;
|
||||
const nameBtn = el("button", {
|
||||
type: "button",
|
||||
|
|
@ -2179,7 +2192,7 @@ function syncAuthUserLabel() {
|
|||
onclick: (ev) => {
|
||||
ev.stopPropagation();
|
||||
document.querySelectorAll(".chat-sess-menu-pop").forEach((n) => n.remove());
|
||||
const menu = el("div", { class: "chat-sess-menu-pop", style: "position:fixed;" }, [
|
||||
const items = [
|
||||
el("button", {
|
||||
type: "button",
|
||||
class: "chat-sess-menu-item",
|
||||
|
|
@ -2198,13 +2211,28 @@ function syncAuthUserLabel() {
|
|||
"data-menu-action": "dispatchLabels",
|
||||
text: t("chat.dispatchLabelsEdit"),
|
||||
}),
|
||||
];
|
||||
if (isAdminViewer) {
|
||||
items.push(el("div", { class: "chat-sess-menu-sep" }));
|
||||
items.push(
|
||||
el("button", {
|
||||
type: "button",
|
||||
class: "chat-sess-menu-item",
|
||||
"data-menu-action": "attachmentAclBackfill",
|
||||
text: t("chat.attachmentAclBackfill"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
items.push(el("div", { class: "chat-sess-menu-sep" }));
|
||||
items.push(
|
||||
el("button", {
|
||||
type: "button",
|
||||
class: "chat-sess-menu-item",
|
||||
"data-menu-action": "logout",
|
||||
text: t("auth.logout"),
|
||||
}),
|
||||
]);
|
||||
);
|
||||
const menu = el("div", { class: "chat-sess-menu-pop", style: "position:fixed;" }, items);
|
||||
const rect = moreBtn.getBoundingClientRect();
|
||||
menu.style.left = `${Math.min(rect.left, window.innerWidth - 220)}px`;
|
||||
menu.style.top = `${Math.max(8, rect.top - 92)}px`;
|
||||
|
|
@ -4394,6 +4422,23 @@ document.body.addEventListener("click", async (e) => {
|
|||
await openDispatchLabelsEditor(status);
|
||||
return;
|
||||
}
|
||||
if (action === "attachmentAclBackfill") {
|
||||
const status = document.querySelector(".chat-status");
|
||||
if (!(await confirmChatAction(t("chat.attachmentAclBackfillPrompt")))) return;
|
||||
try {
|
||||
const res = await apiPost("/admin/api/chat/admin/attachments/acl/backfill", {});
|
||||
const ok = !!(res && (res.ok === true || res.ok === 1));
|
||||
if (ok) {
|
||||
if (status) status.textContent = t("chat.attachmentAclBackfillOk", res);
|
||||
} else {
|
||||
const err = String((res && (res.error || res.detail)) || "backfill_failed");
|
||||
if (status) status.textContent = t("chat.attachmentAclBackfillFail", { error: err });
|
||||
}
|
||||
} catch (err) {
|
||||
if (status) status.textContent = t("chat.attachmentAclBackfillFail", { error: String(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
|
||||
from oclaw.runtime.application.gateway import process_inbound_payload_usecase
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -14,7 +19,7 @@ def _require_ilink_auth(
|
|||
*,
|
||||
authorization_type: str | None,
|
||||
authorization: str | None,
|
||||
) -> None:
|
||||
) -> str:
|
||||
# Minimal contract required by oclaw-weixin:
|
||||
# - AuthorizationType: ilink_bot_token
|
||||
# - Authorization: Bearer <token>
|
||||
|
|
@ -23,12 +28,259 @@ def _require_ilink_auth(
|
|||
auth = (authorization or "").strip()
|
||||
if not auth.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="missing bearer token")
|
||||
token = auth[7:].strip()
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="empty bearer token")
|
||||
expected = str(os.getenv("AIA_ILINK_BOT_TOKEN") or "").strip()
|
||||
if expected and token != expected:
|
||||
raise HTTPException(status_code=401, detail="invalid bearer token")
|
||||
return token
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _normalize_channel(raw: Any) -> str:
|
||||
ch = str(raw or "wechat").strip().lower()
|
||||
return ch if ch else "wechat"
|
||||
|
||||
|
||||
def _resolve_account_id(obj: dict[str, Any]) -> str:
|
||||
for key in ("account_id", "bot_id", "aibotid", "app_id", "agent_id"):
|
||||
val = obj.get(key)
|
||||
if val:
|
||||
return str(val).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_stream_selector(body: dict[str, Any]) -> tuple[str, str]:
|
||||
return _normalize_channel(body.get("channel")), _resolve_account_id(body)
|
||||
|
||||
|
||||
def _extract_text_from_body(body: dict[str, Any]) -> str:
|
||||
direct = body.get("text")
|
||||
if isinstance(direct, str):
|
||||
return direct.strip()
|
||||
content = body.get("content")
|
||||
if isinstance(content, str):
|
||||
return content.strip()
|
||||
if isinstance(content, dict):
|
||||
text = content.get("text")
|
||||
if isinstance(text, str):
|
||||
return text.strip()
|
||||
msg = body.get("msg")
|
||||
if isinstance(msg, dict):
|
||||
text = msg.get("text")
|
||||
if isinstance(text, str):
|
||||
return text.strip()
|
||||
nested = msg.get("content")
|
||||
if isinstance(nested, str):
|
||||
return nested.strip()
|
||||
if isinstance(nested, dict):
|
||||
text2 = nested.get("text")
|
||||
if isinstance(text2, str):
|
||||
return text2.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_inbound_identity(body: dict[str, Any]) -> tuple[str, str]:
|
||||
chat_cands = (
|
||||
body.get("chat_id"),
|
||||
body.get("room_id"),
|
||||
body.get("conversation_id"),
|
||||
body.get("external_chat_id"),
|
||||
body.get("to_wxid"),
|
||||
)
|
||||
user_cands = (
|
||||
body.get("user_id"),
|
||||
body.get("external_user_id"),
|
||||
body.get("from_user"),
|
||||
body.get("from_wxid"),
|
||||
body.get("sender"),
|
||||
body.get("wxid"),
|
||||
)
|
||||
msg = body.get("msg")
|
||||
if isinstance(msg, dict):
|
||||
chat_cands = chat_cands + (
|
||||
msg.get("chat_id"),
|
||||
msg.get("room_id"),
|
||||
msg.get("conversation_id"),
|
||||
msg.get("to_wxid"),
|
||||
)
|
||||
user_cands = user_cands + (
|
||||
msg.get("user_id"),
|
||||
msg.get("external_user_id"),
|
||||
msg.get("from_user"),
|
||||
msg.get("from_wxid"),
|
||||
msg.get("sender"),
|
||||
msg.get("wxid"),
|
||||
)
|
||||
user_id = next((str(v).strip() for v in user_cands if str(v or "").strip()), "")
|
||||
chat_id = next((str(v).strip() for v in chat_cands if str(v or "").strip()), "")
|
||||
if not chat_id:
|
||||
chat_id = user_id
|
||||
return user_id, chat_id
|
||||
|
||||
|
||||
class _IlinkBridge:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._seq = 0
|
||||
self._events: list[dict[str, Any]] = []
|
||||
|
||||
def enqueue_reply(
|
||||
self,
|
||||
*,
|
||||
token: str,
|
||||
channel: str,
|
||||
account_id: str,
|
||||
chat_id: str,
|
||||
text: str,
|
||||
context_token: str | None,
|
||||
) -> None:
|
||||
payload = {
|
||||
"channel": channel,
|
||||
"account_id": account_id,
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"content": {"type": "text", "text": text},
|
||||
# Required by iLink sendmessage protocol; we cache it per reply so the
|
||||
# bridge can recover across restarts without needing in-memory token cache.
|
||||
"context_token": (context_token or "").strip(),
|
||||
"ts": _now_ms(),
|
||||
}
|
||||
with self._lock:
|
||||
self._seq += 1
|
||||
event = {
|
||||
"id": self._seq,
|
||||
"token": token,
|
||||
"payload": payload,
|
||||
}
|
||||
self._events.append(event)
|
||||
if len(self._events) > 2000:
|
||||
self._events = self._events[-1000:]
|
||||
|
||||
def poll(
|
||||
self,
|
||||
*,
|
||||
token: str,
|
||||
cursor: int,
|
||||
channel: str,
|
||||
account_id: str,
|
||||
limit: int = 50,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
out: list[dict[str, Any]] = []
|
||||
next_cursor = max(0, int(cursor or 0))
|
||||
with self._lock:
|
||||
for event in self._events:
|
||||
eid = int(event.get("id") or 0)
|
||||
if eid <= cursor:
|
||||
continue
|
||||
if str(event.get("token") or "") != token:
|
||||
continue
|
||||
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
||||
if channel and str(payload.get("channel") or "") != channel:
|
||||
continue
|
||||
if account_id and str(payload.get("account_id") or "") != account_id:
|
||||
continue
|
||||
next_cursor = max(next_cursor, eid)
|
||||
item = dict(payload)
|
||||
item["id"] = str(eid)
|
||||
item["msg_id"] = str(eid)
|
||||
out.append(item)
|
||||
if len(out) >= max(1, limit):
|
||||
break
|
||||
return out, next_cursor
|
||||
|
||||
|
||||
_BRIDGE = _IlinkBridge()
|
||||
|
||||
|
||||
async def _process_inbound_and_enqueue(
|
||||
*,
|
||||
token: str,
|
||||
channel: str,
|
||||
account_id: str,
|
||||
user_id: str,
|
||||
chat_id: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
def _extract_context_token(in_payload: dict[str, Any]) -> str:
|
||||
meta = in_payload.get("metadata")
|
||||
raw = {}
|
||||
if isinstance(meta, dict):
|
||||
raw = meta.get("raw") if isinstance(meta.get("raw"), dict) else {}
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
|
||||
# 1) Most direct: raw.msg.context_token (when runner forwards the original msg object).
|
||||
msg = raw.get("msg")
|
||||
if isinstance(msg, dict):
|
||||
ctx = msg.get("context_token")
|
||||
if ctx is not None:
|
||||
return str(ctx).strip()
|
||||
|
||||
# 2) Runner also sets raw.metadata.context_token.
|
||||
raw_meta = raw.get("metadata")
|
||||
if isinstance(raw_meta, dict):
|
||||
ctx2 = raw_meta.get("context_token")
|
||||
if ctx2 is not None:
|
||||
return str(ctx2).strip()
|
||||
|
||||
# 3) Some clients may put it at top-level.
|
||||
top_ctx = raw.get("context_token")
|
||||
if top_ctx is not None:
|
||||
return str(top_ctx).strip()
|
||||
|
||||
return ""
|
||||
|
||||
try:
|
||||
out = await asyncio.wait_for(asyncio.to_thread(process_inbound_payload_usecase, payload), timeout=60.0)
|
||||
except asyncio.TimeoutError:
|
||||
ctx_token = _extract_context_token(payload)
|
||||
_BRIDGE.enqueue_reply(
|
||||
token=token,
|
||||
channel=channel,
|
||||
account_id=account_id,
|
||||
chat_id=chat_id or user_id,
|
||||
text="系统繁忙,处理超时,请稍后重试。",
|
||||
context_token=ctx_token,
|
||||
)
|
||||
return
|
||||
except Exception as exc:
|
||||
ctx_token = _extract_context_token(payload)
|
||||
_BRIDGE.enqueue_reply(
|
||||
token=token,
|
||||
channel=channel,
|
||||
account_id=account_id,
|
||||
chat_id=chat_id or user_id,
|
||||
text=f"系统错误:{type(exc).__name__}",
|
||||
context_token=ctx_token,
|
||||
)
|
||||
return
|
||||
|
||||
replies = out.get("replies") if isinstance(out, dict) else []
|
||||
if not isinstance(replies, list):
|
||||
return
|
||||
for item in replies:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
ctx_token = _extract_context_token(payload)
|
||||
reply_text = str(item.get("text") or "").strip()
|
||||
reply_chat_id = str(item.get("chat_id") or chat_id or user_id).strip() or user_id
|
||||
if not reply_text:
|
||||
continue
|
||||
_BRIDGE.enqueue_reply(
|
||||
token=token,
|
||||
channel=channel,
|
||||
account_id=account_id,
|
||||
chat_id=reply_chat_id,
|
||||
text=reply_text,
|
||||
context_token=ctx_token,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ilink/bot/getupdates")
|
||||
async def ilink_getupdates(
|
||||
body: dict[str, Any],
|
||||
|
|
@ -36,12 +288,52 @@ async def ilink_getupdates(
|
|||
authorizationtype: str | None = Header(default=None, alias="AuthorizationType"),
|
||||
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||
) -> dict[str, Any]:
|
||||
_require_ilink_auth(authorization_type=authorizationtype, authorization=authorization)
|
||||
# TODO: implement real long-poll cursor + message queue.
|
||||
# For now, return an empty poll result so the plugin can stay connected.
|
||||
token = _require_ilink_auth(authorization_type=authorizationtype, authorization=authorization)
|
||||
buf = str(body.get("get_updates_buf") or "")
|
||||
try:
|
||||
cursor = int(buf or "0")
|
||||
except Exception:
|
||||
cursor = 0
|
||||
channel, account_id = _resolve_stream_selector(body)
|
||||
limit_raw = body.get("limit")
|
||||
try:
|
||||
limit = max(1, min(int(limit_raw), 100))
|
||||
except Exception:
|
||||
limit = 50
|
||||
timeout_raw = body.get("longpolling_timeout_ms")
|
||||
try:
|
||||
timeout_ms = max(1000, min(int(timeout_raw), 35_000))
|
||||
except Exception:
|
||||
timeout_ms = 35_000
|
||||
deadline = time.time() + (timeout_ms / 1000.0)
|
||||
msgs: list[dict[str, Any]] = []
|
||||
next_cursor = cursor
|
||||
while time.time() < deadline:
|
||||
msgs, next_cursor = _BRIDGE.poll(
|
||||
token=token,
|
||||
cursor=cursor,
|
||||
channel=channel,
|
||||
account_id=account_id,
|
||||
limit=limit,
|
||||
)
|
||||
if msgs:
|
||||
break
|
||||
await asyncio.sleep(0.2)
|
||||
if not msgs:
|
||||
msgs, next_cursor = _BRIDGE.poll(
|
||||
token=token,
|
||||
cursor=cursor,
|
||||
channel=channel,
|
||||
account_id=account_id,
|
||||
limit=limit,
|
||||
)
|
||||
_ = request
|
||||
return {"ret": 0, "msgs": [], "get_updates_buf": buf, "longpolling_timeout_ms": 35000}
|
||||
return {
|
||||
"ret": 0,
|
||||
"msgs": msgs,
|
||||
"get_updates_buf": str(next_cursor if msgs else cursor),
|
||||
"longpolling_timeout_ms": timeout_ms,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/ilink/bot/sendmessage")
|
||||
|
|
@ -50,9 +342,32 @@ async def ilink_sendmessage(
|
|||
authorizationtype: str | None = Header(default=None, alias="AuthorizationType"),
|
||||
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||
) -> dict[str, Any]:
|
||||
_require_ilink_auth(authorization_type=authorizationtype, authorization=authorization)
|
||||
# TODO: map outbound message into our channel sender (when implemented).
|
||||
_ = body
|
||||
token = _require_ilink_auth(authorization_type=authorizationtype, authorization=authorization)
|
||||
channel, account_id = _resolve_stream_selector(body)
|
||||
user_id, chat_id = _extract_inbound_identity(body)
|
||||
text = _extract_text_from_body(body)
|
||||
if not user_id:
|
||||
return {"ret": 400, "errmsg": "missing user_id"}
|
||||
if not account_id:
|
||||
return {"ret": 400, "errmsg": "missing account_id"}
|
||||
payload = {
|
||||
"channel": channel,
|
||||
"account_id": account_id,
|
||||
"user_id": user_id,
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"metadata": {"source": "ilink", "raw": body},
|
||||
}
|
||||
asyncio.create_task(
|
||||
_process_inbound_and_enqueue(
|
||||
token=token,
|
||||
channel=channel,
|
||||
account_id=account_id,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
return {"ret": 0}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue