feat: implement channel runtime connectors

This commit is contained in:
2026-06-03 16:22:44 +08:00
parent ee972441f5
commit c3d84b904a
105 changed files with 15621 additions and 322 deletions

View File

@ -0,0 +1 @@
"""Generic external connector sidecar."""

View File

@ -0,0 +1,74 @@
from __future__ import annotations
from typing import Any
from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import JSONResponse
from external_connector.models import ConnectorSessionRequest, SendRequest
from external_connector.providers.base import ConnectorProvider
def create_app(*, provider: ConnectorProvider, api_token: str) -> FastAPI:
app = FastAPI(title="External Connector")
def require_auth(authorization: str | None) -> None:
if api_token and authorization != f"Bearer {api_token}":
raise HTTPException(status_code=401, detail="Invalid connector token")
@app.get("/health")
def health() -> dict[str, Any]:
return provider.health()
@app.get("/connectors")
def connectors(authorization: str | None = Header(default=None)) -> list[dict[str, Any]]:
require_auth(authorization)
return provider.connectors()
@app.post("/connector-sessions")
def start_session(
payload: ConnectorSessionRequest,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
require_auth(authorization)
return provider.start_session(payload.model_dump(by_alias=True))
@app.get("/connector-sessions/{session_id}")
def get_session(session_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]:
require_auth(authorization)
try:
return provider.get_session(session_id)
except KeyError:
raise HTTPException(status_code=404, detail="Connector session not found")
@app.post("/connector-sessions/{session_id}/cancel")
def cancel_session(session_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]:
require_auth(authorization)
provider.cancel_session(session_id)
return {"ok": True}
@app.post("/connections/{connection_id}/logout")
def logout(connection_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]:
require_auth(authorization)
provider.logout(connection_id)
return {"ok": True}
@app.post("/send", response_model=None)
def send(payload: SendRequest, authorization: str | None = Header(default=None)) -> JSONResponse | dict[str, Any]:
require_auth(authorization)
result = dict(provider.send(payload.model_dump(by_alias=True)))
status_code = int(result.pop("httpStatus", 200))
if status_code != 200:
return JSONResponse(status_code=status_code, content=result)
return result
if hasattr(provider, "handle_event"):
@app.post("/feishu/events", response_model=None)
def feishu_events(payload: dict[str, Any]) -> JSONResponse | dict[str, Any]:
result = dict(provider.handle_event(payload)) # type: ignore[attr-defined]
status_code = int(result.pop("httpStatus", 200))
if status_code != 200:
return JSONResponse(status_code=status_code, content=result)
return result
return app

View File

@ -0,0 +1,62 @@
from __future__ import annotations
import os
from pathlib import Path
import uvicorn
from external_connector.app import create_app
from external_connector.providers.composite import CompositeProvider
from external_connector.providers.fake import FakeProvider
from external_connector.providers.feishu_bot import FeishuBotProvider
from external_connector.providers.vendor_cli import VendorCliProvider
from external_connector.providers.weixin_ilink import WeixinIlinkProvider
from external_connector.state import SidecarStateStore
def build_app():
home = Path(os.getenv("CONNECTOR_HOME", "/var/lib/external-connector"))
store = SidecarStateStore(home / "state.json")
provider_name = os.getenv("CONNECTOR_PROVIDER", "fake")
if provider_name == "official":
provider = CompositeProvider([
_weixin_provider(store),
_feishu_provider(store),
])
elif provider_name == "weixin_ilink":
provider = _weixin_provider(store)
elif provider_name == "feishu_bot":
provider = _feishu_provider(store)
elif provider_name == "vendor_cli":
provider = VendorCliProvider(store=store, env=os.environ)
else:
provider = FakeProvider(store)
return create_app(provider=provider, api_token=os.getenv("CONNECTOR_API_TOKEN", ""))
def _weixin_provider(store: SidecarStateStore) -> WeixinIlinkProvider:
return WeixinIlinkProvider(
store=store,
bridge_base_url=os.getenv("BEAVER_BRIDGE_BASE_URL", ""),
bridge_token=os.getenv("BEAVER_BRIDGE_TOKEN", ""),
)
def _feishu_provider(store: SidecarStateStore) -> FeishuBotProvider:
return FeishuBotProvider(
store=store,
bridge_base_url=os.getenv("BEAVER_BRIDGE_BASE_URL", ""),
public_base_url=os.getenv("CONNECTOR_PUBLIC_BASE_URL", ""),
bridge_token=os.getenv("BEAVER_BRIDGE_TOKEN", ""),
)
app = build_app()
def main() -> None:
uvicorn.run(app, host="0.0.0.0", port=8787)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,24 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class ConnectorSessionRequest(BaseModel):
kind: str
connection_id: str = Field(alias="connectionId")
channel_id: str = Field(alias="channelId")
display_name: str = Field(alias="displayName")
callback_base_url: str = Field(alias="callbackBaseUrl")
options: dict[str, Any] = Field(default_factory=dict)
class SendRequest(BaseModel):
request_id: str = Field(alias="requestId")
connection_id: str = Field(alias="connectionId")
channel_id: str = Field(alias="channelId")
kind: str
target: dict[str, Any]
content: str
metadata: dict[str, Any] = Field(default_factory=dict)

View File

@ -0,0 +1,149 @@
const Lark = require("@larksuiteoapi/node-sdk");
const appId = requireEnv("FEISHU_APP_ID");
const appSecret = requireEnv("FEISHU_APP_SECRET");
const connectionId = requireEnv("FEISHU_CONNECTION_ID");
const channelId = requireEnv("FEISHU_CHANNEL_ID");
const accountId = process.env.FEISHU_ACCOUNT_ID || `feishu:${appId}`;
const bridgeBaseUrl = requireEnv("BEAVER_BRIDGE_BASE_URL").replace(/\/+$/, "");
const bridgeToken = requireEnv("BEAVER_BRIDGE_TOKEN");
const domain = (process.env.FEISHU_DOMAIN || "feishu").toLowerCase() === "lark"
? Lark.Domain.Lark
: Lark.Domain.Feishu;
const wsClient = new Lark.WSClient({
appId,
appSecret,
domain,
loggerLevel: Lark.LoggerLevel.info,
onReady: () => log("feishu_ws_ready", {}),
onError: (error) => log("feishu_ws_error", { error: redact(String(error && error.message ? error.message : error)) }),
onReconnecting: () => log("feishu_ws_reconnecting", {}),
onReconnected: () => log("feishu_ws_reconnected", {}),
handshakeTimeoutMs: 15000,
wsConfig: { pingTimeout: 10 },
});
const dispatcher = new Lark.EventDispatcher({}).register({
"im.message.receive_v1": async (data) => {
const event = bridgeEventFromFeishu(data);
log("feishu_inbound_message", {
connectionId,
eventId: event.eventId,
messageId: event.messageId,
peerId: event.peerId,
textLength: event.content.length,
});
await postJson(`${bridgeBaseUrl}/api/channel-connector-bridge/events`, event);
},
});
process.on("SIGTERM", () => {
wsClient.close({ force: true });
process.exit(0);
});
process.on("SIGINT", () => {
wsClient.close({ force: true });
process.exit(0);
});
wsClient.start({ eventDispatcher: dispatcher }).catch((error) => {
log("feishu_ws_start_failed", { error: redact(String(error && error.message ? error.message : error)) });
process.exit(1);
});
setInterval(() => {
if (typeof wsClient.getConnectionStatus === "function") {
log("feishu_ws_status", wsClient.getConnectionStatus());
}
}, 60000).unref();
function bridgeEventFromFeishu(data) {
const message = objectValue(data.message);
const sender = objectValue(data.sender);
const senderId = objectValue(sender.sender_id);
const peerId = stringValue(senderId.open_id || senderId.user_id || "");
const messageId = stringValue(message.message_id || randomId());
const eventId = stringValue(data.event_id || data.eventId || `${channelId}:${messageId}`);
return {
eventId,
timestamp: new Date().toISOString(),
deliveryAttempt: 1,
connectionId,
channelId,
kind: "feishu",
accountId,
peerId,
peerType: message.chat_type === "group" ? "group" : "dm",
userId: peerId,
threadId: stringValue(message.chat_id || "") || null,
messageId,
messageType: stringValue(message.message_type || "text"),
content: extractText(message),
metadata: {
chatId: message.chat_id || null,
rawMessageType: message.message_type || null,
},
};
}
function extractText(message) {
const content = message.content;
if (typeof content !== "string") {
return "";
}
try {
const parsed = JSON.parse(content);
if (parsed && parsed.text != null) {
return String(parsed.text);
}
} catch (_error) {
return content;
}
return content;
}
async function postJson(url, payload) {
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": `Bearer ${bridgeToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`bridge post failed ${response.status}: ${text.slice(0, 300)}`);
}
}
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
function objectValue(value) {
return value && typeof value === "object" ? value : {};
}
function stringValue(value) {
return value == null ? "" : String(value);
}
function randomId() {
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function log(event, fields) {
console.log(JSON.stringify({ event, ...fields }));
}
function redact(text) {
return text
.replace(appSecret, "***")
.replace(bridgeToken, "***");
}

View File

@ -0,0 +1,28 @@
from __future__ import annotations
from typing import Any, Protocol
class ConnectorProvider(Protocol):
provider_id: str
def connectors(self) -> list[dict[str, Any]]:
...
def health(self) -> dict[str, Any]:
...
def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
...
def get_session(self, session_id: str) -> dict[str, Any]:
...
def cancel_session(self, session_id: str) -> None:
...
def logout(self, connection_id: str) -> None:
...
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
...

View File

@ -0,0 +1,70 @@
from __future__ import annotations
from typing import Any
from external_connector.providers.base import ConnectorProvider
class CompositeProvider:
provider_id = "composite"
def __init__(self, providers: list[ConnectorProvider]) -> None:
self.providers = providers
self._by_kind: dict[str, ConnectorProvider] = {}
for provider in providers:
for connector in provider.connectors():
self._by_kind[str(connector["kind"])] = provider
def connectors(self) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
for provider in self.providers:
items.extend(provider.connectors())
return items
def health(self) -> dict[str, Any]:
return {"ok": True, "providerId": self.provider_id, "providers": [provider.health() for provider in self.providers]}
def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
return self._provider(str(payload["kind"])).start_session(payload)
def get_session(self, session_id: str) -> dict[str, Any]:
for provider in self.providers:
try:
return provider.get_session(session_id)
except KeyError:
continue
raise KeyError(session_id)
def cancel_session(self, session_id: str) -> None:
for provider in self.providers:
try:
provider.cancel_session(session_id)
return None
except KeyError:
continue
raise KeyError(session_id)
def logout(self, connection_id: str) -> None:
for provider in self.providers:
provider.logout(connection_id)
return None
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
return self._provider(str(payload["kind"])).send(payload)
def handle_event(self, payload: dict[str, Any]) -> dict[str, Any]:
for provider in self.providers:
handler = getattr(provider, "handle_event", None)
if handler is None:
continue
try:
return dict(handler(payload))
except KeyError:
continue
raise KeyError("No event provider matched")
def _provider(self, kind: str) -> ConnectorProvider:
provider = self._by_kind.get(kind)
if provider is None:
raise KeyError(f"Unsupported connector kind: {kind}")
return provider

View File

@ -0,0 +1,119 @@
from __future__ import annotations
import base64
from typing import Any
from uuid import uuid4
from external_connector.state import ConnectorSessionState, SidecarStateStore
def _session_view(session: ConnectorSessionState) -> dict[str, Any]:
return {
"sessionId": session.session_id,
"kind": session.kind,
"status": session.status,
"qrCode": session.qr_code,
"qrImage": session.qr_image,
"instructions": list(session.instructions),
"accountId": session.account_id,
"displayName": session.display_name if session.account_id else None,
"error": session.error,
"metadata": dict(session.metadata),
}
def _fake_qr_image() -> str:
svg = (
'<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">'
'<rect width="256" height="256" fill="#fff"/>'
'<rect x="16" y="16" width="58" height="58" fill="#111"/>'
'<rect x="28" y="28" width="34" height="34" fill="#fff"/>'
'<rect x="184" y="16" width="58" height="58" fill="#111"/>'
'<rect x="196" y="28" width="34" height="34" fill="#fff"/>'
'<rect x="16" y="184" width="58" height="58" fill="#111"/>'
'<rect x="28" y="196" width="34" height="34" fill="#fff"/>'
'<rect x="96" y="96" width="18" height="18" fill="#111"/>'
'<rect x="132" y="96" width="18" height="18" fill="#111"/>'
'<rect x="168" y="96" width="18" height="18" fill="#111"/>'
'<rect x="96" y="132" width="18" height="18" fill="#111"/>'
'<rect x="150" y="132" width="18" height="18" fill="#111"/>'
'<rect x="204" y="132" width="18" height="18" fill="#111"/>'
'<rect x="114" y="168" width="18" height="18" fill="#111"/>'
'<rect x="168" y="168" width="18" height="18" fill="#111"/>'
'<rect x="204" y="168" width="18" height="18" fill="#111"/>'
'<rect x="96" y="204" width="18" height="18" fill="#111"/>'
'<rect x="132" y="204" width="18" height="18" fill="#111"/>'
'<rect x="186" y="204" width="18" height="18" fill="#111"/>'
'<text x="128" y="248" text-anchor="middle" font-family="monospace" font-size="14" fill="#444">FAKE QR</text>'
"</svg>"
)
encoded = base64.b64encode(svg.encode("utf-8")).decode("ascii")
return f"data:image/svg+xml;base64,{encoded}"
class FakeProvider:
provider_id = "fake"
def __init__(self, store: SidecarStateStore) -> None:
self.store = store
def connectors(self) -> list[dict[str, Any]]:
return [
{
"kind": "weixin",
"displayName": "Weixin",
"authType": "qr",
"providerId": self.provider_id,
"capabilities": ["receive_text", "send_text", "receive_media", "direct_messages"],
},
{
"kind": "feishu",
"displayName": "Feishu/Lark",
"authType": "plugin_install",
"providerId": self.provider_id,
"capabilities": ["receive_text", "send_text", "receive_media", "groups"],
},
]
def health(self) -> dict[str, Any]:
return {"ok": True, "providerId": self.provider_id}
def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
session = self.store.create_session(
kind=str(payload["kind"]),
connection_id=str(payload["connectionId"]),
channel_id=str(payload["channelId"]),
display_name=str(payload["displayName"]),
options=dict(payload.get("options") or {}),
)
session = self.store.update_session(
session.session_id,
status="qr_ready" if session.kind == "weixin" else "waiting_for_user",
qr_image=_fake_qr_image() if session.kind == "weixin" else None,
instructions=["Run the provider install flow and finish verification"] if session.kind == "feishu" else [],
)
return _session_view(session)
def get_session(self, session_id: str) -> dict[str, Any]:
return _session_view(self.store.get_session(session_id))
def cancel_session(self, session_id: str) -> None:
self.store.update_session(session_id, status="cancelled")
def logout(self, connection_id: str) -> None:
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}
provider_message_id = f"fake_{uuid4().hex}"
self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id)
return {"ok": True, "providerMessageId": provider_message_id}

View File

@ -0,0 +1,542 @@
from __future__ import annotations
import base64
import json
import os
import subprocess
import threading
import time
from collections.abc import Callable
from io import BytesIO
from pathlib import Path
from typing import Any
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
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]
ReceiverStart = Callable[[ConnectorSessionState], object]
class FeishuBotProvider:
provider_id = "feishu_bot"
def __init__(
self,
*,
store: SidecarStateStore,
http_client: Any | None = None,
bridge_base_url: str,
public_base_url: str | None = None,
bridge_token: str,
bridge_post: BridgePoster | None = None,
api_base_url: str = "",
start_receivers: bool = True,
receiver_start: ReceiverStart | None = None,
) -> None:
self.store = store
self.http = http_client or httpx.Client(timeout=30)
self.bridge_base_url = bridge_base_url.rstrip("/")
self.public_base_url = (public_base_url or bridge_base_url).rstrip("/")
self.bridge_token = bridge_token
self.bridge_post = bridge_post or self._default_bridge_post
self.api_base_url = api_base_url.rstrip("/")
self.start_receivers = start_receivers
self.receiver_start = receiver_start or self._start_receiver_process
self._receiver_processes: dict[str, object] = {}
self._receiver_lock = threading.Lock()
self._start_existing_connected_receivers()
def connectors(self) -> list[dict[str, Any]]:
return [
{
"kind": "feishu",
"displayName": "Feishu/Lark",
"authType": "qr_or_bot_app",
"providerId": self.provider_id,
"capabilities": ["receive_text", "send_text", "groups"],
}
]
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 != "feishu":
raise KeyError(f"Unsupported connector kind: {kind}")
options = dict(payload.get("options") or {})
session = self.store.create_session(
kind=kind,
connection_id=str(payload["connectionId"]),
channel_id=str(payload["channelId"]),
display_name=str(payload["displayName"]),
options=options,
)
metadata = {
"eventCallbackPath": "/feishu/events",
"eventCallbackUrl": f"{self.public_base_url}/feishu/events",
}
app_id = str(options.get("appId") or options.get("app_id") or "").strip()
app_secret = str(options.get("appSecret") or options.get("app_secret") or "").strip()
verification_token = str(options.get("verificationToken") or options.get("verification_token") or "").strip()
mode = str(options.get("mode") or "create").strip().lower()
domain = _domain(options)
if not app_id or not app_secret:
if mode != "link":
return self._start_registration_session(session, metadata=metadata, domain=domain)
session = self.store.update_session(
session.session_id,
status="waiting_for_user",
instructions=_link_instructions(metadata["eventCallbackUrl"]),
metadata=metadata,
)
return _session_view(session)
token_data = self._tenant_token(app_id, app_secret, domain=domain)
metadata.update(
{
"appId": app_id,
"appSecret": app_secret,
"verificationToken": verification_token,
"tenantAccessToken": token_data["token"],
"tenantTokenExpiresAt": token_data["expires_at"],
"domain": domain,
}
)
session = self.store.update_session(
session.session_id,
status="connected",
account_id=f"feishu:{app_id}",
metadata=metadata,
instructions=_connected_instructions(),
)
self._ensure_receiver(session)
return _session_view(session)
def get_session(self, session_id: str) -> dict[str, Any]:
session = self.store.get_session(session_id)
if session.kind != "feishu":
raise KeyError(session_id)
if session.kind == "feishu" and session.status in {"qr_ready", "scanned", "confirmed", "waiting_for_user"} and session.metadata.get("deviceCode"):
session = self._poll_registration_session(session)
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 != "feishu":
raise KeyError(session_id)
self.store.update_session(session_id, status="cancelled")
self._stop_receiver(session.connection_id)
def logout(self, connection_id: str) -> None:
try:
session = self.store.find_session_by_connection_id(connection_id)
except KeyError:
return None
self._stop_receiver(connection_id)
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 = self._tenant_token_for_session(session)
target = dict(payload.get("target") or {})
metadata = dict(session.metadata)
api_base = _open_api_base_url(str(metadata.get("domain") or "feishu"), self.api_base_url)
try:
response = self.http.post(
f"{api_base}/open-apis/im/v1/messages?receive_id_type=open_id",
json={
"receive_id": str(target.get("peerId") or ""),
"msg_type": "text",
"content": json.dumps({"text": str(payload.get("content") or "")}, ensure_ascii=False),
},
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
timeout=20,
)
response.raise_for_status()
except Exception as exc:
error = str(exc)
self.store.fail_send(begin.dedupe_key, error=error)
return {"ok": False, "error": error, "httpStatus": 502}
data = dict(response.json())
if int(data.get("code") or 0) != 0:
error = str(data.get("msg") or data)
self.store.fail_send(begin.dedupe_key, error=error)
return {"ok": False, "error": error, "httpStatus": 502}
provider_message_id = str((data.get("data") or {}).get("message_id") or f"feishu_{payload['requestId']}")
self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id)
return {"ok": True, "providerMessageId": provider_message_id}
def handle_event(self, payload: dict[str, Any]) -> dict[str, Any]:
challenge = payload.get("challenge")
if challenge:
return {"challenge": challenge}
header = dict(payload.get("header") or {})
event = dict(payload.get("event") or {})
app_id = str(header.get("app_id") or "")
session = self._session_for_app_id(app_id)
expected_token = str(session.metadata.get("verificationToken") or "")
received_token = str(header.get("token") or payload.get("token") or "")
if expected_token and received_token != expected_token:
return {"ok": False, "error": "invalid verification token", "httpStatus": 401}
bridge_event = _bridge_event_from_feishu(session, header, event)
self.bridge_post(
f"{self.bridge_base_url}/api/channel-connector-bridge/events",
bridge_event,
{"Authorization": f"Bearer {self.bridge_token}"},
)
return {"ok": True}
def _tenant_token_for_session(self, session: ConnectorSessionState) -> str:
metadata = dict(session.metadata)
expires_at = float(metadata.get("tenantTokenExpiresAt") or 0)
token = str(metadata.get("tenantAccessToken") or "")
if token and expires_at - time.time() > 60:
return token
app_id = str(metadata.get("appId") or "")
app_secret = str(metadata.get("appSecret") or "")
token_data = self._tenant_token(app_id, app_secret, domain=str(metadata.get("domain") or "feishu"))
metadata.update({"tenantAccessToken": token_data["token"], "tenantTokenExpiresAt": token_data["expires_at"]})
self.store.update_session(session.session_id, metadata=metadata)
return str(token_data["token"])
def _tenant_token(self, app_id: str, app_secret: str, *, domain: str = "feishu") -> dict[str, Any]:
response = self.http.post(
f"{_open_api_base_url(domain, self.api_base_url)}/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": app_id, "app_secret": app_secret},
timeout=20,
)
response.raise_for_status()
data = dict(response.json())
if int(data.get("code") or 0) != 0:
raise RuntimeError(str(data.get("msg") or data))
return {"token": str(data["tenant_access_token"]), "expires_at": time.time() + int(data.get("expire") or 7200)}
def _session_for_app_id(self, app_id: str) -> ConnectorSessionState:
sessions = self.store.list_sessions()
for session in sorted(sessions, key=lambda item: item.updated_at, reverse=True):
if session.kind == "feishu" and session.status == "connected" and session.metadata.get("appId") == app_id:
return session
raise KeyError(app_id)
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 _start_registration_session(
self,
session: ConnectorSessionState,
*,
metadata: dict[str, Any],
domain: str,
) -> dict[str, Any]:
try:
init_data = self._registration_post(domain, {"action": "init"})
supported = init_data.get("supported_auth_methods") or []
if "client_secret" not in supported:
session = self.store.update_session(
session.session_id,
status="error",
error="Current Feishu/Lark environment does not support client_secret bot registration",
metadata=metadata,
)
return _session_view(session)
begin_data = self._registration_post(
domain,
{
"action": "begin",
"archetype": "PersonalAgent",
"auth_method": "client_secret",
"request_user_info": "open_id",
},
)
except Exception as exc:
session = self.store.update_session(
session.session_id,
status="error",
error=str(exc),
metadata=metadata,
)
return _session_view(session)
qr_code = _with_onboard_from(str(begin_data.get("verification_uri_complete") or ""))
if not qr_code:
session = self.store.update_session(
session.session_id,
status="error",
error="Feishu/Lark registration did not return a QR URL",
metadata=metadata,
)
return _session_view(session)
metadata.update(
{
"domain": domain,
"registrationBaseUrl": _registration_base_url(domain),
"deviceCode": str(begin_data.get("device_code") or ""),
"pollIntervalSeconds": int(begin_data.get("interval") or 5),
"expiresAt": time.time() + int(begin_data.get("expire_in") or 600),
}
)
session = self.store.update_session(
session.session_id,
status="qr_ready",
qr_code=qr_code,
qr_image=_qr_svg_data_uri(qr_code),
instructions=_create_instructions(metadata["eventCallbackUrl"]),
metadata=metadata,
)
return _session_view(session)
def _poll_registration_session(self, session: ConnectorSessionState) -> ConnectorSessionState:
metadata = dict(session.metadata)
expires_at = float(metadata.get("expiresAt") or 0)
if expires_at and time.time() > expires_at:
return self.store.update_session(session.session_id, status="expired", metadata=metadata)
domain = str(metadata.get("domain") or "feishu")
device_code = str(metadata.get("deviceCode") or "")
if not device_code:
return session
try:
poll_data = self._registration_post(domain, {"action": "poll", "device_code": device_code})
except Exception as exc:
return self.store.update_session(session.session_id, status="error", error=str(exc), metadata=metadata)
user_info = dict(poll_data.get("user_info") or {})
if user_info.get("tenant_brand") == "lark":
domain = "lark"
metadata["domain"] = domain
app_id = str(poll_data.get("client_id") or "").strip()
app_secret = str(poll_data.get("client_secret") or "").strip()
if app_id and app_secret:
token_data = self._tenant_token(app_id, app_secret, domain=domain)
metadata.update(
{
"appId": app_id,
"appSecret": app_secret,
"tenantAccessToken": token_data["token"],
"tenantTokenExpiresAt": token_data["expires_at"],
"registrationOpenId": str(user_info.get("open_id") or ""),
}
)
return self.store.update_session(
session.session_id,
status="connected",
account_id=f"feishu:{app_id}",
metadata=metadata,
instructions=_connected_instructions(),
)
error = str(poll_data.get("error") or "")
if error == "authorization_pending":
return session
if error == "slow_down":
metadata["pollIntervalSeconds"] = int(metadata.get("pollIntervalSeconds") or 5) + 5
return self.store.update_session(session.session_id, metadata=metadata)
if error == "expired_token":
return self.store.update_session(session.session_id, status="expired", metadata=metadata)
if error == "access_denied":
return self.store.update_session(session.session_id, status="error", error="Feishu/Lark authorization was denied", metadata=metadata)
if error:
description = str(poll_data.get("error_description") or error)
return self.store.update_session(session.session_id, status="error", error=description, metadata=metadata)
return session
def _registration_post(self, domain: str, values: dict[str, str]) -> dict[str, Any]:
response = self.http.post(
f"{_registration_base_url(domain)}/oauth/v1/app/registration",
data=urlencode(values),
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=20,
)
response.raise_for_status()
return dict(response.json())
def _start_existing_connected_receivers(self) -> None:
if not self.start_receivers:
return None
for session in self.store.list_sessions():
if session.kind == "feishu" and session.status == "connected":
self._ensure_receiver(session)
return None
def _ensure_receiver(self, session: ConnectorSessionState) -> None:
if not self.start_receivers or not _has_receiver_material(session):
return None
with self._receiver_lock:
existing = self._receiver_processes.get(session.connection_id)
if existing is not None and _receiver_is_alive(existing):
return None
receiver = self.receiver_start(session)
self._receiver_processes[session.connection_id] = receiver
return None
def _stop_receiver(self, connection_id: str) -> None:
with self._receiver_lock:
receiver = self._receiver_processes.pop(connection_id, None)
if receiver is None:
return None
terminate = getattr(receiver, "terminate", None)
if callable(terminate):
terminate()
wait = getattr(receiver, "wait", None)
if callable(wait):
try:
wait(timeout=5)
except subprocess.TimeoutExpired:
kill = getattr(receiver, "kill", None)
if callable(kill):
kill()
return None
def _start_receiver_process(self, session: ConnectorSessionState) -> subprocess.Popen[bytes]:
metadata = dict(session.metadata)
script = Path(__file__).resolve().parents[1] / "node" / "feishu_ws_receiver.js"
env = os.environ.copy()
env.update(
{
"FEISHU_APP_ID": str(metadata.get("appId") or ""),
"FEISHU_APP_SECRET": str(metadata.get("appSecret") or ""),
"FEISHU_DOMAIN": str(metadata.get("domain") or "feishu"),
"FEISHU_CONNECTION_ID": session.connection_id,
"FEISHU_CHANNEL_ID": session.channel_id,
"FEISHU_ACCOUNT_ID": str(session.account_id or ""),
"BEAVER_BRIDGE_BASE_URL": self.bridge_base_url,
"BEAVER_BRIDGE_TOKEN": self.bridge_token,
}
)
return subprocess.Popen(["node", str(script)], env=env, cwd=str(script.parent))
def _bridge_event_from_feishu(session: ConnectorSessionState, header: dict[str, Any], event: dict[str, Any]) -> dict[str, Any]:
message = dict(event.get("message") or {})
sender = dict(event.get("sender") or {})
sender_id = dict(sender.get("sender_id") or {})
peer_id = str(sender_id.get("open_id") or sender_id.get("user_id") or "")
message_id = str(message.get("message_id") or uuid4().hex)
event_id = str(header.get("event_id") or f"{session.channel_id}:{message_id}")
return {
"eventId": event_id,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"deliveryAttempt": 1,
"connectionId": session.connection_id,
"channelId": session.channel_id,
"kind": "feishu",
"accountId": session.account_id,
"peerId": peer_id,
"peerType": "group" if message.get("chat_type") == "group" else "dm",
"userId": peer_id,
"threadId": str(message.get("chat_id") or "") or None,
"messageId": message_id,
"messageType": str(message.get("message_type") or "text"),
"content": _extract_text(message),
"metadata": {"chatId": message.get("chat_id"), "rawMessageType": message.get("message_type")},
}
def _has_receiver_material(session: ConnectorSessionState) -> bool:
metadata = dict(session.metadata)
return bool(
session.status == "connected"
and str(metadata.get("appId") or "").strip()
and str(metadata.get("appSecret") or "").strip()
and session.connection_id
and session.channel_id
)
def _receiver_is_alive(receiver: object) -> bool:
poll = getattr(receiver, "poll", None)
if callable(poll):
return poll() is None
return True
def _extract_text(message: dict[str, Any]) -> str:
content = message.get("content")
if isinstance(content, str):
try:
parsed = json.loads(content)
except json.JSONDecodeError:
return content
text = parsed.get("text")
if text is not None:
return str(text)
return content
return ""
def _domain(options: dict[str, Any]) -> str:
domain = str(options.get("domain") or "feishu").strip().lower()
return "lark" if domain == "lark" else "feishu"
def _registration_base_url(domain: str) -> str:
return "https://accounts.larksuite.com" if domain == "lark" else "https://accounts.feishu.cn"
def _open_api_base_url(domain: str, configured_base_url: str) -> str:
if configured_base_url:
return configured_base_url.rstrip("/")
return "https://open.larksuite.com" if domain == "lark" else "https://open.feishu.cn"
def _with_onboard_from(value: str) -> str:
if not value:
return ""
parts = urlsplit(value)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query.setdefault("from", "onboard")
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
def _qr_svg_data_uri(value: str) -> str:
image = qrcode.make(value, image_factory=qrcode.image.svg.SvgPathImage)
buffer = BytesIO()
image.save(buffer)
return "data:image/svg+xml;base64," + base64.b64encode(buffer.getvalue()).decode("ascii")
def _create_instructions(event_callback_url: str) -> list[str]:
return [
"使用飞书客户端扫描二维码,选择一键创建飞书机器人。",
"创建完成后,点击打开机器人,在飞书中向机器人发送任意消息即可开始对话。",
"在飞书开放平台启用接收消息事件 im.message.receive_v1并选择长连接事件通道。",
f"如果使用 HTTP 回调模式,事件请求 URL 可设为 {event_callback_url}",
"如需用户身份授权,在飞书对话中发送 /feishu auth。",
"建议发送:学习一下我安装的新飞书插件,列出有哪些能力。",
"验证安装:在飞书对话中发送 /feishu start返回版本号代表安装成功。",
"如果 Windows 设备扫码异常,通常是终端二维码分辨率问题,可换用 Cmder 或使用本窗口二维码。",
]
def _link_instructions(event_callback_url: str) -> list[str]:
return [
"选择关联已有机器人时,请输入正确的 App ID 和 App Secret。",
"如果提示无效的 App ID 或 App Secret请回到飞书开放平台复制最新应用凭证。",
"在飞书开放平台启用接收消息事件 im.message.receive_v1并选择长连接事件通道。",
f"如果使用 HTTP 回调模式,事件请求 URL 可设为 {event_callback_url}",
]
def _connected_instructions() -> list[str]:
return [
"飞书机器人凭据已连接。",
"sidecar 会通过飞书长连接保持应用在线。",
"请确认飞书开放平台已启用接收消息事件 im.message.receive_v1。",
"在飞书对话中发送 /feishu start 验证插件返回版本号;发送任意消息验证 Beaver 是否收到。",
]

View File

@ -0,0 +1,281 @@
from __future__ import annotations
import json
import os
import re
import shlex
import subprocess
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Any
from external_connector.providers.fake import _session_view
from external_connector.state import SidecarStateStore
Runner = Callable[[list[str], str, float], tuple[int, str, str]]
def default_runner(command: list[str], cwd: str, timeout: float) -> tuple[int, str, str]:
completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False, timeout=timeout)
return completed.returncode, completed.stdout, completed.stderr
class VendorCliProvider:
provider_id = "vendor_cli"
def __init__(
self,
*,
store: SidecarStateStore,
env: Mapping[str, str] | None = None,
runner: Runner = default_runner,
) -> None:
self.store = store
self.env = env or os.environ
self.runner = runner
self.command_timeout_seconds = float(self.env.get("CONNECTOR_COMMAND_TIMEOUT_SECONDS") or 120)
def connectors(self) -> list[dict[str, Any]]:
return [
{
"kind": "weixin",
"displayName": "Weixin",
"authType": "qr",
"providerId": self.provider_id,
"capabilities": ["receive_text", "send_text", "receive_media", "direct_messages"],
},
{
"kind": "feishu",
"displayName": "Feishu/Lark",
"authType": "plugin_install",
"providerId": self.provider_id,
"capabilities": ["receive_text", "send_text", "receive_media", "groups"],
},
]
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"])
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 {}),
)
try:
command_template = self._command_template(kind)
except RuntimeError as exc:
session = self.store.update_session(session.session_id, status="error", error=str(exc))
return _session_view(session)
connector_home = Path(self.store.path).parent
state_dir = connector_home / kind / session.connection_id
state_dir.mkdir(parents=True, exist_ok=True)
command = shlex.split(command_template.format(state_dir=str(state_dir), connection_id=session.connection_id))
try:
code, stdout, stderr = self.runner(command, str(connector_home), self.command_timeout_seconds)
except subprocess.TimeoutExpired:
session = self.store.update_session(session.session_id, status="error", error="Provider command timed out")
return _session_view(session)
except Exception as exc:
session = self.store.update_session(session.session_id, status="error", error=_redact(str(exc)))
return _session_view(session)
if code != 0:
session = self.store.update_session(session.session_id, status="error", error=_redact(stderr or stdout))
return _session_view(session)
status = "connected" if "connected" in stdout.lower() else "waiting_for_user"
account_id = _extract_account_id(stdout)
session = self.store.update_session(
session.session_id,
status=status,
account_id=account_id,
metadata={"stateRef": str(state_dir)},
instructions=["Complete the vendor install or verification flow"] if status != "connected" else [],
)
return _session_view(session)
def get_session(self, session_id: str) -> dict[str, Any]:
session = self.store.get_session(session_id)
if session.status in {"cancelled", "error"}:
return _session_view(session)
command_template = self._optional_command_template(session.kind, "STATUS")
if not command_template:
return _session_view(session)
connector_home = Path(self.store.path).parent
state_dir = str(session.metadata.get("stateRef") or connector_home / session.kind / session.connection_id)
command = shlex.split(
command_template.format(
state_dir=state_dir,
connection_id=session.connection_id,
session_id=session.session_id,
)
)
try:
code, stdout, stderr = self.runner(command, str(connector_home), self.command_timeout_seconds)
except subprocess.TimeoutExpired:
session = self.store.update_session(session.session_id, status="error", error="Provider status command timed out")
return _session_view(session)
except Exception as exc:
session = self.store.update_session(session.session_id, status="error", error=_redact(str(exc)))
return _session_view(session)
if code != 0:
session = self.store.update_session(session.session_id, status="error", error=_redact(stderr or stdout))
return _session_view(session)
updates = _provider_output_updates(stdout)
if updates:
metadata = dict(session.metadata)
metadata.update(dict(updates.pop("metadata", {}) or {}))
updates["metadata"] = metadata
session = self.store.update_session(session.session_id, **updates)
return _session_view(session)
def cancel_session(self, session_id: str) -> None:
self.store.update_session(session_id, status="cancelled")
def logout(self, connection_id: str) -> None:
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}
command_template = self._optional_command_template(str(payload["kind"]), "SEND")
if not command_template:
provider_message_id = f"vendor_{payload['requestId']}"
self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id)
return {"ok": True, "providerMessageId": provider_message_id}
connector_home = Path(self.store.path).parent
state_dir = connector_home / str(payload["kind"]) / str(payload["connectionId"])
payload_path = _write_send_payload(state_dir, str(payload["requestId"]), payload)
command = shlex.split(
command_template.format(
state_dir=str(state_dir),
payload_path=str(payload_path),
connection_id=str(payload["connectionId"]),
channel_id=str(payload["channelId"]),
request_id=str(payload["requestId"]),
kind=str(payload["kind"]),
)
)
try:
code, stdout, stderr = self.runner(command, str(connector_home), self.command_timeout_seconds)
except subprocess.TimeoutExpired:
return {"ok": False, "error": "Provider send command timed out"}
except Exception as exc:
return {"ok": False, "error": _redact(str(exc))}
if code != 0:
return {"ok": False, "error": _redact(stderr or stdout)}
provider_message_id = _extract_provider_message_id(stdout) or f"vendor_{payload['requestId']}"
self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id)
return {"ok": True, "providerMessageId": provider_message_id}
def _command_template(self, kind: str) -> str:
key = "WEIXIN_CONNECT_COMMAND" if kind == "weixin" else "FEISHU_CONNECT_COMMAND"
command = str(self.env.get(key) or "").strip()
if not command:
raise RuntimeError(f"{key} is required")
return command
def _optional_command_template(self, kind: str, action: str) -> str | None:
prefix = "WEIXIN" if kind == "weixin" else "FEISHU"
command = str(self.env.get(f"{prefix}_{action}_COMMAND") or "").strip()
return command or None
def _extract_account_id(output: str) -> str | None:
for part in output.split():
if part.startswith("account="):
return part.split("=", 1)[1]
return None
def _provider_output_updates(output: str) -> dict[str, Any]:
text = output.strip()
if not text:
return {}
parsed: dict[str, Any] = {}
try:
raw = json.loads(text)
except json.JSONDecodeError:
raw = _parse_key_value_output(text)
if isinstance(raw, dict):
parsed = raw
updates: dict[str, Any] = {}
key_map = {
"status": "status",
"qrImage": "qr_image",
"qr_image": "qr_image",
"qrCode": "qr_code",
"qr_code": "qr_code",
"accountId": "account_id",
"account_id": "account_id",
"account": "account_id",
"displayName": "display_name",
"display_name": "display_name",
"error": "error",
}
for source_key, target_key in key_map.items():
value = parsed.get(source_key)
if value is not None:
updates[target_key] = str(value)
instructions = parsed.get("instructions")
if isinstance(instructions, list):
updates["instructions"] = [str(item) for item in instructions]
elif isinstance(instructions, str) and instructions.strip():
updates["instructions"] = [instructions.strip()]
metadata = parsed.get("metadata")
if isinstance(metadata, dict):
updates["metadata"] = metadata
return updates
def _parse_key_value_output(text: str) -> dict[str, str]:
values: dict[str, str] = {}
for part in text.split():
if "=" not in part:
continue
key, value = part.split("=", 1)
if key:
values[key] = value
return values
def _write_send_payload(state_dir: Path, request_id: str, payload: dict[str, Any]) -> Path:
sends_dir = state_dir / "sends"
sends_dir.mkdir(parents=True, exist_ok=True)
safe_request_id = re.sub(r"[^A-Za-z0-9_.-]+", "_", request_id) or "request"
payload_path = sends_dir / f"{safe_request_id}.json"
payload_path.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
return payload_path
def _extract_provider_message_id(output: str) -> str | None:
text = output.strip()
if not text:
return None
try:
raw = json.loads(text)
except json.JSONDecodeError:
raw = _parse_key_value_output(text)
if not isinstance(raw, dict):
return None
value = raw.get("providerMessageId") or raw.get("provider_message_id") or raw.get("messageId") or raw.get("message_id")
return str(value) if value is not None else None
def _redact(text: str) -> str:
redacted = re.sub(r"\bsecret-[A-Za-z0-9._:-]+\b", "***", text)
return re.sub(r"\b(appSecret|token|secret)=\S+", r"\1=***", redacted)

View File

@ -0,0 +1,463 @@
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

View File

@ -0,0 +1,203 @@
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from threading import Lock
from typing import Any
from uuid import uuid4
def iso_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(slots=True)
class ConnectorSessionState:
session_id: str
kind: str
connection_id: str
channel_id: str
display_name: str
status: str
options: dict[str, Any] = field(default_factory=dict)
qr_code: str | None = None
qr_image: str | None = None
instructions: list[str] = field(default_factory=list)
account_id: str | None = None
error: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=iso_now)
updated_at: str = field(default_factory=iso_now)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ConnectorSessionState":
return cls(
session_id=str(data.get("session_id") or ""),
kind=str(data.get("kind") or ""),
connection_id=str(data.get("connection_id") or ""),
channel_id=str(data.get("channel_id") or ""),
display_name=str(data.get("display_name") or ""),
status=str(data.get("status") or "pending"),
options=dict(data.get("options") or {}),
qr_code=str(data["qr_code"]) if data.get("qr_code") is not None else None,
qr_image=str(data["qr_image"]) if data.get("qr_image") is not None else None,
instructions=[str(item) for item in data.get("instructions") or []],
account_id=str(data["account_id"]) if data.get("account_id") is not None else None,
error=str(data["error"]) if data.get("error") is not None else None,
metadata=dict(data.get("metadata") or {}),
created_at=str(data.get("created_at") or iso_now()),
updated_at=str(data.get("updated_at") or iso_now()),
)
@dataclass(slots=True)
class SendBeginResult:
should_send: bool
dedupe_key: str
status: str
http_status: int
retry_after_seconds: int | None = None
provider_message_id: str | None = None
class SidecarStateStore:
def __init__(self, path: Path, *, send_processing_ttl_seconds: int = 60) -> None:
self.path = Path(path)
self.send_processing_ttl_seconds = int(send_processing_ttl_seconds)
self._lock = Lock()
def create_session(
self,
*,
kind: str,
connection_id: str,
channel_id: str,
display_name: str,
options: dict[str, Any],
) -> ConnectorSessionState:
session = ConnectorSessionState(
session_id=f"cs_{uuid4().hex}",
kind=kind,
connection_id=connection_id,
channel_id=channel_id,
display_name=display_name,
status="pending",
options=dict(options),
)
with self._lock:
data = self._load()
data["sessions"][session.session_id] = session.to_dict()
self._save(data)
return session
def get_session(self, session_id: str) -> ConnectorSessionState:
data = self._load()
raw = data["sessions"].get(session_id)
if not isinstance(raw, dict):
raise KeyError(session_id)
return ConnectorSessionState.from_dict(raw)
def list_sessions(self) -> list[ConnectorSessionState]:
data = self._load()
return [
ConnectorSessionState.from_dict(raw)
for raw in data["sessions"].values()
if isinstance(raw, dict)
]
def find_session_by_connection_id(self, connection_id: str) -> ConnectorSessionState:
data = self._load()
matches: list[ConnectorSessionState] = []
for raw in data["sessions"].values():
if not isinstance(raw, dict):
continue
session = ConnectorSessionState.from_dict(raw)
if session.connection_id == connection_id:
matches.append(session)
if not matches:
raise KeyError(connection_id)
matches.sort(key=lambda item: item.updated_at)
return matches[-1]
def update_session(self, session_id: str, **updates: Any) -> ConnectorSessionState:
with self._lock:
data = self._load()
raw = data["sessions"].get(session_id)
if not isinstance(raw, dict):
raise KeyError(session_id)
session = ConnectorSessionState.from_dict(raw)
for key, value in updates.items():
if hasattr(session, key):
setattr(session, key, value)
session.updated_at = iso_now()
data["sessions"][session_id] = session.to_dict()
self._save(data)
return session
def begin_send(self, *, connection_id: str, request_id: str) -> SendBeginResult:
dedupe_key = f"{connection_id}:{request_id}"
with self._lock:
data = self._load()
existing = data["sends"].get(dedupe_key)
if isinstance(existing, dict):
status = str(existing.get("status") or "processing")
if status == "completed":
provider_message_id = str(existing.get("provider_message_id") or "")
return SendBeginResult(False, dedupe_key, "completed", 200, None, provider_message_id)
if status == "processing" and not self._send_is_stale(existing):
return SendBeginResult(False, dedupe_key, "processing", 409, 5)
data["sends"][dedupe_key] = {
"connection_id": connection_id,
"request_id": request_id,
"status": "processing",
"updated_at": iso_now(),
}
self._save(data)
return SendBeginResult(True, dedupe_key, "processing", 200)
def complete_send(self, dedupe_key: str, *, provider_message_id: str | None) -> None:
with self._lock:
data = self._load()
item = dict(data["sends"].get(dedupe_key) or {})
item.update({"status": "completed", "provider_message_id": provider_message_id, "updated_at": iso_now()})
data["sends"][dedupe_key] = item
self._save(data)
def fail_send(self, dedupe_key: str, *, error: str | None) -> None:
with self._lock:
data = self._load()
item = dict(data["sends"].get(dedupe_key) or {})
item.update({"status": "failed", "last_error": error, "updated_at": iso_now()})
data["sends"][dedupe_key] = item
self._save(data)
def _send_is_stale(self, item: dict[str, Any]) -> bool:
updated_at = str(item.get("updated_at") or iso_now())
updated = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
return (datetime.now(timezone.utc) - updated).total_seconds() >= self.send_processing_ttl_seconds
def _load(self) -> dict[str, Any]:
if not self.path.exists():
return {"sessions": {}, "sends": {}}
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"sessions": {}, "sends": {}}
if not isinstance(data, dict):
return {"sessions": {}, "sends": {}}
if not isinstance(data.get("sessions"), dict):
data["sessions"] = {}
if not isinstance(data.get("sends"), dict):
data["sends"] = {}
return data
def _save(self, data: dict[str, Any]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(self.path)