mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-10 23:30:53 +08:00
重构仓库目录为统一的 runtime 分层并清理历史 openclaw 残留。
本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。 Made-with: Cursor
This commit is contained in:
parent
ba3836f00f
commit
4a23b715a2
498 changed files with 2760 additions and 2200 deletions
4
interfaces/channels/__init__.py
Normal file
4
interfaces/channels/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from __future__ import annotations
|
||||
|
||||
__all__ = []
|
||||
|
||||
46
interfaces/channels/base.py
Normal file
46
interfaces/channels/base.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InboundMessage:
|
||||
channel: str
|
||||
external_user_id: str
|
||||
external_chat_id: str
|
||||
text: str
|
||||
is_group: bool = False
|
||||
mentions: list[str] = field(default_factory=list)
|
||||
attachments: list[dict[str, Any]] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutboundMessage:
|
||||
external_chat_id: str
|
||||
text: str
|
||||
attachments: list[dict[str, Any]] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class ChannelAdapter(Protocol):
|
||||
channel_name: str
|
||||
|
||||
def parse_inbound(self, payload: dict[str, Any]) -> InboundMessage:
|
||||
raise NotImplementedError
|
||||
|
||||
def format_outbound(self, msg: OutboundMessage) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def safe_json_loads(raw: str) -> dict[str, Any]:
|
||||
try:
|
||||
obj = json.loads(raw or "")
|
||||
return obj if isinstance(obj, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
__all__ = ["InboundMessage", "OutboundMessage", "ChannelAdapter", "safe_json_loads"]
|
||||
6
interfaces/channels/wecom/__init__.py
Normal file
6
interfaces/channels/wecom/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .wecom_bridge import WeComAdapter
|
||||
|
||||
__all__ = ["WeComAdapter"]
|
||||
|
||||
737
interfaces/channels/wecom/longconn_runner.py
Normal file
737
interfaces/channels/wecom/longconn_runner.py
Normal file
|
|
@ -0,0 +1,737 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
import uuid
|
||||
import queue
|
||||
import threading
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from oclaw.runtime.application.gateway import process_inbound_payload_usecase
|
||||
from oclaw.interfaces.channels.wecom.normalize import normalize_wecom_event, normalize_wecom_event_batch
|
||||
from oclaw.platform.config.paths import db_path
|
||||
from oclaw.platform.integrations.wecom_client import WeComClient
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
def _safe_json(obj: Any) -> str:
|
||||
try:
|
||||
return json.dumps(obj, ensure_ascii=True, default=str)
|
||||
except Exception:
|
||||
return str(obj)
|
||||
|
||||
|
||||
def _account_id_from_payload(payload: dict[str, Any], store: SqliteStore) -> str:
|
||||
meta = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
for key in ("aibotid", "bot_id", "account_id"):
|
||||
v = meta.get(key)
|
||||
if v:
|
||||
return str(v).strip()
|
||||
for key in ("aibotid", "bot_id", "account_id"):
|
||||
v = payload.get(key)
|
||||
if v:
|
||||
return str(v).strip()
|
||||
raw = payload.get("raw")
|
||||
if isinstance(raw, dict):
|
||||
for key in ("aibotid", "bot_id", "account_id"):
|
||||
v = raw.get(key)
|
||||
if v:
|
||||
return str(v).strip()
|
||||
return str(store.get_setting("wecom_bot_id") or "").strip()
|
||||
|
||||
|
||||
def _sanitize_outbound_text(text: str, *, max_chars: int = 1800) -> str:
|
||||
s = str(text or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
# Decode escaped newlines so WeCom shows real line breaks.
|
||||
s = s.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\N", "\n")
|
||||
# Remove hidden reasoning block before delivering to end users.
|
||||
lower = s.lower()
|
||||
start_tag = "<redacted_thinking>"
|
||||
end_tag = "</redacted_thinking>"
|
||||
if start_tag in lower and end_tag in lower:
|
||||
start = lower.find(start_tag)
|
||||
end = lower.find(end_tag, start)
|
||||
if end >= 0:
|
||||
s = (s[:start] + s[end + len(end_tag) :]).strip()
|
||||
if s.startswith(start_tag):
|
||||
s = s[len(start_tag) :].strip()
|
||||
# Collapse excessive blank lines for better mobile display.
|
||||
while "\n\n\n" in s:
|
||||
s = s.replace("\n\n\n", "\n\n")
|
||||
if len(s) > max_chars:
|
||||
s = s[:max_chars].rstrip() + "\n\n(回复过长,已截断)"
|
||||
return s
|
||||
|
||||
|
||||
class _SingleInstanceLock:
|
||||
def __init__(self, lock_path: Path) -> None:
|
||||
self.lock_path = lock_path
|
||||
self.fh: Any | None = None
|
||||
|
||||
def acquire(self) -> None:
|
||||
self.lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.fh = open(self.lock_path, "a+b")
|
||||
self.fh.seek(0)
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt # type: ignore
|
||||
|
||||
msvcrt.locking(self.fh.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl # type: ignore
|
||||
|
||||
fcntl.flock(self.fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"wecom_longconn_already_running: {self.lock_path}") from exc
|
||||
|
||||
def release(self) -> None:
|
||||
if self.fh is None:
|
||||
return
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt # type: ignore
|
||||
|
||||
self.fh.seek(0)
|
||||
msvcrt.locking(self.fh.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
import fcntl # type: ignore
|
||||
|
||||
fcntl.flock(self.fh.fileno(), fcntl.LOCK_UN)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.fh = None
|
||||
|
||||
|
||||
def _http_get_json(url: str, timeout: float = 15.0) -> dict[str, Any]:
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
obj = json.loads(raw or "{}")
|
||||
return obj if isinstance(obj, dict) else {}
|
||||
|
||||
|
||||
def _http_post_json(url: str, payload: dict[str, Any], timeout: float = 10.0) -> dict[str, Any]:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={"content-type": "application/json", "accept": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
obj = json.loads(raw or "{}")
|
||||
except Exception:
|
||||
return {"ok": True, "raw": raw}
|
||||
return obj if isinstance(obj, dict) else {"ok": True, "raw": raw}
|
||||
|
||||
|
||||
def _retry_count() -> int:
|
||||
raw = str(os.getenv("WECOM_LONGCONN_SEND_RETRY") or "2").strip()
|
||||
return max(1, min(int(raw) if raw.isdigit() else 2, 5))
|
||||
|
||||
|
||||
def _load_mock_events() -> list[dict[str, Any]]:
|
||||
seed = str(os.getenv("WECOM_LONGCONN_MOCK_TEXT") or "帮助").strip()
|
||||
return [
|
||||
{
|
||||
"user_id": "u_mock_001",
|
||||
"chat_id": "u_mock_001",
|
||||
"text": seed,
|
||||
"is_group": False,
|
||||
"msgid": f"mock-{int(time.time())}",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _load_events_once(mode: str) -> list[dict[str, Any]]:
|
||||
if mode == "mock":
|
||||
return _load_mock_events()
|
||||
if mode == "pull":
|
||||
url = str(os.getenv("WECOM_LONGCONN_PULL_URL") or "").strip()
|
||||
if not url:
|
||||
raise RuntimeError("missing WECOM_LONGCONN_PULL_URL when mode=pull")
|
||||
obj = _http_get_json(url)
|
||||
return normalize_wecom_event_batch(obj)
|
||||
raise RuntimeError(f"unsupported WECOM_LONGCONN_MODE: {mode}")
|
||||
|
||||
|
||||
def _run_ws_forever(*, sender: WeComClient, deliver_outbound: bool, use_response_url: bool) -> int:
|
||||
try:
|
||||
import websocket # type: ignore
|
||||
except Exception as exc:
|
||||
raise RuntimeError("websocket-client not installed; run: pip install websocket-client") from exc
|
||||
bot_id, bot_secret = sender.get_bot_credentials()
|
||||
ws_url = str(os.getenv("WECOM_LONGCONN_WS_URL") or "wss://openws.work.weixin.qq.com").strip()
|
||||
seen_ids: deque[str] = deque(maxlen=2000)
|
||||
seen_set: set[str] = set()
|
||||
print(f"[wecom-longconn] websocket connecting url={ws_url} bot={bot_id}")
|
||||
store = sender.store
|
||||
workers_raw = (
|
||||
str(store.get_setting("AIA_WECOM_LONGCONN_WORKERS") or "").strip()
|
||||
or str(store.get_setting("WECOM_LONGCONN_WORKERS") or "").strip()
|
||||
or str(os.getenv("AIA_WECOM_LONGCONN_WORKERS") or "").strip()
|
||||
or str(os.getenv("WECOM_LONGCONN_WORKERS") or "").strip()
|
||||
)
|
||||
workers = 2
|
||||
if workers_raw.isdigit():
|
||||
workers = max(1, min(int(workers_raw), 8))
|
||||
in_max_raw = (
|
||||
str(store.get_setting("AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
|
||||
or str(store.get_setting("WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
|
||||
or str(os.getenv("AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
|
||||
or str(os.getenv("WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
|
||||
)
|
||||
in_q_max = 200
|
||||
if in_max_raw.isdigit():
|
||||
in_q_max = max(20, min(int(in_max_raw), 5000))
|
||||
inbound_q: "queue.Queue[tuple[dict[str, Any], dict[str, Any]]]" = queue.Queue(maxsize=in_q_max)
|
||||
outbound_q: "queue.Queue[dict[str, Any]]" = queue.Queue()
|
||||
ws_ref: dict[str, Any] = {"ws": None}
|
||||
stop_sender = threading.Event()
|
||||
|
||||
def _drain_outbound_queue(*, ws: Any) -> None:
|
||||
while True:
|
||||
try:
|
||||
ob = outbound_q.get_nowait()
|
||||
except Exception:
|
||||
break
|
||||
try:
|
||||
if not deliver_outbound:
|
||||
print("[wecom-longconn] outbound_skipped", _safe_json(ob))
|
||||
continue
|
||||
text = str(ob.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
response_url = str(ob.get("response_url") or "").strip()
|
||||
req_id = str(ob.get("callback_req_id") or uuid.uuid4().hex)
|
||||
rsp = {
|
||||
"cmd": "aibot_respond_msg",
|
||||
"headers": {"req_id": req_id},
|
||||
"body": {"msgtype": "markdown", "markdown": {"content": text}},
|
||||
}
|
||||
sent = False
|
||||
if use_response_url and response_url:
|
||||
try:
|
||||
cb_payload = {"msgtype": "text", "text": {"content": text}}
|
||||
cb_res: dict[str, Any] = {}
|
||||
cb_err = -1
|
||||
for _i in range(_retry_count()):
|
||||
cb_res = _http_post_json(response_url, cb_payload, timeout=12)
|
||||
cb_err = (
|
||||
int(cb_res.get("errcode"))
|
||||
if isinstance(cb_res, dict) and str(cb_res.get("errcode", "")).strip() != ""
|
||||
else 0
|
||||
)
|
||||
if cb_err == 0:
|
||||
break
|
||||
if cb_err == 0:
|
||||
sent = True
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_mode", "response_url")
|
||||
store.set_setting("wecom_last_outbound_error", "")
|
||||
except Exception:
|
||||
pass
|
||||
print("[wecom-longconn] outbound_sent_response_url", _safe_json({"url": response_url, "res": cb_res}))
|
||||
else:
|
||||
try:
|
||||
store.set_setting(
|
||||
"wecom_last_outbound_error",
|
||||
f"response_url_errcode={cb_err}: {json.dumps(cb_res, ensure_ascii=False)}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
print(
|
||||
"[wecom-longconn] response_url_send_not_ok_fallback_ws",
|
||||
_safe_json({"url": response_url, "res": cb_res}),
|
||||
)
|
||||
except Exception as exc:
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_error", f"response_url:{type(exc).__name__}: {exc}")
|
||||
except Exception:
|
||||
pass
|
||||
print(f"[wecom-longconn] response_url_send_error: {type(exc).__name__}: {exc}")
|
||||
if not sent:
|
||||
ws_ok = False
|
||||
ws_err = ""
|
||||
for _i in range(_retry_count()):
|
||||
try:
|
||||
ws.send(json.dumps(rsp, ensure_ascii=False))
|
||||
ws_ok = True
|
||||
ws_err = ""
|
||||
break
|
||||
except Exception as exc:
|
||||
ws_err = f"{type(exc).__name__}: {exc}"
|
||||
time.sleep(0.15)
|
||||
if ws_ok:
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_mode", "ws")
|
||||
store.set_setting("wecom_last_outbound_error", "")
|
||||
except Exception:
|
||||
pass
|
||||
print("[wecom-longconn] outbound_sent_ws", _safe_json(rsp))
|
||||
else:
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_error", f"ws_send_failed:{ws_err}")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
outbound_q.task_done()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _sender_loop() -> None:
|
||||
while not stop_sender.is_set():
|
||||
try:
|
||||
# Block briefly to react immediately after worker enqueues replies.
|
||||
ob = outbound_q.get(timeout=0.3)
|
||||
except queue.Empty:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
ws_obj = ws_ref.get("ws")
|
||||
if ws_obj is None:
|
||||
# websocket reconnecting; put back and retry soon.
|
||||
outbound_q.put(ob)
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
if not deliver_outbound:
|
||||
print("[wecom-longconn] outbound_skipped", _safe_json(ob))
|
||||
continue
|
||||
text = str(ob.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
response_url = str(ob.get("response_url") or "").strip()
|
||||
req_id = str(ob.get("callback_req_id") or uuid.uuid4().hex)
|
||||
rsp = {
|
||||
"cmd": "aibot_respond_msg",
|
||||
"headers": {"req_id": req_id},
|
||||
"body": {"msgtype": "markdown", "markdown": {"content": text}},
|
||||
}
|
||||
sent = False
|
||||
if use_response_url and response_url:
|
||||
try:
|
||||
cb_payload = {"msgtype": "text", "text": {"content": text}}
|
||||
cb_res: dict[str, Any] = {}
|
||||
cb_err = -1
|
||||
for _i in range(_retry_count()):
|
||||
cb_res = _http_post_json(response_url, cb_payload, timeout=12)
|
||||
cb_err = (
|
||||
int(cb_res.get("errcode"))
|
||||
if isinstance(cb_res, dict) and str(cb_res.get("errcode", "")).strip() != ""
|
||||
else 0
|
||||
)
|
||||
if cb_err == 0:
|
||||
break
|
||||
if cb_err == 0:
|
||||
sent = True
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_mode", "response_url")
|
||||
store.set_setting("wecom_last_outbound_error", "")
|
||||
except Exception:
|
||||
pass
|
||||
print("[wecom-longconn] outbound_sent_response_url", _safe_json({"url": response_url, "res": cb_res}))
|
||||
else:
|
||||
try:
|
||||
store.set_setting(
|
||||
"wecom_last_outbound_error",
|
||||
f"response_url_errcode={cb_err}: {json.dumps(cb_res, ensure_ascii=False)}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
print("[wecom-longconn] response_url_send_not_ok_fallback_ws", _safe_json({"url": response_url, "res": cb_res}))
|
||||
except Exception as exc:
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_error", f"response_url:{type(exc).__name__}: {exc}")
|
||||
except Exception:
|
||||
pass
|
||||
print(f"[wecom-longconn] response_url_send_error: {type(exc).__name__}: {exc}")
|
||||
if not sent:
|
||||
ws_ok = False
|
||||
ws_err = ""
|
||||
for _i in range(_retry_count()):
|
||||
try:
|
||||
ws_obj.send(json.dumps(rsp, ensure_ascii=False))
|
||||
ws_ok = True
|
||||
ws_err = ""
|
||||
break
|
||||
except Exception as exc:
|
||||
ws_err = f"{type(exc).__name__}: {exc}"
|
||||
time.sleep(0.15)
|
||||
if ws_ok:
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_mode", "ws")
|
||||
store.set_setting("wecom_last_outbound_error", "")
|
||||
except Exception:
|
||||
pass
|
||||
print("[wecom-longconn] outbound_sent_ws", _safe_json(rsp))
|
||||
else:
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_error", f"ws_send_failed:{ws_err}")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
outbound_q.task_done()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _worker_loop(idx: int) -> None:
|
||||
while True:
|
||||
payload, meta2 = inbound_q.get()
|
||||
try:
|
||||
out = process_inbound_payload_usecase(payload)
|
||||
replies = out.get("replies") if isinstance(out, dict) else []
|
||||
if not isinstance(replies, list):
|
||||
replies = []
|
||||
for rep in replies:
|
||||
if not isinstance(rep, dict):
|
||||
continue
|
||||
text = str(rep.get("text") or "").strip()
|
||||
text = _sanitize_outbound_text(text)
|
||||
if not text:
|
||||
continue
|
||||
outbound_q.put(
|
||||
{
|
||||
"callback_req_id": str(meta2.get("callback_req_id") or ""),
|
||||
"response_url": str(meta2.get("response_url") or ""),
|
||||
"text": text,
|
||||
"raw_rep": rep,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
outbound_q.put(
|
||||
{
|
||||
"callback_req_id": str(meta2.get("callback_req_id") or ""),
|
||||
"response_url": str(meta2.get("response_url") or ""),
|
||||
"text": f"[wecom-longconn] worker_error: {type(exc).__name__}: {exc}",
|
||||
"raw_rep": {},
|
||||
}
|
||||
)
|
||||
finally:
|
||||
inbound_q.task_done()
|
||||
|
||||
for i in range(workers):
|
||||
threading.Thread(target=_worker_loop, args=(i,), name=f"wecom_worker_{i}", daemon=True).start()
|
||||
threading.Thread(target=_sender_loop, name="wecom_sender", daemon=True).start()
|
||||
|
||||
while True:
|
||||
ws = None
|
||||
try:
|
||||
ws = websocket.create_connection(ws_url, timeout=30)
|
||||
ws.settimeout(60)
|
||||
ws_ref["ws"] = ws
|
||||
sub = {
|
||||
"cmd": "aibot_subscribe",
|
||||
"headers": {"req_id": uuid.uuid4().hex},
|
||||
"body": {"bot_id": bot_id, "secret": bot_secret},
|
||||
}
|
||||
ws.send(json.dumps(sub, ensure_ascii=False))
|
||||
print("[wecom-longconn] subscribe sent")
|
||||
while True:
|
||||
try:
|
||||
raw = ws.recv()
|
||||
except websocket.WebSocketTimeoutException:
|
||||
# Idle timeout is expected when no inbound message arrives.
|
||||
# Keep the connection alive instead of reconnecting.
|
||||
try:
|
||||
ws.ping()
|
||||
print("[wecom-longconn] ping")
|
||||
except Exception:
|
||||
raise
|
||||
# Drain outbound queue on idle ticks so replies are pushed immediately,
|
||||
# not delayed until the next inbound message.
|
||||
_drain_outbound_queue(ws=ws)
|
||||
continue
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
print("[wecom-longconn] non_json_message", raw)
|
||||
continue
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
cmd = str(msg.get("cmd") or "").strip()
|
||||
if not cmd:
|
||||
body0 = msg.get("body") if isinstance(msg.get("body"), dict) else {}
|
||||
cmd = str(body0.get("cmd") or body0.get("type") or msg.get("type") or "").strip()
|
||||
if not cmd and "errcode" in msg and "errmsg" in msg:
|
||||
ack_req_id = ""
|
||||
h = msg.get("headers")
|
||||
if isinstance(h, dict):
|
||||
ack_req_id = str(h.get("req_id") or "").strip()
|
||||
ack_err = int(msg.get("errcode") or 0)
|
||||
ack_msg = str(msg.get("errmsg") or "").strip()
|
||||
try:
|
||||
store.set_setting("wecom_last_ack_req_id", ack_req_id)
|
||||
store.set_setting("wecom_last_ack_errcode", str(ack_err))
|
||||
store.set_setting("wecom_last_ack_errmsg", ack_msg)
|
||||
if ack_err == 0:
|
||||
store.set_setting("wecom_last_outbound_error", "")
|
||||
else:
|
||||
store.set_setting("wecom_last_outbound_error", f"ack_errcode={ack_err}: {ack_msg}")
|
||||
except Exception:
|
||||
pass
|
||||
print("[wecom-longconn] outbound_ack", _safe_json(msg))
|
||||
continue
|
||||
try:
|
||||
store.set_setting("wecom_last_cmd", cmd)
|
||||
except Exception:
|
||||
pass
|
||||
if cmd == "aibot_subscribe_rsp":
|
||||
print("[wecom-longconn] subscribe_rsp", json.dumps(msg, ensure_ascii=False))
|
||||
continue
|
||||
if cmd not in ("aibot_msg_callback", "aibot_event_callback"):
|
||||
try:
|
||||
store.set_setting(
|
||||
"wecom_last_unknown_cmd_payload",
|
||||
json.dumps(msg, ensure_ascii=False, default=str)[:4000],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
print(
|
||||
"[wecom-longconn] unknown_cmd",
|
||||
_safe_json(
|
||||
{"cmd": cmd, "keys": sorted(msg.keys())[:20]},
|
||||
),
|
||||
)
|
||||
continue
|
||||
body = msg.get("body") if isinstance(msg.get("body"), dict) else {}
|
||||
headers = msg.get("headers") if isinstance(msg.get("headers"), dict) else {}
|
||||
callback_req_id = str(headers.get("req_id") or "").strip()
|
||||
try:
|
||||
store.set_setting(
|
||||
"wecom_last_raw_body",
|
||||
json.dumps(body, ensure_ascii=False, default=str)[:4000],
|
||||
)
|
||||
from_obj = body.get("from")
|
||||
from_uid = ""
|
||||
if isinstance(from_obj, dict):
|
||||
from_uid = str(from_obj.get("userid") or from_obj.get("user_id") or from_obj.get("id") or "").strip()
|
||||
elif isinstance(from_obj, str):
|
||||
from_uid = from_obj.strip()
|
||||
if from_uid:
|
||||
store.set_setting("wecom_last_raw_from_user", from_uid)
|
||||
except Exception:
|
||||
pass
|
||||
if cmd == "aibot_event_callback":
|
||||
event_type = str(body.get("event") or body.get("event_type") or body.get("type") or "").strip()
|
||||
event_obj = body.get("event") if isinstance(body.get("event"), dict) else {}
|
||||
event_type_norm = str(
|
||||
event_obj.get("eventtype")
|
||||
or event_obj.get("type")
|
||||
or event_type
|
||||
).strip()
|
||||
from_obj = body.get("from")
|
||||
from_user = ""
|
||||
if isinstance(from_obj, dict):
|
||||
from_user = str(
|
||||
from_obj.get("userid")
|
||||
or from_obj.get("user_id")
|
||||
or from_obj.get("id")
|
||||
or from_obj.get("from_user_id")
|
||||
or ""
|
||||
).strip()
|
||||
elif isinstance(from_obj, str):
|
||||
from_user = from_obj.strip()
|
||||
print(
|
||||
"[wecom-longconn] event_callback",
|
||||
_safe_json(
|
||||
{
|
||||
"event_type": event_type_norm or event_type,
|
||||
"from_user": from_user,
|
||||
"keys": sorted(body.keys())[:20],
|
||||
},
|
||||
),
|
||||
)
|
||||
if (event_type_norm or event_type).lower() == "disconnected_event":
|
||||
try:
|
||||
store.set_setting("wecom_last_parse_error", "disconnected_event")
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
try:
|
||||
payload = normalize_wecom_event(body)
|
||||
except Exception as exc:
|
||||
print(
|
||||
"[wecom-longconn] skip_invalid_msg_callback",
|
||||
_safe_json(
|
||||
{
|
||||
"error": f"{type(exc).__name__}: {exc}",
|
||||
"keys": sorted(body.keys())[:30],
|
||||
},
|
||||
),
|
||||
)
|
||||
try:
|
||||
store.set_setting("wecom_last_parse_error", f"{type(exc).__name__}: {exc}")
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
try:
|
||||
store.set_setting(
|
||||
"wecom_last_normalized_payload",
|
||||
json.dumps(payload, ensure_ascii=False, default=str)[:4000],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
msgid = str(body.get("msgid") or payload.get("metadata", {}).get("msgid") or "").strip()
|
||||
if msgid and msgid in seen_set:
|
||||
continue
|
||||
if msgid:
|
||||
if len(seen_ids) >= seen_ids.maxlen and seen_ids:
|
||||
old = seen_ids.popleft()
|
||||
seen_set.discard(old)
|
||||
seen_ids.append(msgid)
|
||||
seen_set.add(msgid)
|
||||
try:
|
||||
now = str(int(time.time()))
|
||||
user_id = str(payload.get("user_id") or "").strip()
|
||||
store.set_setting("wecom_last_msg_ts", now)
|
||||
if user_id:
|
||||
store.set_setting("wecom_last_from_user", user_id)
|
||||
raw_recent = str(store.get_setting("wecom_recent_from_users") or "[]")
|
||||
recent = json.loads(raw_recent)
|
||||
if not isinstance(recent, list):
|
||||
recent = []
|
||||
recent = [x for x in recent if isinstance(x, dict)]
|
||||
if user_id:
|
||||
recent = [x for x in recent if str(x.get("user_id") or "") != user_id]
|
||||
recent.insert(0, {"user_id": user_id, "ts": now})
|
||||
store.set_setting("wecom_recent_from_users", json.dumps(recent[:20], ensure_ascii=False))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
inbound_q.put_nowait(
|
||||
(
|
||||
payload,
|
||||
{
|
||||
"callback_req_id": callback_req_id or uuid.uuid4().hex,
|
||||
"response_url": str(body.get("response_url") or "").strip(),
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Inbound backlog; drop gracefully to keep the websocket loop healthy.
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_error", "inbound_queue_full")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Drain outbound queue opportunistically.
|
||||
_drain_outbound_queue(ws=ws)
|
||||
except KeyboardInterrupt:
|
||||
print("[wecom-longconn] stopped by user")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"[wecom-longconn] ws_loop_error: {type(exc).__name__}: {exc}")
|
||||
time.sleep(3)
|
||||
finally:
|
||||
ws_ref["ws"] = None
|
||||
if ws is not None:
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_forever() -> int:
|
||||
store = SqliteStore(db_path())
|
||||
sender = WeComClient(store)
|
||||
lock = _SingleInstanceLock(Path(db_path()).resolve().parent / "locks" / "wecom_longconn.lock")
|
||||
lock.acquire()
|
||||
default_mode = "ws"
|
||||
mode = str(os.getenv("WECOM_LONGCONN_MODE") or default_mode).strip().lower()
|
||||
interval_s = max(1.0, float(os.getenv("WECOM_LONGCONN_INTERVAL_SEC") or "3"))
|
||||
deliver_outbound = str(os.getenv("WECOM_LONGCONN_DELIVER_OUTBOUND") or "1").strip().lower() not in (
|
||||
"0",
|
||||
"false",
|
||||
"no",
|
||||
)
|
||||
seen_ids: deque[str] = deque(maxlen=2000)
|
||||
seen_set: set[str] = set()
|
||||
|
||||
# Prefer response_url by default for lower latency and better delivery semantics.
|
||||
use_response_url = str(os.getenv("WECOM_LONGCONN_USE_RESPONSE_URL") or "1").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
print(
|
||||
f"[wecom-longconn] started mode={mode} interval={interval_s}s outbound={deliver_outbound} use_response_url={use_response_url}"
|
||||
)
|
||||
try:
|
||||
if mode == "ws":
|
||||
return _run_ws_forever(
|
||||
sender=sender,
|
||||
deliver_outbound=deliver_outbound,
|
||||
use_response_url=use_response_url,
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
events = _load_events_once(mode)
|
||||
if not events:
|
||||
time.sleep(interval_s)
|
||||
continue
|
||||
for evt in events:
|
||||
payload = normalize_wecom_event(evt)
|
||||
meta = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
msgid = str(meta.get("msgid") or "").strip()
|
||||
if msgid and msgid in seen_set:
|
||||
continue
|
||||
if msgid:
|
||||
if len(seen_ids) >= seen_ids.maxlen and seen_ids:
|
||||
old = seen_ids.popleft()
|
||||
seen_set.discard(old)
|
||||
seen_ids.append(msgid)
|
||||
seen_set.add(msgid)
|
||||
out = process_inbound_payload_usecase(payload)
|
||||
replies = out.get("replies") if isinstance(out, dict) else []
|
||||
if not isinstance(replies, list):
|
||||
replies = []
|
||||
for rep in replies:
|
||||
if not isinstance(rep, dict):
|
||||
continue
|
||||
if not deliver_outbound:
|
||||
print("[wecom-longconn] outbound_skipped", _safe_json(rep))
|
||||
continue
|
||||
to_user = str(payload.get("user_id") or "").strip()
|
||||
text = str(rep.get("text") or "").strip()
|
||||
text = _sanitize_outbound_text(text)
|
||||
if not to_user or not text:
|
||||
continue
|
||||
aid = _account_id_from_payload(payload, store)
|
||||
print(
|
||||
"[wecom-longconn] outbound_skipped_http_removed use_ws_mode",
|
||||
_safe_json({"to_user": to_user, "account_id": aid or "", "text_len": len(text)}),
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print("[wecom-longconn] stopped by user")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"[wecom-longconn] loop_error: {type(exc).__name__}: {exc}")
|
||||
time.sleep(interval_s)
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
return run_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
127
interfaces/channels/wecom/normalize.py
Normal file
127
interfaces/channels/wecom/normalize.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _first_non_empty(*values: Any) -> str:
|
||||
for v in values:
|
||||
s = str(v or "").strip()
|
||||
if s:
|
||||
return s
|
||||
return ""
|
||||
|
||||
|
||||
def _pick(d: dict[str, Any], *keys: str) -> Any:
|
||||
for k in keys:
|
||||
if k in d:
|
||||
return d.get(k)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_text(raw: dict[str, Any]) -> str:
|
||||
direct_text = _pick(raw, "text")
|
||||
if isinstance(direct_text, str) and direct_text.strip():
|
||||
return direct_text.strip()
|
||||
direct = _first_non_empty(
|
||||
_pick(raw, "content", "Content"),
|
||||
)
|
||||
if direct:
|
||||
return direct
|
||||
text_obj = raw.get("text")
|
||||
if isinstance(text_obj, dict):
|
||||
return _first_non_empty(_pick(text_obj, "content", "Content"))
|
||||
text_raw_obj = raw.get("text_raw")
|
||||
if isinstance(text_raw_obj, dict):
|
||||
return _first_non_empty(_pick(text_raw_obj, "content", "Content", "text"))
|
||||
content_obj = raw.get("content")
|
||||
if isinstance(content_obj, dict):
|
||||
return _first_non_empty(_pick(content_obj, "text", "content", "Content"))
|
||||
if isinstance(raw.get("msg"), dict):
|
||||
return _first_non_empty(_pick(raw.get("msg", {}), "text", "content", "Content"))
|
||||
msg_obj = raw.get("message")
|
||||
if isinstance(msg_obj, dict):
|
||||
return _first_non_empty(
|
||||
_pick(msg_obj, "text", "content", "Content"),
|
||||
_pick(msg_obj.get("text", {}), "content") if isinstance(msg_obj.get("text"), dict) else None,
|
||||
)
|
||||
payload = raw.get("payload")
|
||||
if isinstance(payload, dict):
|
||||
return _first_non_empty(
|
||||
_pick(payload, "text", "content", "Content"),
|
||||
_pick(payload.get("text", {}), "content") if isinstance(payload.get("text"), dict) else None,
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_wecom_event(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert WeCom-like raw events into normalized gateway payload."""
|
||||
chat_id = _first_non_empty(
|
||||
_pick(raw, "chat_id", "conversation_id", "conversationId", "chatid", "roomid", "RoomId"),
|
||||
_pick(raw.get("message", {}), "chat_id", "conversation_id", "chatid")
|
||||
if isinstance(raw.get("message"), dict)
|
||||
else None,
|
||||
_pick(raw.get("chat", {}), "id", "chat_id", "chatid", "roomid")
|
||||
if isinstance(raw.get("chat"), dict)
|
||||
else None,
|
||||
_pick(raw.get("conversation", {}), "id", "chat_id", "chatid")
|
||||
if isinstance(raw.get("conversation"), dict)
|
||||
else None,
|
||||
_pick(raw.get("room", {}), "id", "roomid", "chatid")
|
||||
if isinstance(raw.get("room"), dict)
|
||||
else None,
|
||||
)
|
||||
from_obj = raw.get("from")
|
||||
user_id = _first_non_empty(
|
||||
_pick(raw, "user_id", "from_user_id", "fromUserId", "FromUserName", "userid", "external_userid"),
|
||||
from_obj if isinstance(from_obj, str) else None,
|
||||
_pick(from_obj, "userid", "user_id", "id", "from_user_id", "UserId", "userid64", "uid")
|
||||
if isinstance(from_obj, dict)
|
||||
else None,
|
||||
_pick(raw.get("sender", {}), "userid", "user_id", "id") if isinstance(raw.get("sender"), dict) else None,
|
||||
_pick(raw.get("message", {}), "from_user_id", "fromUserId") if isinstance(raw.get("message"), dict) else None,
|
||||
chat_id,
|
||||
)
|
||||
if not chat_id:
|
||||
chat_id = user_id
|
||||
text = _extract_text(raw)
|
||||
msgid = _first_non_empty(_pick(raw, "msgid", "msg_id", "id"), _pick(raw.get("message", {}), "msgid", "id"))
|
||||
agentid = _first_non_empty(_pick(raw, "agentid", "agent_id"), _pick(raw.get("message", {}), "agentid"))
|
||||
|
||||
chat_type = _first_non_empty(_pick(raw, "chat_type", "conversation_type"))
|
||||
is_group = bool(raw.get("is_group")) or chat_type in ("group", "room")
|
||||
if not is_group and chat_id:
|
||||
is_group = chat_id.endswith("@chatroom")
|
||||
|
||||
# If group but room id was missing, we previously fell back chat_id=user_id — same key as private 1:1.
|
||||
if is_group and chat_id == user_id:
|
||||
chat_id = f"group:unknown:{user_id}"
|
||||
|
||||
return {
|
||||
"channel": "wecom",
|
||||
"user_id": user_id,
|
||||
"chat_id": chat_id or user_id,
|
||||
"text": text,
|
||||
"is_group": is_group,
|
||||
"metadata": {
|
||||
"agentid": agentid,
|
||||
"msgid": msgid,
|
||||
"source": "wecom_longconn",
|
||||
"raw": raw,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def normalize_wecom_event_batch(obj: Any) -> list[dict[str, Any]]:
|
||||
"""Extract event list from common envelopes returned by pull APIs."""
|
||||
if isinstance(obj, list):
|
||||
return [x for x in obj if isinstance(x, dict)]
|
||||
if not isinstance(obj, dict):
|
||||
return []
|
||||
for key in ("events", "messages", "items", "data"):
|
||||
arr = obj.get(key)
|
||||
if isinstance(arr, list):
|
||||
return [x for x in arr if isinstance(x, dict)]
|
||||
return [obj]
|
||||
|
||||
|
||||
__all__ = ["normalize_wecom_event", "normalize_wecom_event_batch"]
|
||||
49
interfaces/channels/wecom/wecom_bridge.py
Normal file
49
interfaces/channels/wecom/wecom_bridge.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from oclaw.interfaces.channels.base import ChannelAdapter, InboundMessage, OutboundMessage
|
||||
|
||||
|
||||
class WeComAdapter(ChannelAdapter):
|
||||
channel_name = "wecom"
|
||||
|
||||
def parse_inbound(self, payload: dict[str, Any]) -> InboundMessage:
|
||||
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"))
|
||||
if is_group and chat_id == user_id:
|
||||
chat_id = f"group:unknown:{user_id}"
|
||||
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
mentions_raw = payload.get("mentions")
|
||||
mentions: list[str] = []
|
||||
if isinstance(mentions_raw, list):
|
||||
mentions = [str(x).strip() for x in mentions_raw if str(x).strip()]
|
||||
attachments = payload.get("attachments") if isinstance(payload.get("attachments"), list) else []
|
||||
return InboundMessage(
|
||||
channel=self.channel_name,
|
||||
external_user_id=user_id,
|
||||
external_chat_id=chat_id,
|
||||
text=text,
|
||||
is_group=is_group,
|
||||
mentions=mentions,
|
||||
attachments=[a for a in attachments if isinstance(a, dict)],
|
||||
metadata={str(k): v for k, v in metadata.items()},
|
||||
)
|
||||
|
||||
def format_outbound(self, msg: OutboundMessage) -> dict[str, Any]:
|
||||
return {
|
||||
"channel": self.channel_name,
|
||||
"chat_id": msg.external_chat_id,
|
||||
"text": msg.text,
|
||||
"attachments": msg.attachments,
|
||||
"metadata": msg.metadata,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["WeComAdapter"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue