新增小红书、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
298 lines
11 KiB
Python
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 |