ForcePilot/backend/package/yuxi/channel/extensions/base.py
Kris fb754d82f1 feat(channel): 添加全渠道网关核心基础架构
新增渠道扩展框架的核心基础层,为所有渠道扩展提供统一的基础设施。

主要变更:
- channel/extensions/__init__.py: 渠道扩展包初始化与注册
- channel/extensions/base.py: 渠道插件基类,定义统一接口规范
- channel_service.py: 渠道服务核心业务逻辑
- models_channels.py: 渠道相关数据库模型
- 8 个 channel_*_repo.py: 渠道数据仓库层
- storage/postgres/manager.py: 数据库管理器,新增渠道表
- models_business.py: 业务模型更新
- config/app.py: 应用配置更新
- conversation_repository.py: 会话仓库更新
- oidc_service.py: OIDC 服务更新
- logging_config.py: 日志配置更新
2026-05-21 12:08:03 +08:00

612 lines
18 KiB
Python

from yuxi.channel.capabilities import ChannelCapabilities
class BaseChannelPlugin:
"""可选的渠道插件基类,提供默认实现。渠道不强制继承,满足 Protocol 即可。"""
id: str = ""
name: str = ""
order: int = 99
label: str | None = None
aliases: list[str] | None = None
resolve_reply_to_mode: str | None = None
@property
def capabilities(self) -> ChannelCapabilities:
return ChannelCapabilities()
async def resolve_account(self, account_id: str) -> dict:
return {}
async def list_accounts(self) -> list[dict]:
return []
def list_account_ids(self, config: dict) -> list[str]:
return ["default"]
def is_configured(self, account: dict) -> bool:
return bool(account)
def is_enabled(self, account: dict) -> bool:
return True
def disabled_reason(self, account: dict) -> str:
return ""
async def resolve_allow_from(self, config: dict, account_id: str) -> list[str] | None:
return None
def describe_account(self, account: dict) -> dict:
return {"account_id": account.get("account_id", "")}
async def start(self, ctx) -> object:
pass
async def stop(self, ctx) -> None:
pass
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:
pass
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,
) -> None:
pass
async def probe(self, account: dict) -> bool:
return True
def build_summary(self, snapshot: object) -> dict:
return {}
async def resolve_thread_id(self, account_id: str, reply_to_id: str | None) -> str | None:
return None
async def send_typing(self, target_id: str, thread_id: str | None = None) -> None:
pass
async def clear_typing(self, target_id: str, thread_id: str | None = None) -> None:
pass
async def check_ready(self, account_id: str) -> bool:
return True
# ── HeartbeatProtocol ────────────────────────────────
@property
def ping_interval_ms(self) -> int:
return 30000
async def on_ping(self, account: dict) -> bool:
return True
async def start_typing(self, target_id: str, account: dict) -> object:
return None
async def stop_typing(self, handle: object) -> None:
pass
async def typing_indicator(
self,
target_id: str,
account: dict,
_stop_event,
) -> None:
pass
# ── AgentPromptProtocol ──────────────────────────────
def build_system_prompt(self, context) -> str | None:
return None
def build_context_note(self, context) -> str:
return ""
@property
def channel_format_instructions(self) -> str | None:
return None
# ── ThreadingProtocol ────────────────────────────────
def extract_thread_id(self, msg: object) -> str | None:
return None
def resolve_reply_transport(self, msg: object, thread_id: str | None):
from yuxi.channel.protocols import ReplyTransport
return ReplyTransport()
def get_thread_info(self, thread_id: str):
return None
# ── MessagingProtocol ────────────────────────────────
def resolve_session(self, msg: object):
from yuxi.channel.message.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)
def parse_explicit_target(self, content: str) -> str | None:
return None
def build_reply_payload(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
session_key: str | None = None,
agent_config_id: str | None = None,
**kwargs,
):
from yuxi.channel.protocols import BoundReplyPayload
return BoundReplyPayload(target_id=target_id, content=content, reply_to_id=reply_to_id, thread_id=thread_id)
def build_attachment_refs(self, attachments: list[dict]) -> str:
lines = []
for att in attachments:
ft = att.get("file_type", "file")
fp = att.get("file_path", "")
label = {"image": "图片", "video": "视频"}.get(ft, "文件")
lines.append(f"[{label}: {fp}]")
return "\n".join(lines)
def resolve_focused_binding(self, msg: object, session, current_binding: object) -> dict | None:
return None
@property
def supported_explicit_targets(self) -> list[str]:
return []
# ── AgentToolProtocol ────────────────────────────────
def get_agent_tools(self) -> list:
return []
async def execute_agent_tool(self, tool_name: str, params: dict, context: dict) -> dict:
return {"success": False, "error": f"Unknown tool: {tool_name}"}
# ── MessageActionProtocol ────────────────────────────
@property
def actions(self):
"""消息动作适配器。子类重写以注册支持的动作。
用法::
class MyPlugin(BaseChannelPlugin):
@property
def actions(self):
if self._actions is None:
self._actions = MessageActionRegistry()
self._actions.register(
MessageAction.SEND,
self._handle_send,
description="发送文本消息",
)
return self._actions
"""
return None
def get_message_actions(self) -> list:
if self.actions:
return self.actions.get_message_actions()
return []
async def execute_message_action(self, action: str, params: dict, context: dict) -> dict:
if self.actions:
result = await self.actions.execute_action(action, params, context)
return result
return {"success": False, "error": f"Unknown action: {action}"}
# ── SecurityProtocol ────────────────────────────────
async def check_allowlist(self, peer_id: str, channel_type: str) -> bool:
return True
def resolve_dm_policy(self) -> dict:
return {"mode": "open", "allow_from": []}
# ── PairingProtocol ─────────────────────────────────
async def generate_code(self, peer_id: str) -> str:
return ""
async def verify_code(self, peer_id: str, code: str) -> bool:
return True
# ── GroupsProtocol ──────────────────────────────────
async def list_groups(self) -> list[dict]:
return []
# ── MentionsProtocol ────────────────────────────────
def extract_mentions(self, raw_message: dict) -> list[str]:
return []
# ── LifecycleProtocol ───────────────────────────────
async def on_config_changed(self, prev_cfg: dict, next_cfg: dict, account_id: str) -> None:
pass
async def on_account_removed(self, account_id: str) -> None:
pass
async def on_retire(self) -> None:
pass
async def run_startup_maintenance(self, cfg: dict) -> None:
pass
# ── ApprovalProtocol ────────────────────────────────
async def check_approval_required(self, config: dict, action, initiator_peer_id: str) -> bool:
approval_cfg = config.get("approval", {})
if not approval_cfg.get("enabled", False):
return False
approvers = approval_cfg.get("approvers", [])
if not approvers or initiator_peer_id in approvers:
return False
return True
async def create_approval_request(
self, config: dict, action, initiator_peer_id: str, description: str, context: dict
):
from yuxi.channel.protocols import ApprovalRequest
return ApprovalRequest(
id="",
channel_type="",
account_id="",
initiator_peer_id=initiator_peer_id,
action=action,
description=description,
)
async def send_approval_notification(self, config: dict, request, approver_peer_ids: list[str]) -> bool:
return False
async def check_approval_status(self, config: dict, request_id: str):
return None
def get_approver_ids(self, config: dict) -> list[str]:
return config.get("approval", {}).get("approvers", [])
# ── ConfigSchemaProtocol ────────────────────────────
def config_schema(self) -> dict:
return {}
# ── SetupProtocol ───────────────────────────────────
async def setup(self, config: dict) -> bool:
return True
# ── SetupWizardProtocol ─────────────────────────────
def setup_wizard_steps(self) -> list:
return []
async def validate_wizard_input(self, step_key: str, value: str) -> str | None:
return None
# ── AuthProtocol ────────────────────────────────────
async def login(self, config: dict) -> dict:
return {}
async def logout(self, config: dict) -> None:
pass
async def refresh_token(self, config: dict) -> dict | None:
return None
async def qr_auth_start(self, config: dict) -> str:
return ""
async def qr_auth_wait(self, config: dict) -> dict | None:
return None
# ── ElevatedProtocol ────────────────────────────────
async def elevate(self, account: dict, reason: str) -> bool:
return True
async def revoke(self, account: dict) -> None:
pass
@property
def is_elevated(self) -> bool:
return False
# ── AllowlistManagementProtocol ─────────────────────
async def list_allow_entries(self, config: dict, scope: str) -> list[str]:
return []
async def add_allow_entry(self, config: dict, scope: str, entry: str) -> bool:
return False
async def remove_allow_entry(self, config: dict, scope: str, entry: str) -> bool:
return False
# ── ReactionProtocol ─────────────────────────────────
async def send_reaction(
self,
target_id: str,
message_id: str,
emoji: str,
*,
account_id: str | None = None,
) -> None:
pass
async def remove_reaction(
self,
target_id: str,
message_id: str,
emoji: str,
*,
account_id: str | None = None,
) -> None:
pass
async def fetch_reactions(
self,
target_id: str,
message_id: str,
*,
account_id: str | None = None,
) -> list[dict]:
return []
# ── WebhookProtocol ──────────────────────────────────
async def handle_webhook(
self,
request: object,
account_id: str | None = None,
) -> object:
return None
def verify_signature(self, body: bytes, signature: str, secret: str) -> bool:
return False
def resolve_webhook_auth_bypass_paths(self) -> list[str]:
return []
# ── InboundHandlerProtocol ───────────────────────────
async def handle_raw_event(
self,
event: dict,
account: dict,
) -> object | None:
return None
def parse_to_unified(
self,
raw_event: dict,
account_id: str,
) -> object | None:
return None
def resolve_event_type(self, raw_event: dict) -> str:
return "unknown"
# ── MediaProtocol ────────────────────────────────────
async def upload_media(
self,
file_path: str,
media_type: str,
*,
account_id: str | None = None,
) -> dict:
return {}
async def download_media(
self,
media_url: str,
*,
account_id: str | None = None,
) -> bytes:
return b""
def detect_media_type(self, filename: str, content_type: str = "") -> str:
return "file"
def validate_media_url(self, url: str) -> bool:
return False
def resolve_media_size_limit(self, media_type: str) -> int:
return 0
# ── FormatProtocol ───────────────────────────────────
def markdown_to_native(self, md_text: str) -> dict | str:
return md_text
def native_to_markdown(self, native_content: dict | str) -> str:
if isinstance(native_content, dict):
return native_content.get("text", "")
return str(native_content)
# ── DedupeProtocol ───────────────────────────────────
def is_duplicate(self, key: str) -> bool:
return False
def mark_seen(self, key: str) -> None:
pass
def reset(self) -> None:
pass
@property
def ttl_seconds(self) -> int:
return 300
@property
def max_entries(self) -> int:
return 10000
# ── CardProtocol ─────────────────────────────────────
async def send_card(
self,
target_id: str,
card_content: dict,
*,
reply_to_id: str | None = None,
account_id: str | None = None,
) -> str | None:
return None
async def update_card(
self,
message_id: str,
card_content: dict,
*,
account_id: str | None = None,
) -> bool:
return False
async def edit_card(
self,
target_id: str,
message_id: str,
card_content: dict,
*,
thread_id: str | None = None,
account_id: str | None = None,
) -> str | None:
ok = await self.update_card(message_id, card_content, account_id=account_id)
return message_id if ok else None
def build_text_card(self, text: str, title: str | None = None, **kwargs) -> dict:
return {"text": text}
def supports_card_interactions(self) -> bool:
return False
# ── ErrorHandlingProtocol ────────────────────────────
def classify_error(self, error: BaseException) -> object:
from yuxi.channel.errors import classify_error
return classify_error(error)
def is_retryable(self, error: BaseException) -> bool:
from yuxi.channel.errors import is_retryable
return is_retryable(error)
def should_backoff(self, error: BaseException, attempt: int) -> int:
from yuxi.channel.errors import should_backoff
return should_backoff(error, attempt)
# ── PollingProtocol ──────────────────────────────────
poll_interval_seconds: int = 60
async def poll_once(self, account: dict) -> list[dict]:
return []
async def should_poll(self, account: dict) -> bool:
return False
def get_poll_cursor(self, account_id: str) -> str | None:
return None
def set_poll_cursor(self, account_id: str, cursor: str) -> None:
pass
# ── PinProtocol ──────────────────────────────────────
async def pin_message(
self,
message_id: str,
*,
account_id: str | None = None,
) -> bool:
return False
async def unpin_message(
self,
message_id: str,
*,
account_id: str | None = None,
) -> bool:
return False
async def list_pins(
self,
chat_id: str,
*,
account_id: str | None = None,
) -> list[dict]:
return []
# ── WebsocketProtocol ────────────────────────────────
async def connect_ws(self, account: dict) -> object:
return None
async def disconnect_ws(self) -> None:
pass
async def on_ws_message(self, message: str | bytes) -> None:
pass
@property
def ws_reconnect_interval_ms(self) -> int:
return 5000
@property
def ws_heartbeat_interval_ms(self) -> int:
return 30000
# ── Hooks ───────────────────────────────────────────
async def on_message_sending(self, msg: object, content: str) -> str | None:
return content
async def on_message_received(self, msg: object) -> object | None:
return msg
async def before_send_attempt(self, target_id: str, content: str) -> None:
pass
async def after_send_success(self, target_id: str, receipt: object) -> None:
pass
async def after_send_failure(self, target_id: str, error: str) -> None:
pass
async def after_commit(self, receipt: object) -> None:
pass