ForcePilot/backend/package/yuxi/channel/extensions/yuanbao/plugin.py
Kris 5946478772 feat(channel): 添加小红书、XMPP、元宝和 Zalo 渠道扩展
新增小红书、XMPP、元宝、Zalo 四个渠道扩展。

小红书渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, dedupe, media, status, window

XMPP 渠道扩展主要模块:plugin, config, gateway, outbound, streaming, pairing, security, dedupe, accounts, commands, muc, rate_limiter, stanza_utils, status, monitor

元宝渠道扩展主要模块:plugin, client, config_schema, gateway, outbound(chunk/queue/transport), inbound(dispatcher), streaming, pairing, security, accounts, actions, commands, codec(biz/conn), session, shared, utils

Zalo 渠道扩展主要模块:api, config, gateway, webhook, outbound, pairing, security, session, polling, monitor, status
2026-05-21 12:04:05 +08:00

298 lines
11 KiB
Python

from __future__ import annotations
import logging
from yuxi.channel.capabilities import ChannelCapabilities
from yuxi.channel.extensions.base import BaseChannelPlugin
from yuxi.channel.extensions.yuanbao.accounts import (
ResolvedYuanbaoAccount,
TokenCache,
resolve_yuanbao_account,
)
from yuxi.channel.extensions.yuanbao.gateway import YuanbaoGateway
from yuxi.channel.extensions.yuanbao.security import check_dm_access, check_group_access
logger = logging.getLogger("yuxi.channel.yuanbao")
class YuanbaoPlugin(BaseChannelPlugin):
id = "yuanbao"
name = "Yuanbao (元宝)"
order = 85
label = "Yuanbao"
aliases = ["yuanbao", "yb", "tencent-yuanbao", "元宝"]
def __init__(self):
self._token_cache = TokenCache()
self._accounts: dict[str, ResolvedYuanbaoAccount] = {}
self._gateways: dict[str, YuanbaoGateway] = {}
self._reply_to_modes: dict[str, str] = {}
self._default_account_id: str | None = None
self._seen_msg_keys: set[str] = set()
self._max_seen = 10000
@property
def capabilities(self) -> ChannelCapabilities:
return ChannelCapabilities(
chat_types=["direct", "group"],
message_types=["text", "image", "file", "audio", "video", "sticker"],
reactions=False,
typing_indicator=True,
threads=False,
edit=False,
unsend=False,
reply=True,
media=True,
native_commands=True,
polls=False,
streaming=True,
streaming_mode="block",
block_streaming=True,
block_streaming_chunk_min_chars=2800,
block_streaming_chunk_max_chars=3000,
block_streaming_coalesce_min_chars=2800,
block_streaming_coalesce_max_chars=3000,
block_streaming_coalesce_idle_ms=1000,
)
def list_account_ids(self, config: dict) -> list[str]:
accounts = config.get("channels", {}).get("yuanbao", {}).get("accounts", {})
return list(accounts.keys())
def is_configured(self, account: dict) -> bool:
app_key = account.get("appKey")
app_secret = account.get("appSecret")
token = account.get("token")
return bool((app_key and app_secret) or token)
def is_enabled(self, account: dict) -> bool:
return account.get("enabled", True)
def disabled_reason(self, account: dict) -> str:
if not account.get("enabled", True):
return "账户已被禁用"
if not self.is_configured(account):
return "缺少 AppKey/AppSecret 或 Token 配置"
return ""
def describe_account(self, account: dict) -> dict:
return {
"account_id": account.get("account_id", ""),
"name": account.get("name", ""),
"api_domain": account.get("apiDomain", "bot.yuanbao.tencent.com"),
}
async def resolve_account(self, account_id: str) -> dict:
account = self._accounts.get(account_id)
if account is None:
return {}
return {
"account_id": account.account_id,
"name": account.name,
"api_domain": account.api_domain,
"enabled": account.enabled,
}
async def resolve_allow_from(self, config: dict, account_id: str) -> list[str] | None:
raw = config.get("channels", {}).get("yuanbao", {}).get("accounts", {}).get(account_id, {})
whitelist = raw.get("debugWhitelist")
return whitelist if whitelist else None
async def start(self, ctx) -> object:
account = await resolve_yuanbao_account(ctx.account_id, ctx.config)
self._accounts[ctx.account_id] = account
account_cfg = (
ctx.config.get("channels", {})
.get("yuanbao", {})
.get("accounts", {})
.get(ctx.account_id, {})
)
self._reply_to_modes[ctx.account_id] = account_cfg.get("replyToMode", "first")
if account_cfg.get("defaultAccount", False):
self._default_account_id = ctx.account_id
elif self._default_account_id is None:
self._default_account_id = ctx.account_id
gateway = self._gateways.get(ctx.account_id)
if gateway is None:
gateway = YuanbaoGateway(
account=account,
token_cache=self._token_cache,
queue=ctx.queue,
cancel_event=ctx.cancel_event,
logger=ctx.logger or logger,
)
self._gateways[ctx.account_id] = gateway
await gateway.start()
return gateway
async def stop(self, ctx) -> None:
gateway = self._gateways.pop(ctx.account_id, None)
if gateway:
await gateway.stop()
if self._default_account_id == ctx.account_id:
self._default_account_id = None
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> None:
if not content:
return
gateway = self._get_gateway(account_id)
if gateway is None:
logger.error("No gateway available for account %s", account_id)
return
await gateway.send_text(target_id, content, reply_to_id=reply_to_id)
async def send_media(
self,
target_id: str,
media_url: str,
media_type: str,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> None:
gateway = self._get_gateway(account_id)
if gateway is None:
return
await gateway.send_media(target_id, media_url, media_type, reply_to_id=reply_to_id)
async def send_typing(self, target_id: str, thread_id: str | None = None) -> None:
gateway = self._get_gateway(None)
if gateway is not None and gateway.is_connected:
await gateway.send_typing(target_id)
async def probe(self, account: dict) -> bool:
try:
acct = await resolve_yuanbao_account(account.get("account_id", "default"), account)
await self._token_cache.get(acct)
return True
except Exception:
return False
async def check_ready(self, account_id: str) -> bool:
gateway = self._gateways.get(account_id)
return gateway is not None and gateway.is_connected
def build_summary(self, snapshot: object) -> dict:
from yuxi.channel.protocols import build_standard_summary
return build_standard_summary(snapshot, self.id)
def config_schema(self) -> dict:
from yuxi.channel.extensions.yuanbao.config_schema import YUANBAO_CONFIG_SCHEMA
return YUANBAO_CONFIG_SCHEMA
def resolve_reply_to_mode(
self,
config: dict,
account_id: str | None = None,
chat_type: str | None = None,
) -> str:
if account_id and account_id in self._reply_to_modes:
return self._reply_to_modes[account_id]
return "first"
def resolve_reply_transport(self, msg: object, thread_id: str | None):
from yuxi.channel.protocols import ReplyTransport
transport = ReplyTransport()
reply_to_id = getattr(msg, "reply_to_id", None)
if reply_to_id:
transport.reply_to_id = reply_to_id
account_id = getattr(msg, "account_id", None)
transport.mode = self.resolve_reply_to_mode({}, account_id)
return transport
def resolve_session(self, msg: object):
from yuxi.channel.routing.models import PeerKind
from yuxi.channel.protocols import SessionResolution
if hasattr(msg, "sender") and hasattr(msg.sender, "kind"):
if msg.sender.kind == PeerKind.DIRECT:
return SessionResolution(kind="direct", conversation_id=msg.sender.id)
gid = msg.group.id if hasattr(msg, "group") and msg.group and msg.group.id else "unknown"
return SessionResolution(kind="group", conversation_id=gid)
async def check_allowlist(self, peer_id: str, channel_type: str) -> bool:
for account in self._accounts.values():
cfg = getattr(account, "raw", {})
whitelist = cfg.get("debugWhitelist", [])
if channel_type == "direct":
policy = cfg.get("dmPolicy", "open")
if not check_dm_access(peer_id, policy, whitelist):
return False
else:
if not check_group_access(peer_id, "open", whitelist):
return False
return True
def resolve_dm_policy(self) -> dict:
return {"mode": "open", "allow_from": []}
def is_duplicate(self, key: str) -> bool:
return key in self._seen_msg_keys
def mark_seen(self, key: str) -> None:
self._seen_msg_keys.add(key)
if len(self._seen_msg_keys) > self._max_seen:
to_remove = list(self._seen_msg_keys)[: len(self._seen_msg_keys) // 2]
self._seen_msg_keys.difference_update(to_remove)
def sanitize_text(self, text: str, payload: object | None = None) -> str:
return text
def build_context_note(self, context) -> str:
return ""
def build_system_prompt(self, context) -> str | None:
return (
"你正在通过腾讯元宝与用户交互。元宝支持 Markdown 消息格式,"
"包括代码块、加粗、斜体、删除线、标题(H1-H3)、有序/无序列表、"
"引用和超链接。消息会按 ~3000 字符自动分块发送。"
"请使用简洁清晰的格式回复,仅对代码片段使用代码围栏。"
)
@property
def channel_format_instructions(self) -> str | None:
return (
"Yuanbao supports Markdown messages with inline code, fenced code blocks, "
"bold, italic, strikethrough, headers (H1-H3), ordered/unordered lists, "
"blockquotes, and hyperlinks. Messages are chunked at ~3000 characters. "
"Do not wrap entire responses in markdown code fences — "
"use them only for actual code snippets."
)
@property
def markdown_hint_enabled(self) -> bool:
return True
def classify_error(self, error: BaseException) -> object:
from yuxi.channel.errors import classify_error as _classify_error
return _classify_error(error)
def _get_gateway(self, account_id: str | None) -> YuanbaoGateway | None:
if account_id:
return self._gateways.get(account_id)
if self._default_account_id:
gw = self._gateways.get(self._default_account_id)
if gw and gw.is_connected:
return gw
for gw in self._gateways.values():
if gw.is_connected:
return gw
return None