feat: 添加MinIO文件系统支持并优化外部连接器功能

- 添加MinIO用户文件系统配置选项(BEAVER_MINIO_ROOT_USER等)
- 更新外部连接器配置结构,包括BASE_URL和认证令牌设置
- 改进connector provider支持更多类型(official, feishu_bot等)
- 实现Mistral模型推理模式支持reasoning_effort参数
- 增强外部连接器策略配置和运行时配置管理
- 添加connector bridge事件验证和安全保护机制
- 优化任务路由逻辑,区分simple_chat和new_task场景
- 更新初始技能工具提示配置,分离authoring admin功能
This commit is contained in:
2026-06-05 11:46:40 +08:00
parent 236ac19789
commit 2c5205b06e
120 changed files with 8321 additions and 1865 deletions

View File

@ -89,6 +89,7 @@ class FeishuBotProvider:
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)
metadata.update(_policy_metadata(options))
if not app_id or not app_secret:
if mode != "link":
return self._start_registration_session(session, metadata=metadata, domain=domain)
@ -157,34 +158,44 @@ class FeishuBotProvider:
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)
peer_type = str(target.get("peerType") or "dm")
receive_id_type = "chat_id" if peer_type == "group" else "open_id"
receive_id = str(target.get("threadId") or target.get("peerId") or "") if receive_id_type == "chat_id" else str(target.get("peerId") or "")
chunks = _text_chunks(str(payload.get("content") or ""), _positive_int(metadata.get("maxMessageChars"), default=20000))
provider_message_ids: list[str] = []
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()
for chunk in chunks:
response = self.http.post(
f"{api_base}/open-apis/im/v1/messages?receive_id_type={receive_id_type}",
json={
"receive_id": receive_id,
"msg_type": "text",
"content": json.dumps({"text": chunk}, ensure_ascii=False),
},
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
timeout=20,
)
response.raise_for_status()
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_ids.append(str((data.get("data") or {}).get("message_id") or f"feishu_{payload['requestId']}"))
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']}")
provider_message_id = ",".join(provider_message_ids) 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:
token = str(payload.get("token") or (payload.get("header") or {}).get("token") or "")
if not token or self._session_for_verification_token(token) is None:
return {"ok": False, "error": "verification token is required", "httpStatus": 401}
return {"challenge": challenge}
header = dict(payload.get("header") or {})
event = dict(payload.get("event") or {})
@ -192,9 +203,14 @@ class FeishuBotProvider:
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:
if not expected_token or received_token != expected_token:
return {"ok": False, "error": "invalid verification token", "httpStatus": 401}
ignored = _ignore_reason(event)
if ignored:
return {"ok": True, "ignored": ignored}
bridge_event = _bridge_event_from_feishu(session, header, event)
if bridge_event is None:
return {"ok": True, "ignored": "empty_or_oversized"}
self.bridge_post(
f"{self.bridge_base_url}/api/channel-connector-bridge/events",
bridge_event,
@ -234,6 +250,18 @@ class FeishuBotProvider:
return session
raise KeyError(app_id)
def _session_for_verification_token(self, token: str) -> ConnectorSessionState | None:
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 token
and session.metadata.get("verificationToken") == token
):
return session
return None
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()
@ -415,6 +443,15 @@ class FeishuBotProvider:
"FEISHU_CONNECTION_ID": session.connection_id,
"FEISHU_CHANNEL_ID": session.channel_id,
"FEISHU_ACCOUNT_ID": str(session.account_id or ""),
"FEISHU_REQUIRE_MENTION_IN_GROUPS": _env_bool(metadata.get("requireMentionInGroups"), default=True),
"FEISHU_RESPOND_TO_MENTION_ALL": _env_bool(metadata.get("respondToMentionAll"), default=False),
"FEISHU_DM_MODE": str(metadata.get("dmMode") or "open"),
"FEISHU_ALLOW_FROM": ",".join(_string_list(metadata.get("allowFrom"))),
"FEISHU_GROUP_ALLOW_FROM": ",".join(_string_list(metadata.get("groupAllowFrom"))),
"FEISHU_MAX_MESSAGE_CHARS": str(_positive_int(metadata.get("maxMessageChars"), default=20000)),
"FEISHU_TEXT_BATCH_DELAY_MS": str(_positive_int(metadata.get("textBatchDelayMs"), default=0)),
"FEISHU_TEXT_BATCH_MAX_MESSAGES": str(_positive_int(metadata.get("textBatchMaxMessages"), default=10)),
"FEISHU_TEXT_BATCH_MAX_CHARS": str(_positive_int(metadata.get("textBatchMaxChars"), default=20000)),
"BEAVER_BRIDGE_BASE_URL": self.bridge_base_url,
"BEAVER_BRIDGE_TOKEN": self.bridge_token,
}
@ -422,13 +459,20 @@ class FeishuBotProvider:
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]:
def _bridge_event_from_feishu(session: ConnectorSessionState, header: dict[str, Any], event: dict[str, Any]) -> dict[str, Any] | None:
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 "")
is_group = message.get("chat_type") == "group"
sender_open_id = str(sender_id.get("open_id") or sender_id.get("user_id") or "")
chat_id = str(message.get("chat_id") or "")
peer_id = chat_id if is_group else sender_open_id
message_id = str(message.get("message_id") or uuid4().hex)
event_id = str(header.get("event_id") or f"{session.channel_id}:{message_id}")
content = _extract_text(message).strip()
max_chars = _positive_int(session.metadata.get("maxMessageChars"), default=20000)
if not content or len(content) > max_chars:
return None
return {
"eventId": event_id,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
@ -438,13 +482,18 @@ def _bridge_event_from_feishu(session: ConnectorSessionState, header: dict[str,
"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,
"peerType": "group" if is_group else "dm",
"userId": sender_open_id,
"threadId": chat_id 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")},
"content": content,
"metadata": {
"chatId": message.get("chat_id"),
"rawMessageType": message.get("message_type"),
"senderType": sender.get("sender_type"),
"mentions": message.get("mentions") if isinstance(message.get("mentions"), list) else [],
},
}
@ -480,6 +529,97 @@ def _extract_text(message: dict[str, Any]) -> str:
return ""
def _text_chunks(text: str, max_chars: int) -> list[str]:
cleaned = str(text or "")
if not cleaned:
return [""]
size = max(1, int(max_chars))
return [cleaned[index : index + size] for index in range(0, len(cleaned), size)]
def _ignore_reason(event: dict[str, Any]) -> str:
sender = dict(event.get("sender") or {})
sender_type = str(sender.get("sender_type") or "").strip().lower()
if sender_type and sender_type != "user":
return f"sender_type:{sender_type}"
message = dict(event.get("message") or {})
content = _extract_text(message).strip()
if content.startswith("/feishu"):
return "feishu_command"
return ""
def _policy_metadata(options: dict[str, Any]) -> dict[str, Any]:
metadata: dict[str, Any] = {}
for key in ("allowFrom", "allow_from"):
items = _string_list(options.get(key))
if items:
metadata["allowFrom"] = items
break
for key in ("groupAllowFrom", "group_allow_from"):
items = _string_list(options.get(key))
if items:
metadata["groupAllowFrom"] = items
break
if "requireMentionInGroups" in options or "require_mention_in_groups" in options:
metadata["requireMentionInGroups"] = _bool(options.get("requireMentionInGroups", options.get("require_mention_in_groups")))
else:
metadata["requireMentionInGroups"] = True
if "respondToMentionAll" in options or "respond_to_mention_all" in options:
metadata["respondToMentionAll"] = _bool(options.get("respondToMentionAll", options.get("respond_to_mention_all")))
dm_mode = str(options.get("dmMode") or options.get("dm_mode") or "open").strip()
metadata["dmMode"] = dm_mode if dm_mode in {"open", "allowlist", "pair", "disabled"} else "open"
for key, metadata_key, default in (
("maxMessageChars", "maxMessageChars", 20000),
("textBatchDelayMs", "textBatchDelayMs", 0),
("textBatchMaxMessages", "textBatchMaxMessages", 10),
("textBatchMaxChars", "textBatchMaxChars", 20000),
):
alt_key = _camel_to_snake(key)
if key in options or alt_key in options:
metadata[metadata_key] = _positive_int(options.get(key, options.get(alt_key)), default=default)
return metadata
def _string_list(value: Any) -> list[str]:
if isinstance(value, str):
raw_items = value.replace("\n", ",").split(",")
elif isinstance(value, list):
raw_items = value
else:
raw_items = []
return [str(item).strip() for item in raw_items if str(item).strip()]
def _bool(value: Any) -> bool:
if isinstance(value, bool):
return value
return str(value).strip().lower() in {"1", "true", "yes", "on"}
def _positive_int(value: Any, *, default: int) -> int:
try:
number = int(value)
except (TypeError, ValueError):
return default
return number if number > 0 else default
def _env_bool(value: Any, *, default: bool) -> str:
if value is None:
return "true" if default else "false"
return "true" if _bool(value) else "false"
def _camel_to_snake(value: str) -> str:
result: list[str] = []
for char in value:
if char.isupper() and result:
result.append("_")
result.append(char.lower())
return "".join(result)
def _domain(options: dict[str, Any]) -> str:
domain = str(options.get("domain") or "feishu").strip().lower()
return "lark" if domain == "lark" else "feishu"

View File

@ -45,6 +45,7 @@ class WeixinIlinkProvider:
self.start_receivers = start_receivers
self._receiver_stops: dict[str, threading.Event] = {}
self._receiver_lock = threading.Lock()
self._start_existing_connected_receivers()
def connectors(self) -> list[dict[str, Any]]:
return [
@ -303,13 +304,45 @@ class WeixinIlinkProvider:
self._receiver_stops[session.connection_id] = stop
thread = threading.Thread(target=self._receiver_loop, args=(session.connection_id, stop), daemon=True)
thread.start()
print(
json.dumps(
{
"event": "weixin_receiver_started",
"connectionId": session.connection_id,
"channelId": session.channel_id,
},
ensure_ascii=False,
),
flush=True,
)
return None
def _start_existing_connected_receivers(self) -> None:
if not self.start_receivers:
return None
for session in self.store.list_sessions():
if session.kind != "weixin" or session.status != "connected":
continue
if _has_connection_material(session):
self._ensure_receiver(session)
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:
except Exception as exc:
print(
json.dumps(
{
"event": "weixin_receiver_error",
"connectionId": connection_id,
"error": str(exc)[:300],
},
ensure_ascii=False,
),
flush=True,
)
time.sleep(5)
stop.wait(1)