363 lines
13 KiB
Python
363 lines
13 KiB
Python
"""默认出站中间件实现(聚合文件)。"""
|
||
|
||
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)
|