"""飞书 outbox 入队服务。""" from __future__ import annotations import hashlib from typing import Optional from sqlalchemy import and_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from ....models import FeishuReplyOutbox class FeishuOutboxService: """复用 reply_hash 幂等策略的 outbox 写入入口。""" _PLAIN_TEXT_MESSAGE_LIMIT = 12000 @staticmethod def _build_reply_hash(*, source_message_id: Optional[str], message_text: str) -> Optional[str]: source = str(source_message_id or "").strip() body = str(message_text or "").strip() if not source or not body: return None raw = f"{source}{body}".encode("utf-8") return hashlib.md5(raw, usedforsecurity=False).hexdigest() @staticmethod def _is_requirement_plan_reply_text(value: Optional[str]) -> bool: text = str(value or "") return any(marker in text for marker in ("A. ", "B. ", "C. ", "⭐ 当前我更推荐:")) @classmethod def _guard_requirement_plan_reply_integrity(cls, value: Optional[str]) -> None: text = str(value or "").strip() if not cls._is_requirement_plan_reply_text(text): return required_markers = ("A. ", "B. ", "C. ", "⭐ 当前我更推荐:") if any(marker not in text for marker in required_markers): raise ValueError("incomplete_requirement_plan_reply") @classmethod def _normalize_outbox_text(cls, value: Optional[str]) -> str: normalized = str(value or "").strip() cls._guard_requirement_plan_reply_integrity(normalized) if len(normalized) > cls._PLAIN_TEXT_MESSAGE_LIMIT and cls._is_requirement_plan_reply_text(normalized): raise ValueError("requirement_plan_reply_exceeds_plain_text_outbox_limit") return normalized[: cls._PLAIN_TEXT_MESSAGE_LIMIT] or "已收到,正在处理。" @classmethod async def enqueue( cls, db: AsyncSession, *, tenant_id: int, chat_id: str, text: str, source_message_id: Optional[str], dedupe_seed: Optional[str] = None, trace_id: Optional[str] = None, ) -> Optional[int]: normalized_chat_id = str(chat_id or "").strip()[:128] if not normalized_chat_id: return None normalized_text = cls._normalize_outbox_text(text) normalized_source_message_id = (str(source_message_id or "").strip()[:128] or None) hash_source = normalized_source_message_id or (str(dedupe_seed or "").strip()[:128] or None) reply_hash = cls._build_reply_hash(source_message_id=hash_source, message_text=normalized_text) if reply_hash: sent_exists = ( await db.execute( select(FeishuReplyOutbox.id) .where( and_( FeishuReplyOutbox.tenant_id == int(tenant_id), FeishuReplyOutbox.reply_hash == reply_hash, FeishuReplyOutbox.status == "sent", ) ) .limit(1) ) ).scalar_one_or_none() if sent_exists is not None: return int(sent_exists) row = FeishuReplyOutbox( tenant_id=int(tenant_id), trace_id=(str(trace_id or "").strip()[:64] or None), source_message_id=normalized_source_message_id, chat_id=normalized_chat_id, message_text=normalized_text, reply_hash=reply_hash, status="pending", attempts=0, next_retry_at=None, ) try: async with db.begin_nested(): db.add(row) await db.flush() except IntegrityError: if reply_hash: sent_exists = ( await db.execute( select(FeishuReplyOutbox.id) .where( and_( FeishuReplyOutbox.tenant_id == int(tenant_id), FeishuReplyOutbox.reply_hash == reply_hash, FeishuReplyOutbox.status == "sent", ) ) .limit(1) ) ).scalar_one_or_none() if sent_exists is not None: return int(sent_exists) raise return int(row.id) if row.id is not None else None