Files
beaver_project/external-connector/external_connector/providers/weixin_ilink.py

464 lines
18 KiB
Python

from __future__ import annotations
import base64
import hashlib
import json
import random
import threading
import time
from collections.abc import Callable
from typing import Any
from urllib.parse import quote
from uuid import uuid4
import httpx
import qrcode
import qrcode.image.svg
from external_connector.providers.fake import _session_view
from external_connector.state import ConnectorSessionState, SidecarStateStore
BridgePoster = Callable[[str, dict[str, Any], dict[str, str]], None]
class WeixinIlinkProvider:
provider_id = "weixin_ilink"
fixed_base_url = "https://ilinkai.weixin.qq.com"
app_client_version = str((2 << 16) | (4 << 8) | 3)
def __init__(
self,
*,
store: SidecarStateStore,
http_client: Any | None = None,
bridge_base_url: str,
bridge_token: str,
bridge_post: BridgePoster | None = None,
start_receivers: bool = True,
) -> None:
self.store = store
self.http = http_client or httpx.Client(timeout=40)
self.bridge_base_url = bridge_base_url.rstrip("/")
self.bridge_token = bridge_token
self.bridge_post = bridge_post or self._default_bridge_post
self.start_receivers = start_receivers
self._receiver_stops: dict[str, threading.Event] = {}
self._receiver_lock = threading.Lock()
def connectors(self) -> list[dict[str, Any]]:
return [
{
"kind": "weixin",
"displayName": "Weixin",
"authType": "qr",
"providerId": self.provider_id,
"capabilities": ["receive_text", "send_text", "direct_messages"],
}
]
def health(self) -> dict[str, Any]:
return {"ok": True, "providerId": self.provider_id}
def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
kind = str(payload["kind"])
if kind != "weixin":
raise KeyError(f"Unsupported connector kind: {kind}")
session = self.store.create_session(
kind=kind,
connection_id=str(payload["connectionId"]),
channel_id=str(payload["channelId"]),
display_name=str(payload["displayName"]),
options=dict(payload.get("options") or {}),
)
response = self._post_json(
self.fixed_base_url,
"ilink/bot/get_bot_qrcode?bot_type=3",
{"local_token_list": []},
token=None,
timeout=20,
)
qr_code = str(response.get("qrcode") or "")
qr_url = str(response.get("qrcode_img_content") or "")
session = self.store.update_session(
session.session_id,
status="qr_ready",
qr_code=qr_url,
qr_image=_qr_svg_data_uri(qr_url),
metadata={
"qrcode": qr_code,
"qrBaseUrl": self.fixed_base_url,
"bridgeBaseUrl": str(payload.get("callbackBaseUrl") or self.bridge_base_url),
"getUpdatesBuf": "",
},
)
return _session_view(session)
def get_session(self, session_id: str) -> dict[str, Any]:
session = self.store.get_session(session_id)
if session.kind != "weixin":
raise KeyError(session_id)
if session.kind == "weixin" and session.status not in {"expired", "error", "cancelled"} and _has_connection_material(session):
if session.status != "connected":
session = self.store.update_session(session.session_id, status="connected")
self._ensure_receiver(session)
return _session_view(session)
if session.status in {"connected", "expired", "error", "cancelled"}:
if session.status == "connected":
self._ensure_receiver(session)
return _session_view(session)
qrcode = str(session.metadata.get("qrcode") or "")
if not qrcode:
return _session_view(session)
endpoint = f"ilink/bot/get_qrcode_status?qrcode={quote(qrcode)}"
response = self._get_json(self.fixed_base_url, endpoint, timeout=40)
status = str(response.get("status") or "wait")
session = self._apply_login_status(session, response, status)
if session.status == "connected":
self._ensure_receiver(session)
return _session_view(session)
def cancel_session(self, session_id: str) -> None:
session = self.store.get_session(session_id)
if session.kind != "weixin":
raise KeyError(session_id)
session = self.store.update_session(session_id, status="cancelled")
self._stop_receiver(session.connection_id)
def logout(self, connection_id: str) -> None:
self._stop_receiver(connection_id)
try:
session = self.store.find_session_by_connection_id(connection_id)
except KeyError:
return None
self.store.update_session(session.session_id, status="cancelled")
return None
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
begin = self.store.begin_send(connection_id=str(payload["connectionId"]), request_id=str(payload["requestId"]))
if not begin.should_send:
if begin.http_status == 409:
return {"ok": False, "status": begin.status, "retryAfterSeconds": begin.retry_after_seconds, "httpStatus": 409}
return {"ok": True, "providerMessageId": begin.provider_message_id}
session = self.store.find_session_by_connection_id(str(payload["connectionId"]))
token = str(session.metadata.get("token") or "")
base_url = str(session.metadata.get("baseUrl") or "")
if not token or not base_url:
return {"ok": False, "error": "Weixin connection is not connected"}
target = dict(payload.get("target") or {})
metadata = dict(payload.get("metadata") or {})
peer_id = str(target.get("peerId") or "")
request_id = str(payload["requestId"])
client_id = _client_id(request_id)
message_body = {
"from_user_id": "",
"to_user_id": peer_id,
"client_id": client_id,
"message_type": 2,
"message_state": 2,
"item_list": [{"type": 1, "text_item": {"text": str(payload.get("content") or "")}}],
}
context_token = _optional_text(metadata.get("contextToken") or metadata.get("context_token"))
if not context_token:
context_token = _cached_context_token(session, peer_id)
if context_token:
message_body["context_token"] = context_token
print(
json.dumps(
{
"event": "weixin_send_attempt",
"connectionId": payload["connectionId"],
"peerId": peer_id,
"requestId": request_id,
"clientId": client_id,
"hasContextToken": bool(context_token),
"textLength": len(str(payload.get("content") or "")),
},
ensure_ascii=False,
),
flush=True,
)
try:
response = self._post_json(
base_url,
"ilink/bot/sendmessage",
{"msg": message_body},
token=token,
timeout=20,
)
except Exception as exc:
error = str(exc)
self.store.fail_send(begin.dedupe_key, error=error)
return {"ok": False, "error": error, "httpStatus": 502}
error = _business_error(response)
if error:
self.store.fail_send(begin.dedupe_key, error=error)
return {"ok": False, "error": error, "httpStatus": 502}
print(
json.dumps(
{
"event": "weixin_send_result",
"connectionId": payload["connectionId"],
"requestId": request_id,
"responseKeys": sorted(str(key) for key in response.keys()),
"businessError": None,
},
ensure_ascii=False,
),
flush=True,
)
provider_message_id = f"weixin_{payload['requestId']}"
self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id)
return {"ok": True, "providerMessageId": provider_message_id}
def poll_once(self, connection_id: str) -> None:
session = self.store.find_session_by_connection_id(connection_id)
token = str(session.metadata.get("token") or "")
base_url = str(session.metadata.get("baseUrl") or "")
if not token or not base_url:
return None
metadata = dict(session.metadata)
response = self._post_json(
base_url,
"ilink/bot/getupdates",
{"get_updates_buf": str(metadata.get("getUpdatesBuf") or "")},
token=token,
timeout=40,
)
metadata["getUpdatesBuf"] = str(response.get("get_updates_buf") or metadata.get("getUpdatesBuf") or "")
for message in response.get("msgs") or []:
if isinstance(message, dict):
_remember_context_token(metadata, message)
event = _bridge_event_from_weixin_message(session, message)
print(
json.dumps(
{
"event": "weixin_inbound_message",
"connectionId": session.connection_id,
"messageId": event["messageId"],
"peerId": event["peerId"],
"hasContextToken": bool(event["metadata"].get("contextToken")),
"textLength": len(event["content"]),
},
ensure_ascii=False,
),
flush=True,
)
self.bridge_post(
f"{self.bridge_base_url}/api/channel-connector-bridge/events",
event,
{"Authorization": f"Bearer {self.bridge_token}"},
)
self.store.update_session(session.session_id, metadata=metadata)
return None
def _apply_login_status(
self,
session: ConnectorSessionState,
response: dict[str, Any],
status: str,
) -> ConnectorSessionState:
if status == "wait":
return self.store.update_session(session.session_id, status="qr_ready")
if status == "scaned":
return self.store.update_session(session.session_id, status="scanned")
if status == "expired":
return self.store.update_session(session.session_id, status="expired", error="QR code expired")
if status == "confirmed":
token = str(response.get("bot_token") or "")
account_id = str(response.get("ilink_bot_id") or "")
base_url = str(response.get("baseurl") or self.fixed_base_url)
if not token or not account_id:
return self.store.update_session(session.session_id, status="confirmed")
metadata = dict(session.metadata)
metadata.update(
{
"token": token,
"baseUrl": base_url,
"userId": str(response.get("ilink_user_id") or ""),
"getUpdatesBuf": str(metadata.get("getUpdatesBuf") or ""),
}
)
return self.store.update_session(
session.session_id,
status="connected",
account_id=f"weixin:{account_id}",
metadata=metadata,
)
if status == "need_verifycode":
return self.store.update_session(
session.session_id,
status="waiting_for_user",
instructions=["Enter the verification code shown in Weixin, then refresh status."],
)
return self.store.update_session(session.session_id, status="waiting_for_user")
def _ensure_receiver(self, session: ConnectorSessionState) -> None:
if not self.start_receivers:
return None
with self._receiver_lock:
if session.connection_id in self._receiver_stops:
return None
stop = threading.Event()
self._receiver_stops[session.connection_id] = stop
thread = threading.Thread(target=self._receiver_loop, args=(session.connection_id, stop), daemon=True)
thread.start()
return None
def _receiver_loop(self, connection_id: str, stop: threading.Event) -> None:
while not stop.is_set():
try:
self.poll_once(connection_id)
except Exception:
time.sleep(5)
stop.wait(1)
def _stop_receiver(self, connection_id: str) -> None:
with self._receiver_lock:
stop = self._receiver_stops.pop(connection_id, None)
if stop is not None:
stop.set()
def _post_json(
self,
base_url: str,
endpoint: str,
body: dict[str, Any],
*,
token: str | None,
timeout: float,
) -> dict[str, Any]:
response = self.http.post(
_url(base_url, endpoint),
json={**body, "base_info": _base_info()},
headers=_headers(token),
timeout=timeout,
)
response.raise_for_status()
return dict(response.json())
def _get_json(self, base_url: str, endpoint: str, *, timeout: float) -> dict[str, Any]:
response = self.http.get(_url(base_url, endpoint), headers=_common_headers(), timeout=timeout)
response.raise_for_status()
return dict(response.json())
def _default_bridge_post(self, url: str, payload: dict[str, Any], headers: dict[str, str]) -> None:
response = self.http.post(url, json=payload, headers=headers, timeout=20)
response.raise_for_status()
def _url(base_url: str, endpoint: str) -> str:
return f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}"
def _base_info() -> dict[str, str]:
return {"channel_version": "2.4.3", "bot_agent": "Beaver/1.0"}
def _common_headers() -> dict[str, str]:
return {"iLink-App-Id": "bot", "iLink-App-ClientVersion": WeixinIlinkProvider.app_client_version}
def _headers(token: str | None) -> dict[str, str]:
headers = {
**_common_headers(),
"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": base64.b64encode(str(random.getrandbits(32)).encode("utf-8")).decode("ascii"),
}
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def _qr_svg_data_uri(value: str) -> str:
image = qrcode.make(value, image_factory=qrcode.image.svg.SvgPathImage)
raw = image.to_string(encoding="unicode")
return "data:image/svg+xml;base64," + base64.b64encode(raw.encode("utf-8")).decode("ascii")
def _bridge_event_from_weixin_message(session: ConnectorSessionState, message: dict[str, Any]) -> dict[str, Any]:
message_id = str(message.get("message_id") or uuid4().hex)
peer_id = str(message.get("from_user_id") or "")
text = _extract_text(message)
return {
"eventId": f"{session.channel_id}:{message_id}",
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"deliveryAttempt": 1,
"connectionId": session.connection_id,
"channelId": session.channel_id,
"kind": "weixin",
"accountId": session.account_id,
"peerId": peer_id,
"peerType": "dm",
"userId": peer_id,
"threadId": None,
"messageId": message_id,
"messageType": "text",
"content": text,
"metadata": {"contextToken": message.get("context_token")},
}
def _remember_context_token(metadata: dict[str, Any], message: dict[str, Any]) -> None:
peer_id = _optional_text(message.get("from_user_id"))
context_token = _optional_text(message.get("context_token"))
if not peer_id or not context_token:
return None
context_tokens = metadata.get("contextTokens")
if not isinstance(context_tokens, dict):
context_tokens = {}
context_tokens[peer_id] = context_token
metadata["contextTokens"] = context_tokens
return None
def _extract_text(message: dict[str, Any]) -> str:
for item in message.get("item_list") or []:
if not isinstance(item, dict):
continue
text_item = item.get("text_item")
if isinstance(text_item, dict) and text_item.get("text") is not None:
return str(text_item["text"])
return ""
def _business_error(response: dict[str, Any]) -> str | None:
for key in ("ret", "code", "errcode"):
if key not in response:
continue
try:
value = int(response.get(key) or 0)
except (TypeError, ValueError):
value = -1
if value == 0:
return None
message = response.get("errmsg") or response.get("msg") or response.get("error") or response
return str(message)
return None
def _has_connection_material(session: ConnectorSessionState) -> bool:
metadata = dict(session.metadata)
return bool(str(metadata.get("token") or "").strip() and str(metadata.get("baseUrl") or "").strip() and session.account_id)
def _cached_context_token(session: ConnectorSessionState, peer_id: str) -> str | None:
context_tokens = dict(session.metadata.get("contextTokens") or {})
return _optional_text(context_tokens.get(peer_id))
def _client_id(request_id: str) -> str:
text = str(request_id).strip()
if text and len(text) <= 64 and all(char.isalnum() or char in {"_", "-"} for char in text):
return text
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:24]
return f"beaver-weixin-{digest}"
def _optional_text(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None