新增渠道扩展框架的核心基础层,为所有渠道扩展提供统一的基础设施。 主要变更: - 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: 日志配置更新
612 lines
18 KiB
Python
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
|