298 lines
12 KiB
Python
298 lines
12 KiB
Python
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()
|
||
|