feat: implement channel runtime connectors
This commit is contained in:
1
external-connector/external_connector/__init__.py
Normal file
1
external-connector/external_connector/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Generic external connector sidecar."""
|
||||
74
external-connector/external_connector/app.py
Normal file
74
external-connector/external_connector/app.py
Normal 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
|
||||
62
external-connector/external_connector/main.py
Normal file
62
external-connector/external_connector/main.py
Normal 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()
|
||||
24
external-connector/external_connector/models.py
Normal file
24
external-connector/external_connector/models.py
Normal 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)
|
||||
149
external-connector/external_connector/node/feishu_ws_receiver.js
Normal file
149
external-connector/external_connector/node/feishu_ws_receiver.js
Normal 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, "***");
|
||||
}
|
||||
28
external-connector/external_connector/providers/base.py
Normal file
28
external-connector/external_connector/providers/base.py
Normal 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]:
|
||||
...
|
||||
70
external-connector/external_connector/providers/composite.py
Normal file
70
external-connector/external_connector/providers/composite.py
Normal 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
|
||||
119
external-connector/external_connector/providers/fake.py
Normal file
119
external-connector/external_connector/providers/fake.py
Normal 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}
|
||||
542
external-connector/external_connector/providers/feishu_bot.py
Normal file
542
external-connector/external_connector/providers/feishu_bot.py
Normal 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 是否收到。",
|
||||
]
|
||||
281
external-connector/external_connector/providers/vendor_cli.py
Normal file
281
external-connector/external_connector/providers/vendor_cli.py
Normal 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)
|
||||
463
external-connector/external_connector/providers/weixin_ilink.py
Normal file
463
external-connector/external_connector/providers/weixin_ilink.py
Normal 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
|
||||
203
external-connector/external_connector/state.py
Normal file
203
external-connector/external_connector/state.py
Normal 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)
|
||||
Reference in New Issue
Block a user