ForcePilot/backend/package/yuxi/channel/middlewares/outbound.py
Kris bab30f2715
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Ruff Format Check / Ruff Format & Lint (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
feat:0715
2026-07-15 12:30:58 +08:00

363 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""默认出站中间件实现(聚合文件)。"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
from yuxi.channel.capabilities.negotiation import supports
from yuxi.channel.constants import DeliveryStatus, DispatchResult
from yuxi.channel.exceptions import ChannelErrorClassification
from yuxi.channel.middlewares.protocols import OutboundResult
from yuxi.channel.plugins.protocol import OutboundMessage
from yuxi.utils.logging_config import logger
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from yuxi.channel.middlewares.protocols import OutboundContext
@runtime_checkable
class _UpdateStatusCallable(Protocol):
async def __call__(self, ctx: OutboundContext, status: str) -> None: ...
def _chunk_if_needed(
message: OutboundMessage,
capabilities,
chunk_func: Callable[[str, int], list[str]],
) -> list[OutboundMessage]:
"""根据渠道最大文本长度对长文本消息进行分片。"""
if message.content_type not in {"text", "markdown"}:
return [message]
limit = capabilities.max_text_length
if len(message.content) <= limit:
return [message]
chunks = chunk_func(message.content, limit)
return [
OutboundMessage(
content=chunk,
content_type=message.content_type,
reply_to_channel_message_id=(message.reply_to_channel_message_id if idx == 0 else None),
thread_id=message.thread_id,
extra=message.extra,
)
for idx, chunk in enumerate(chunks)
]
class BuildMessageMiddleware:
"""从数据库 Message 与出站事件构造 OutboundMessage并完成能力协商。"""
name = "build_message"
default_order = 100
async def process(
self,
ctx: OutboundContext,
next_mw: Callable[[], Awaitable[Any]],
) -> Any:
db_message = ctx.db_message
config = ctx.config
plugin = ctx.plugin
event = ctx.event
outbound = OutboundMessage(
content=db_message.content,
content_type=db_message.message_type or "text",
media=(db_message.channel_metadata.get("media", []) if db_message.channel_metadata else []),
)
# 基于 CapabilityMatrix 的能力协商,先于 Downgrader 的兜底降级
if outbound.media and not supports(plugin, "media", config):
dropped = len(outbound.media)
logger.warning(
"Channel %s does not support media, dropping %d attachment(s)",
event.get("channel_type"),
dropped,
)
note = f"[该消息包含 {dropped} 个媒体附件,当前渠道不支持媒体,已省略]"
outbound.content = f"{outbound.content}\n\n{note}" if outbound.content else note
outbound.media = []
if outbound.content_type == "interactive" and not supports(plugin, "interactive", config):
logger.warning(
"Channel %s does not support interactive, downgrading",
event.get("channel_type"),
)
if supports(plugin, "markdown", config):
outbound.content_type = "markdown"
else:
outbound.content_type = "text"
if outbound.content_type == "markdown" and not supports(plugin, "markdown", config):
logger.warning(
"Channel %s does not support markdown, downgrading to text",
event.get("channel_type"),
)
outbound.content_type = "text"
ctx.message = outbound
return await next_mw()
class MediaUploadMiddleware:
"""预上传媒体文件并在 channel_metadata 中缓存结果,避免重试时重复上传。"""
name = "media_upload"
default_order = 200
async def process(
self,
ctx: OutboundContext,
next_mw: Callable[[], Awaitable[Any]],
) -> Any:
if ctx.message is None or not ctx.message.media:
return await next_mw()
db_message = ctx.db_message
uploaded_media = (db_message.channel_metadata or {}).get("uploaded_media")
if uploaded_media is None:
uploaded_media = []
for media in ctx.message.media:
media_id_info = await ctx.plugin.upload_media(media, config=ctx.config)
if media_id_info is not None:
uploaded_media.append({**media, **media_id_info})
else:
uploaded_media.append(media)
db_message.update_channel_metadata({"uploaded_media": uploaded_media})
ctx.message.media = uploaded_media
return await next_mw()
class DowngradeMiddleware:
"""根据渠道投递能力对 OutboundMessage 进行兜底降级。"""
name = "downgrade"
default_order = 300
async def process(
self,
ctx: OutboundContext,
next_mw: Callable[[], Awaitable[Any]],
) -> Any:
if ctx.message is not None:
from yuxi.channel.outbound.downgrade import Downgrader
ctx.message = await Downgrader.downgrade(ctx.message, ctx.capabilities)
return await next_mw()
class ChunkMiddleware:
"""长文本分片并恢复跨重试的 chunk 级状态。"""
name = "chunk"
default_order = 400
async def process(
self,
ctx: OutboundContext,
next_mw: Callable[[], Awaitable[Any]],
) -> Any:
if ctx.message is None:
return await next_mw()
db_message = ctx.db_message
chunks = _chunk_if_needed(ctx.message, ctx.capabilities, ctx.plugin.chunk_text)
if not chunks:
chunks = [ctx.message]
existing_chunks = (db_message.channel_metadata or {}).get("chunks") or []
existing_sent_ids = (db_message.channel_metadata or {}).get("sent_ids") or []
pending_indexes = (db_message.channel_metadata or {}).get("pending_chunk_indexes")
if pending_indexes is None or len(existing_chunks) != len(chunks) or len(existing_sent_ids) != len(chunks):
existing_chunks = [None] * len(chunks)
existing_sent_ids = [None] * len(chunks)
pending_indexes = list(range(len(chunks)))
ctx.chunks = chunks
ctx.sent_ids = existing_sent_ids
ctx.chunk_statuses = existing_chunks
ctx.pending_chunk_indexes = pending_indexes
db_message.update_channel_metadata(
{
"chunks": existing_chunks,
"sent_ids": existing_sent_ids,
"pending_chunk_indexes": pending_indexes,
}
)
return await next_mw()
class FormatMiddleware:
"""将每个 chunk 格式化为渠道 payload。"""
name = "format"
default_order = 500
async def process(
self,
ctx: OutboundContext,
next_mw: Callable[[], Awaitable[Any]],
) -> Any:
ctx.payloads = []
for chunk in ctx.chunks:
payload = await ctx.plugin.format_outbound(message=chunk, config=ctx.config)
ctx.payloads.append(payload)
return await next_mw()
class EnrichMiddleware:
"""对每个 payload 进行渠道特定 enrichment。"""
name = "enrich"
default_order = 600
async def process(
self,
ctx: OutboundContext,
next_mw: Callable[[], Awaitable[Any]],
) -> Any:
for idx, chunk in enumerate(ctx.chunks):
if idx >= len(ctx.payloads):
continue
ctx.payloads[idx] = await ctx.plugin.enrich_outbound(ctx.payloads[idx], chunk, config=ctx.config)
return await next_mw()
class SendMiddleware:
"""调用插件完成实际投递,失败时仍继续执行后续中间件以保证状态更新。"""
name = "send"
default_order = 700
async def process(
self,
ctx: OutboundContext,
next_mw: Callable[[], Awaitable[Any]],
) -> Any:
try:
await self._send(ctx)
except Exception:
logger.exception(
"Unhandled error in send middleware for message %s",
getattr(ctx.db_message, "id", None),
)
ctx.dispatch_result = DispatchResult.RETRYABLE
return await next_mw()
async def _send(self, ctx: OutboundContext) -> None:
plugin = ctx.plugin
config = ctx.config
event = ctx.event
session_key = event.get("session_key")
# 仅在“首次全量”尝试时使用 batch重试时退化为单条发送
if (
plugin.supports_batch_send(config)
and len(ctx.chunks) > 1
and len(ctx.pending_chunk_indexes) == len(ctx.chunks)
):
sent_ids = await plugin.send_batch(session_key, ctx.payloads, config=config)
batch_has_failure = False
for idx, sid in enumerate(sent_ids):
ctx.sent_ids[idx] = sid
if sid is not None:
ctx.chunk_statuses[idx] = {"index": idx, "status": "success", "id": sid}
ctx.pending_chunk_indexes.remove(idx)
else:
ctx.chunk_statuses[idx] = {"index": idx, "status": "failed"}
batch_has_failure = True
if batch_has_failure:
ctx.dispatch_result = DispatchResult.RETRYABLE
return
for idx in list(ctx.pending_chunk_indexes):
payload = ctx.payloads[idx] if idx < len(ctx.payloads) else {}
try:
channel_msg_id = await plugin.send_message(session_key, payload, config=config)
except Exception as e:
classification, chunk_retry_after = plugin.classify_error(e, payload)
ctx.chunk_statuses[idx] = {
"index": idx,
"status": "failed",
"error": str(e),
"classification": str(classification),
}
if classification == ChannelErrorClassification.PERMANENT:
logger.exception(
"Permanent failure sending chunk %s for message %s",
idx,
getattr(ctx.db_message, "id", None),
)
ctx.pending_chunk_indexes.remove(idx)
continue
if classification == ChannelErrorClassification.RATE_LIMITED and chunk_retry_after is not None:
ctx.retry_after = chunk_retry_after
logger.exception(
"Retryable failure sending chunk %s for message %s",
idx,
getattr(ctx.db_message, "id", None),
)
ctx.dispatch_result = DispatchResult.RETRYABLE
break
ctx.sent_ids[idx] = channel_msg_id
ctx.chunk_statuses[idx] = {"index": idx, "status": "success", "id": channel_msg_id}
ctx.pending_chunk_indexes.remove(idx)
class StatusUpdateMiddleware:
"""根据 chunk 投递结果更新消息状态、指标与日志。"""
name = "status_update"
default_order = 800
def __init__(
self,
update_status: _UpdateStatusCallable | None = None,
) -> None:
self._update_status = update_status
async def process(
self,
ctx: OutboundContext,
next_mw: Callable[[], Awaitable[Any]],
) -> Any:
has_success = any(isinstance(cs, dict) and cs.get("status") == "success" for cs in ctx.chunk_statuses)
has_failure = any(isinstance(cs, dict) and cs.get("status") == "failed" for cs in ctx.chunk_statuses)
still_pending = bool(ctx.pending_chunk_indexes)
if still_pending:
status = DeliveryStatus.PENDING
dispatch_result = DispatchResult.RETRYABLE
elif has_failure and has_success:
status = DeliveryStatus.PARTIAL_FAILED
dispatch_result = DispatchResult.PERMANENT_FAILURE
elif has_failure:
status = DeliveryStatus.FAILED
dispatch_result = DispatchResult.PERMANENT_FAILURE
else:
status = DeliveryStatus.COMPLETE
dispatch_result = DispatchResult.SUCCESS
db_message = ctx.db_message
db_message.update_channel_metadata(
{
"sent_ids": ctx.sent_ids,
"chunks": ctx.chunk_statuses,
"pending_chunk_indexes": ctx.pending_chunk_indexes,
}
)
update_status = self._update_status or getattr(ctx, "update_status", None)
if update_status is not None:
await update_status(ctx, status)
return OutboundResult(status=dispatch_result, retry_after=ctx.retry_after)