from __future__ import annotations from .shared import * class FeishuReplyOutboxMixin: async def _safe_reply_text( self, *, chat_id: str, text: str, mention_open_id: Optional[str] = None, mention_name: Optional[str] = None, trace_id: Optional[str] = None, source_message_id: Optional[str] = None, perf_t0: Optional[float] = None, ) -> None: try: plain_text = self._normalize_outgoing_reply_text(text) mention_prefix = self._build_mention_prefix( open_id=mention_open_id, display_name=mention_name, ) final_text = f"{mention_prefix}{plain_text}".strip() outbox_id = await self._enqueue_reply_outbox( chat_id=chat_id, message_text=final_text, trace_id=trace_id, source_message_id=source_message_id, ) perf_t1 = time.perf_counter() if outbox_id > 0 and perf_t0 is not None: _OUTBOX_PERF_T0_BY_ID[int(outbox_id)] = float(perf_t0) if perf_t0 is not None: print( f"[PERF] outbox_written ts={now_cn_naive().isoformat()} T1={perf_t1:.6f} " f"delta={perf_t1 - perf_t0:.2f}s " f"outbox_id={outbox_id}" ) else: print( f"[PERF] outbox_written ts={now_cn_naive().isoformat()} T1={perf_t1:.6f} " f"delta=N/A " f"outbox_id={outbox_id}" ) except Exception as exc: print(f"[Feishu] enqueue reply outbox failed: chat_id={chat_id}, error={exc}") # 统一由 drain_loop 消费 outbox,禁止业务路径直发,避免双发送。 return async def _enqueue_reply_outbox( self, *, chat_id: str, message_text: str, trace_id: Optional[str], source_message_id: Optional[str], ) -> int: async with AsyncSessionLocal() as db: normalized_source_message_id = (source_message_id or "")[:128] or None normalized_message_text = (message_text or "")[:2000] or "已收到,正在处理。" reply_hash = self._build_reply_hash( source_message_id=normalized_source_message_id, message_text=normalized_message_text, ) if reply_hash: sent_exists = ( await db.execute( select(FeishuReplyOutbox.id) .where( FeishuReplyOutbox.tenant_id == int(self.tenant_id or 1), 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(self.tenant_id or 1), trace_id=(trace_id or "")[:64] or None, source_message_id=normalized_source_message_id, chat_id=(chat_id or "")[:128], message_text=normalized_message_text, reply_hash=reply_hash, status="pending", attempts=0, next_retry_at=None, ) db.add(row) try: await db.flush() await self._notify_outbox_ready(db, outbox_id=int(row.id) if row.id is not None else None) await db.commit() except IntegrityError: await db.rollback() if reply_hash: sent_exists = ( await db.execute( select(FeishuReplyOutbox.id) .where( FeishuReplyOutbox.tenant_id == int(self.tenant_id or 1), 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 await db.refresh(row) return int(row.id) @classmethod async def dispatch_pending_outbox_entries(cls, *, limit: int = 30) -> int: fetch_limit = max(1, min(200, int(limit))) dispatched = 0 for _ in range(fetch_limit): claimed = await cls._claim_next_outbox_entry(now=now_cn_naive()) if claimed is None: break await cls._dispatch_claimed_outbox_entry(claimed=claimed) dispatched += 1 return dispatched @classmethod async def _claim_next_outbox_entry(cls, *, now: datetime) -> Optional[dict[str, Any]]: stale_before = now - timedelta(seconds=OUTBOX_STALE_SENDING_SECONDS) async with AsyncSessionLocal() as db: await db.execute( update(FeishuReplyOutbox) .where( FeishuReplyOutbox.status == "sending", FeishuReplyOutbox.updated_at <= stale_before, ) .values(status="retrying", next_retry_at=now) ) candidate_id = ( await db.execute( select(FeishuReplyOutbox.id) .where( FeishuReplyOutbox.status.in_(("pending", "retrying")), or_( FeishuReplyOutbox.next_retry_at.is_(None), FeishuReplyOutbox.next_retry_at <= now, ), ) .order_by(FeishuReplyOutbox.id.asc()) .with_for_update(skip_locked=True) .limit(1) ) ).scalar_one_or_none() if candidate_id is None: await db.commit() return None claimed = ( await db.execute( update(FeishuReplyOutbox) .where( FeishuReplyOutbox.id == int(candidate_id), FeishuReplyOutbox.status.in_(("pending", "retrying")), or_( FeishuReplyOutbox.next_retry_at.is_(None), FeishuReplyOutbox.next_retry_at <= now, ), ) .values( status="sending", attempts=FeishuReplyOutbox.attempts + 1, last_error=None, next_retry_at=None, ) .returning( FeishuReplyOutbox.id, FeishuReplyOutbox.tenant_id, FeishuReplyOutbox.chat_id, FeishuReplyOutbox.message_text, FeishuReplyOutbox.reply_hash, FeishuReplyOutbox.attempts, ) ) ).mappings().first() await db.commit() if claimed is None: return None return dict(claimed) @classmethod async def _dispatch_claimed_outbox_entry(cls, *, claimed: dict[str, Any]) -> None: outbox_id = int(claimed.get("id") or 0) if outbox_id <= 0: return tenant_id = int(claimed.get("tenant_id") or 1) reply_hash = str(claimed.get("reply_hash") or "").strip() if reply_hash: async with AsyncSessionLocal() as db_check: sent_duplicate = ( await db_check.execute( select(FeishuReplyOutbox.id) .where( FeishuReplyOutbox.id != outbox_id, FeishuReplyOutbox.tenant_id == tenant_id, FeishuReplyOutbox.reply_hash == reply_hash, FeishuReplyOutbox.status == "sent", ) .limit(1) ) ).scalar_one_or_none() if sent_duplicate is not None: async with AsyncSessionLocal() as db_skip: row = ( await db_skip.execute( select(FeishuReplyOutbox).where(FeishuReplyOutbox.id == outbox_id) ) ).scalar_one_or_none() if row is not None and str(row.status or "").lower() == "sending": row.status = "sent" row.sent_at = now_cn_naive() row.next_retry_at = None row.last_error = f"dedupe_skip_sent:{int(sent_duplicate)}" await db_skip.commit() _OUTBOX_PERF_T0_BY_ID.pop(outbox_id, None) return send_error: Optional[str] = None try: async with AsyncSessionLocal() as db_send: service = FeishuIntegrationService(db_send) await service.load_config(tenant_id=int(claimed.get("tenant_id") or 1)) await service.send_message_to_group( chat_id=str(claimed.get("chat_id") or ""), message=str(claimed.get("message_text") or ""), ) except Exception as exc: send_error = str(exc) async with AsyncSessionLocal() as db_update: row = ( await db_update.execute( select(FeishuReplyOutbox).where(FeishuReplyOutbox.id == outbox_id) ) ).scalar_one_or_none() if row is None: return if str(row.status or "").lower() != "sending": return attempts = int(row.attempts or claimed.get("attempts") or 0) if send_error is None: row.status = "sent" row.sent_at = now_cn_naive() row.next_retry_at = None row.last_error = None await db_update.commit() perf_t2 = time.perf_counter() perf_t0 = _OUTBOX_PERF_T0_BY_ID.pop(outbox_id, None) if perf_t0 is not None: print( f"[PERF] reply_dispatched ts={now_cn_naive().isoformat()} T2={perf_t2:.6f} " f"delta={perf_t2 - perf_t0:.2f}s " f"outbox_id={outbox_id}" ) else: print( f"[PERF] reply_dispatched ts={now_cn_naive().isoformat()} T2={perf_t2:.6f} " f"delta=N/A " f"outbox_id={outbox_id}" ) return if attempts >= OUTBOX_MAX_ATTEMPTS: row.status = "failed" row.next_retry_at = None _OUTBOX_PERF_T0_BY_ID.pop(outbox_id, None) else: row.status = "retrying" delay_seconds = min(30, 2 * (2 ** max(0, attempts - 1))) row.next_retry_at = now_cn_naive() + timedelta(seconds=delay_seconds) row.last_error = (send_error or "unknown_send_error")[:1000] await db_update.commit() @classmethod def _normalize_outgoing_reply_text(cls, value: Optional[str]) -> str: text = cls._clean_reply_text(value) if (not text) or cls._looks_like_provider_error(text): return cls._build_error_reply_text() return text[:500] @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()