653 lines
22 KiB
Python
Executable File
653 lines
22 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Hardened WeChat outbound backend for AIOS.
|
|
|
|
Only the dedicated UCC adapter is a supported send backend. OpenClaw channel
|
|
discovery is retained for diagnostics, but generic OpenClaw chat or agent
|
|
delivery is never promoted to WeChat delivery.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import selectors
|
|
import signal
|
|
import subprocess
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
|
|
OPENCLAW_BIN = os.environ.get("AIOS_OPENCLAW_BIN", "openclaw")
|
|
WECHAT_ACCOUNT_ID = os.environ.get("AIOS_WECHAT_ACCOUNT_ID", "")
|
|
WECHAT_CHANNEL = os.environ.get("AIOS_WECHAT_CHANNEL", "")
|
|
SEND_BACKEND = os.environ.get("AIOS_WECHAT_SEND_BACKEND", "")
|
|
UCC_ADAPTER_URL = os.environ.get("AIOS_UCC_WECHAT_ADAPTER_URL", "http://127.0.0.1:18800")
|
|
UCC_ADAPTER_TOKEN = os.environ.get("AIOS_UCC_WECHAT_ADAPTER_TOKEN") or os.environ.get(
|
|
"AIOS_WECHAT_QR_ADAPTER_TOKEN", ""
|
|
)
|
|
UCC_SEND_PATH = os.environ.get("AIOS_UCC_WECHAT_SEND_PATH", "/api/internal/wechat/send")
|
|
_allowed_origins = os.environ.get("AIOS_UCC_WECHAT_ALLOWED_ORIGINS", "").strip()
|
|
UCC_ADAPTER_ALLOWED_ORIGINS = tuple(
|
|
origin.strip() for origin in _allowed_origins.split(",") if origin.strip()
|
|
) or (
|
|
"http://127.0.0.1:18800",
|
|
"http://[::1]:18800",
|
|
)
|
|
|
|
MAX_TARGET_BYTES = 512
|
|
MAX_CONTENT_BYTES = 32 * 1024
|
|
MAX_IDENTIFIER_BYTES = 512
|
|
MAX_METADATA_BYTES = 8 * 1024
|
|
MAX_METADATA_DEPTH = 32
|
|
MAX_METADATA_NODES = 1024
|
|
MAX_RESPONSE_BYTES = 64 * 1024
|
|
MAX_ENDPOINT_BYTES = 256
|
|
_DEFAULT_TIMEOUT_SECONDS = 10
|
|
_MAX_TIMEOUT_SECONDS = 30
|
|
_LEGACY_SEND_PATH = "/internal/channels/wechat/send"
|
|
_SAFE_PATH = re.compile(r"^/[A-Za-z0-9._~!$&'()*+,;=:@%/-]+$")
|
|
_MAX_PATH_DECODE_ROUNDS = 4
|
|
_OPENCLAW_TIMEOUT_RETURN_CODE = 124
|
|
_OPENCLAW_OUTPUT_LIMIT_RETURN_CODE = 125
|
|
_OPENCLAW_SPAWN_ERROR_RETURN_CODE = 126
|
|
|
|
|
|
def _bounded_timeout(value: str) -> int:
|
|
try:
|
|
parsed = int(value)
|
|
except (TypeError, ValueError):
|
|
return _DEFAULT_TIMEOUT_SECONDS
|
|
return min(max(parsed, 1), _MAX_TIMEOUT_SECONDS)
|
|
|
|
|
|
REQUEST_TIMEOUT_SECONDS = _bounded_timeout(os.environ.get("AIOS_UCC_WECHAT_TIMEOUT_SECONDS", "10"))
|
|
|
|
|
|
class BackendResult:
|
|
def __init__(self, ok: bool, backend: str, status: str, data: Dict[str, Any]) -> None:
|
|
self.ok = ok
|
|
self.backend = backend
|
|
self.status = status
|
|
self.data = data
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {"ok": self.ok, "backend": self.backend, "status": self.status, **self.data}
|
|
|
|
|
|
class _BackendProtocolError(Exception):
|
|
"""Internal marker whose details must never cross the module boundary."""
|
|
|
|
|
|
def _openclaw_base_args() -> List[str]:
|
|
"""Return the OpenClaw 2026.6.11-compatible command prefix.
|
|
|
|
Gateway/auth global flags are not supported by the installed CLI and would
|
|
also expose credentials through the process argument vector.
|
|
"""
|
|
|
|
return [OPENCLAW_BIN]
|
|
|
|
|
|
def _terminate_process_tree(process: Any) -> None:
|
|
if os.name == "posix":
|
|
try:
|
|
os.killpg(process.pid, signal.SIGTERM)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
process.wait(timeout=0.5)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
pass
|
|
time.sleep(0.05)
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
process.wait(timeout=0.5)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
pass
|
|
return
|
|
if process.poll() is not None:
|
|
return
|
|
try:
|
|
process.terminate()
|
|
process.wait(timeout=1)
|
|
return
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
pass
|
|
try:
|
|
process.kill()
|
|
process.wait(timeout=1)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
pass
|
|
|
|
|
|
def _run_openclaw(
|
|
args: List[str], timeout: int = 15
|
|
) -> subprocess.CompletedProcess[str]:
|
|
bounded_timeout = min(max(int(timeout), 1), _MAX_TIMEOUT_SECONDS)
|
|
popen_options: Dict[str, Any] = {
|
|
"stdout": subprocess.PIPE,
|
|
"stderr": subprocess.PIPE,
|
|
}
|
|
if os.name == "posix":
|
|
popen_options["start_new_session"] = True
|
|
elif hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
|
|
popen_options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
try:
|
|
process = subprocess.Popen(args, **popen_options)
|
|
except (OSError, ValueError):
|
|
return subprocess.CompletedProcess(args, _OPENCLAW_SPAWN_ERROR_RETURN_CODE, "", "")
|
|
|
|
selector = selectors.DefaultSelector()
|
|
streams = {"stdout": process.stdout, "stderr": process.stderr}
|
|
buffers = {"stdout": bytearray(), "stderr": bytearray()}
|
|
try:
|
|
for name, stream in streams.items():
|
|
if stream is not None:
|
|
selector.register(stream, selectors.EVENT_READ, name)
|
|
except (OSError, ValueError):
|
|
selector.close()
|
|
_terminate_process_tree(process)
|
|
for stream in streams.values():
|
|
if stream is not None:
|
|
stream.close()
|
|
return subprocess.CompletedProcess(args, _OPENCLAW_SPAWN_ERROR_RETURN_CODE, "", "")
|
|
|
|
deadline = time.monotonic() + bounded_timeout
|
|
total_bytes = 0
|
|
return_code: Optional[int] = None
|
|
try:
|
|
while selector.get_map():
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
return_code = _OPENCLAW_TIMEOUT_RETURN_CODE
|
|
break
|
|
events = selector.select(min(remaining, 0.1))
|
|
for key, _mask in events:
|
|
allowance = MAX_RESPONSE_BYTES - total_bytes
|
|
try:
|
|
chunk = os.read(key.fileobj.fileno(), min(8192, allowance + 1))
|
|
except OSError:
|
|
return_code = _OPENCLAW_SPAWN_ERROR_RETURN_CODE
|
|
break
|
|
if not chunk:
|
|
selector.unregister(key.fileobj)
|
|
key.fileobj.close()
|
|
continue
|
|
if len(chunk) > allowance:
|
|
return_code = _OPENCLAW_OUTPUT_LIMIT_RETURN_CODE
|
|
break
|
|
buffers[key.data].extend(chunk)
|
|
total_bytes += len(chunk)
|
|
if return_code is not None:
|
|
break
|
|
finally:
|
|
selector.close()
|
|
|
|
if return_code is not None:
|
|
_terminate_process_tree(process)
|
|
for stream in streams.values():
|
|
if stream is not None and not stream.closed:
|
|
stream.close()
|
|
return subprocess.CompletedProcess(args, return_code, "", "")
|
|
try:
|
|
completed_return_code = process.wait(timeout=1)
|
|
except subprocess.TimeoutExpired:
|
|
_terminate_process_tree(process)
|
|
return subprocess.CompletedProcess(args, _OPENCLAW_TIMEOUT_RETURN_CODE, "", "")
|
|
stdout = buffers["stdout"].decode("utf-8", errors="replace")
|
|
stderr = buffers["stderr"].decode("utf-8", errors="replace")
|
|
return subprocess.CompletedProcess(args, completed_return_code, stdout, stderr)
|
|
|
|
|
|
def _json_dict(text: str) -> Dict[str, Any]:
|
|
try:
|
|
value = json.loads(text or "{}")
|
|
except (RecursionError, TypeError, ValueError, json.JSONDecodeError):
|
|
return {}
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
def discover_openclaw_wechat_channel() -> Optional[str]:
|
|
"""Discover an explicitly typed WeChat/Weixin channel for diagnostics."""
|
|
|
|
if WECHAT_CHANNEL:
|
|
return WECHAT_CHANNEL
|
|
try:
|
|
proc = _run_openclaw(_openclaw_base_args() + ["channels", "list", "--json"], timeout=15)
|
|
except (OSError, subprocess.SubprocessError, ValueError):
|
|
return None
|
|
if proc.returncode != 0:
|
|
return None
|
|
data = _json_dict(proc.stdout[:MAX_RESPONSE_BYTES])
|
|
candidates: List[str] = []
|
|
|
|
def walk(value: Any) -> None:
|
|
if isinstance(value, dict):
|
|
name = value.get("name") or value.get("channel") or value.get("id")
|
|
kind = value.get("type") or value.get("kind")
|
|
if isinstance(name, str) and isinstance(kind, str):
|
|
label = f"{name} {kind}".lower()
|
|
if "wechat" in label or "weixin" in label:
|
|
candidates.append(name)
|
|
for child in value.values():
|
|
walk(child)
|
|
elif isinstance(value, list):
|
|
for child in value:
|
|
walk(child)
|
|
|
|
walk(data)
|
|
return candidates[0] if candidates else None
|
|
|
|
|
|
def _auth_headers() -> Dict[str, str]:
|
|
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
if UCC_ADAPTER_TOKEN:
|
|
headers["Authorization"] = "Bearer " + UCC_ADAPTER_TOKEN
|
|
return headers
|
|
|
|
|
|
def _safe_path(value: Any) -> Optional[str]:
|
|
if not isinstance(value, str):
|
|
return None
|
|
path = value if value.startswith("/") else "/" + value
|
|
try:
|
|
encoded_path = path.encode("utf-8")
|
|
except UnicodeEncodeError:
|
|
return None
|
|
if len(encoded_path) > MAX_ENDPOINT_BYTES:
|
|
return None
|
|
if not _SAFE_PATH.fullmatch(path):
|
|
return None
|
|
decoded = path
|
|
for _ in range(_MAX_PATH_DECODE_ROUNDS):
|
|
try:
|
|
next_decoded = urllib.parse.unquote_to_bytes(decoded).decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
return None
|
|
if next_decoded == decoded:
|
|
break
|
|
decoded = next_decoded
|
|
if "%" in decoded or "\\" in decoded or "\x00" in decoded:
|
|
return None
|
|
if not _SAFE_PATH.fullmatch(decoded):
|
|
return None
|
|
if any(segment in {".", ".."} for segment in decoded.split("/")):
|
|
return None
|
|
return path
|
|
|
|
|
|
def _unique_paths(paths: List[str]) -> List[str]:
|
|
out: List[str] = []
|
|
for candidate in paths:
|
|
path = _safe_path(candidate)
|
|
if path and path not in out:
|
|
out.append(path)
|
|
if len(out) == 2:
|
|
break
|
|
return out
|
|
|
|
|
|
def _ucc_send_paths() -> List[str]:
|
|
canonical = _safe_path(UCC_SEND_PATH) or "/api/internal/wechat/send"
|
|
return _unique_paths([canonical, _LEGACY_SEND_PATH])
|
|
|
|
|
|
def _canonical_origin(value: Any) -> Optional[str]:
|
|
if not isinstance(value, str) or not value or len(value) > 2048:
|
|
return None
|
|
if "\\" in value or any(ord(character) < 32 for character in value):
|
|
return None
|
|
try:
|
|
parsed = urllib.parse.urlsplit(value)
|
|
port = parsed.port
|
|
except (UnicodeError, ValueError):
|
|
return None
|
|
scheme = parsed.scheme.lower()
|
|
if scheme not in {"http", "https"} or not parsed.hostname:
|
|
return None
|
|
if parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in {"", "/"}:
|
|
return None
|
|
try:
|
|
host = parsed.hostname.rstrip(".").encode("idna").decode("ascii").lower()
|
|
except UnicodeError:
|
|
return None
|
|
if not host:
|
|
return None
|
|
if port is None:
|
|
port = 80 if scheme == "http" else 443
|
|
rendered_host = f"[{host}]" if ":" in host else host
|
|
return f"{scheme}://{rendered_host}:{port}"
|
|
|
|
|
|
def _adapter_base_url() -> Optional[str]:
|
|
origin = _canonical_origin(UCC_ADAPTER_URL)
|
|
allowed = {
|
|
canonical
|
|
for canonical in (_canonical_origin(value) for value in UCC_ADAPTER_ALLOWED_ORIGINS)
|
|
if canonical
|
|
}
|
|
return origin if origin and origin in allowed else None
|
|
|
|
|
|
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(
|
|
self,
|
|
req: Any,
|
|
fp: Any,
|
|
code: int,
|
|
msg: str,
|
|
headers: Any,
|
|
newurl: str,
|
|
) -> None:
|
|
del req, fp, code, msg, headers, newurl
|
|
return None
|
|
|
|
|
|
def _open_no_redirect(request: urllib.request.Request, timeout: int) -> Any:
|
|
opener = urllib.request.build_opener(_NoRedirectHandler())
|
|
return opener.open(request, timeout=timeout)
|
|
|
|
|
|
def ucc_adapter_alive() -> bool:
|
|
base_url = _adapter_base_url()
|
|
if not base_url:
|
|
return False
|
|
request = urllib.request.Request(
|
|
base_url + "/health", headers=_auth_headers(), method="GET"
|
|
)
|
|
try:
|
|
with _open_no_redirect(request, timeout=min(5, REQUEST_TIMEOUT_SECONDS)) as response:
|
|
return 200 <= int(response.status) < 300
|
|
except (OSError, ValueError, urllib.error.URLError):
|
|
return False
|
|
|
|
|
|
def select_backend() -> Dict[str, Any]:
|
|
explicit = SEND_BACKEND.strip().lower()
|
|
if explicit:
|
|
if explicit == "ucc-adapter":
|
|
return {"backend": "ucc-adapter", "reason": "explicit"}
|
|
return {"backend": "unavailable", "reason": "unsupported_backend"}
|
|
if ucc_adapter_alive():
|
|
return {"backend": "ucc-adapter", "reason": "ucc_adapter_alive"}
|
|
return {"backend": "unavailable", "reason": "ucc_adapter_unavailable"}
|
|
|
|
|
|
def _post_ucc(path: str, payload: Dict[str, Any]) -> Tuple[int, Dict[str, Any]]:
|
|
base_url = _adapter_base_url()
|
|
safe_path = _safe_path(path)
|
|
if not base_url or not safe_path:
|
|
raise _BackendProtocolError("invalid adapter endpoint")
|
|
try:
|
|
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
except (TypeError, ValueError) as exc:
|
|
raise _BackendProtocolError("invalid request payload") from exc
|
|
request = urllib.request.Request(
|
|
base_url + safe_path,
|
|
data=body,
|
|
headers=_auth_headers(),
|
|
method="POST",
|
|
)
|
|
with _open_no_redirect(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
|
|
raw = response.read(MAX_RESPONSE_BYTES + 1)
|
|
if len(raw) > MAX_RESPONSE_BYTES:
|
|
raise _BackendProtocolError("adapter response too large")
|
|
try:
|
|
text = raw.decode("utf-8")
|
|
except UnicodeDecodeError as exc:
|
|
raise _BackendProtocolError("adapter response encoding invalid") from exc
|
|
data = _json_dict(text)
|
|
if not data:
|
|
raise _BackendProtocolError("adapter response invalid")
|
|
return int(response.status), data
|
|
|
|
|
|
def _ucc_response_sent(data: Dict[str, Any]) -> bool:
|
|
return isinstance(data, dict) and data.get("ok") is True and data.get("status") == "sent"
|
|
|
|
|
|
def _ucc_result_is_ok(data: Dict[str, Any], dry_run: bool = False) -> bool:
|
|
expected_status = "dry_run" if dry_run else "sent"
|
|
return isinstance(data, dict) and data.get("ok") is True and data.get("status") == expected_status
|
|
|
|
|
|
def _utf8_size(value: str) -> Optional[int]:
|
|
try:
|
|
return len(value.encode("utf-8"))
|
|
except UnicodeEncodeError:
|
|
return None
|
|
|
|
|
|
def _valid_identifier(value: Any, required: bool = False) -> bool:
|
|
if not isinstance(value, str):
|
|
return False
|
|
if required and not value.strip():
|
|
return False
|
|
if not value:
|
|
return not required
|
|
if len(value) > MAX_IDENTIFIER_BYTES:
|
|
return False
|
|
size = _utf8_size(value)
|
|
return size is not None and size <= MAX_IDENTIFIER_BYTES and not any(
|
|
ord(character) < 32 or ord(character) == 127 for character in value
|
|
)
|
|
|
|
|
|
def _validate_metadata(metadata: Any) -> Optional[str]:
|
|
if not isinstance(metadata, dict):
|
|
return "invalid_input"
|
|
nodes = 0
|
|
stack: List[Tuple[Any, int]] = [(metadata, 0)]
|
|
while stack:
|
|
value, depth = stack.pop()
|
|
nodes += 1
|
|
if depth > MAX_METADATA_DEPTH or nodes > MAX_METADATA_NODES:
|
|
return "input_too_large"
|
|
if isinstance(value, dict):
|
|
if len(value) + nodes + len(stack) > MAX_METADATA_NODES:
|
|
return "input_too_large"
|
|
for key, child in value.items():
|
|
if (
|
|
not isinstance(key, str)
|
|
or len(key) > MAX_METADATA_BYTES
|
|
or _utf8_size(key) is None
|
|
):
|
|
return "invalid_input"
|
|
stack.append((child, depth + 1))
|
|
elif isinstance(value, list):
|
|
if len(value) + nodes + len(stack) > MAX_METADATA_NODES:
|
|
return "input_too_large"
|
|
stack.extend((child, depth + 1) for child in value)
|
|
elif isinstance(value, str):
|
|
if len(value) > MAX_METADATA_BYTES:
|
|
return "input_too_large"
|
|
if _utf8_size(value) is None:
|
|
return "invalid_input"
|
|
elif value is not None and not isinstance(value, (bool, int, float)):
|
|
return "invalid_input"
|
|
try:
|
|
encoded = json.dumps(
|
|
metadata,
|
|
ensure_ascii=False,
|
|
allow_nan=False,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
except (RecursionError, TypeError, UnicodeEncodeError, ValueError):
|
|
return "invalid_input"
|
|
return "input_too_large" if len(encoded) > MAX_METADATA_BYTES else None
|
|
|
|
|
|
def _validate_payload(
|
|
target: Any,
|
|
content: Any,
|
|
account_id: Any,
|
|
message_id: Any,
|
|
metadata: Any,
|
|
dry_run: Any,
|
|
) -> Optional[str]:
|
|
if not isinstance(target, str) or not isinstance(content, str):
|
|
return "invalid_input"
|
|
if not target.strip() or not content.strip() or not isinstance(dry_run, bool):
|
|
return "invalid_input"
|
|
if any(ord(character) < 32 or ord(character) == 127 for character in target):
|
|
return "invalid_input"
|
|
if "\x00" in content or not _valid_identifier(account_id) or not _valid_identifier(message_id):
|
|
return "invalid_input"
|
|
if len(target) > MAX_TARGET_BYTES or len(content) > MAX_CONTENT_BYTES:
|
|
return "input_too_large"
|
|
target_size = _utf8_size(target)
|
|
content_size = _utf8_size(content)
|
|
if target_size is None or content_size is None:
|
|
return "invalid_input"
|
|
if target_size > MAX_TARGET_BYTES or content_size > MAX_CONTENT_BYTES:
|
|
return "input_too_large"
|
|
return _validate_metadata(metadata)
|
|
|
|
|
|
def _safe_provider_message_id(value: Any) -> Optional[str]:
|
|
if not isinstance(value, str) or not value:
|
|
return None
|
|
if len(value) > MAX_IDENTIFIER_BYTES:
|
|
return None
|
|
size = _utf8_size(value)
|
|
if size is None or size > MAX_IDENTIFIER_BYTES:
|
|
return None
|
|
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
|
return None
|
|
return value
|
|
|
|
|
|
def send_ucc_adapter(
|
|
target: str,
|
|
content: str,
|
|
account_id: str = "",
|
|
message_id: str = "",
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
dry_run: bool = False,
|
|
) -> BackendResult:
|
|
effective_account = account_id or WECHAT_ACCOUNT_ID
|
|
effective_metadata: Any = {} if metadata is None else metadata
|
|
error_code = _validate_payload(
|
|
target,
|
|
content,
|
|
effective_account,
|
|
message_id,
|
|
effective_metadata,
|
|
dry_run,
|
|
)
|
|
if error_code:
|
|
return BackendResult(False, "ucc-adapter", "failed", {"code": error_code})
|
|
|
|
payload = {
|
|
"target": target,
|
|
"content": content,
|
|
"account_id": effective_account,
|
|
"message_id": message_id,
|
|
"metadata": effective_metadata,
|
|
"dry_run": dry_run,
|
|
}
|
|
last_endpoint: Optional[str] = None
|
|
for path in _ucc_send_paths():
|
|
last_endpoint = path
|
|
try:
|
|
http_status, data = _post_ucc(path, payload)
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code in {404, 405}:
|
|
continue
|
|
return BackendResult(
|
|
False,
|
|
"ucc-adapter",
|
|
"failed",
|
|
{"code": "ucc_adapter_http_error", "http_status": int(exc.code), "endpoint": path},
|
|
)
|
|
except (OSError, ValueError, urllib.error.URLError, _BackendProtocolError):
|
|
return BackendResult(
|
|
False,
|
|
"ucc-adapter",
|
|
"failed",
|
|
{"code": "ucc_adapter_unavailable", "endpoint": path},
|
|
)
|
|
except Exception:
|
|
# Fail closed without returning exception text, type, or chained cause.
|
|
return BackendResult(
|
|
False,
|
|
"ucc-adapter",
|
|
"failed",
|
|
{"code": "ucc_adapter_unavailable", "endpoint": path},
|
|
)
|
|
|
|
if not (200 <= http_status < 300):
|
|
return BackendResult(
|
|
False,
|
|
"ucc-adapter",
|
|
"failed",
|
|
{"code": "ucc_adapter_http_error", "http_status": http_status, "endpoint": path},
|
|
)
|
|
if not _ucc_result_is_ok(data, dry_run):
|
|
return BackendResult(
|
|
False,
|
|
"ucc-adapter",
|
|
"failed",
|
|
{"code": "ucc_adapter_rejected", "http_status": http_status, "endpoint": path},
|
|
)
|
|
confirmed_status = "dry_run" if dry_run else "sent"
|
|
result_data: Dict[str, Any] = {
|
|
"code": confirmed_status,
|
|
"http_status": http_status,
|
|
"endpoint": path,
|
|
}
|
|
provider_message_id = _safe_provider_message_id(data.get("provider_message_id"))
|
|
if provider_message_id:
|
|
result_data["provider_message_id"] = provider_message_id
|
|
return BackendResult(True, "ucc-adapter", confirmed_status, result_data)
|
|
|
|
data: Dict[str, Any] = {"code": "ucc_adapter_unavailable"}
|
|
if last_endpoint:
|
|
data["endpoint"] = last_endpoint
|
|
return BackendResult(False, "ucc-adapter", "failed", data)
|
|
|
|
|
|
def send_openclaw_channel(
|
|
target: str,
|
|
content: str,
|
|
account_id: str = "",
|
|
message_id: str = "",
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
dry_run: bool = False,
|
|
) -> BackendResult:
|
|
del target, content, account_id, message_id, metadata, dry_run
|
|
return BackendResult(
|
|
False,
|
|
"openclaw-channel",
|
|
"failed",
|
|
{"code": "unsupported_backend"},
|
|
)
|
|
|
|
|
|
def send_message(
|
|
target: str,
|
|
content: str,
|
|
account_id: str = "",
|
|
message_id: str = "",
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
dry_run: bool = False,
|
|
) -> Dict[str, Any]:
|
|
selected = select_backend()
|
|
if selected.get("backend") == "ucc-adapter":
|
|
return send_ucc_adapter(
|
|
target, content, account_id, message_id, metadata, dry_run
|
|
).to_dict()
|
|
code = "unsupported_backend" if selected.get("reason") == "unsupported_backend" else "wechat_outbound_backend_unavailable"
|
|
return BackendResult(False, "unavailable", "failed", {"code": code}).to_dict()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print(json.dumps(select_backend(), ensure_ascii=False, separators=(",", ":")))
|