Ctrl K
ComunidadGuías…

butler/gateway/bot_loop_guard.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Suppress bot-to-bot reply loops in group chats (OpenClaw pair-loop-guard subset)."""

from __future__ import annotations

import logging
import os
import re
import threading
import time
from collections import deque

from butler.utilities.env_parse import env_truthy, int_env
from butler.defaults.env_defaults import (
    BOT_LOOP_WHITELIST_DEFAULT,
    SAFETY_BOT_LOOP_GUARD_DEFAULT,
)

logger = logging.getLogger(__name__)

_WINDOW_SEC = 120.0
_LOCK = threading.RLock()
_PAIR_COUNTS: dict[str, deque[float]] = {}


def bot_loop_guard_enabled() -> bool:
    return bool(env_truthy("BUTLER_BOT_LOOP_GUARD", default=SAFETY_BOT_LOOP_GUARD_DEFAULT))


def pair_threshold() -> int:
    try:
        return int(int_env("BUTLER_BOT_LOOP_PAIR_THRESHOLD", 6, min=2))
    except ValueError:
        return 6


def _pair_key(chat_id: str, sender_id: str) -> str:
    return f"{chat_id}::{sender_id}"


def _is_whitelisted(chat_id: str) -> bool:
    raw = os.getenv("BUTLER_BOT_LOOP_WHITELIST", BOT_LOOP_WHITELIST_DEFAULT).strip()
    if not raw:
        return False
    allowed = {x.strip() for x in raw.split(",") if x.strip()}
    return chat_id in allowed


def looks_like_bot_sender(sender_id: str, text: str) -> bool:
    """Heuristic: bot accounts or messages that @ other bots."""
    sid = str(sender_id or "").lower()
    if "bot" in sid or sid.endswith("@openim"):
        return True
    if re.search(r"@\S*bot\S*", text or "", re.I):
        return True
    return False


def record_and_should_suppress(
    *,
    chat_id: str,
    sender_id: str,
    text: str = "",
) -> tuple[bool, str]:
    """Return (suppress, reason)."""
    if not bot_loop_guard_enabled():
        return False, ""
    cid = str(chat_id or "").strip() or "unknown"
    if _is_whitelisted(cid):
        return False, ""
    if not looks_like_bot_sender(sender_id, text):
        return False, ""

    key = _pair_key(cid, sender_id)
    now = time.monotonic()
    with _LOCK:
        bucket = _PAIR_COUNTS.setdefault(key, deque())
        while bucket and (now - bucket[0]) > _WINDOW_SEC:
            bucket.popleft()
        bucket.append(now)
        count = len(bucket)

    if count >= pair_threshold():
        logger.info(
            "Bot loop guard suppress chat=%s sender=%s count=%d",
            cid,
            sender_id,
            count,
        )
        from butler.gateway.bot_loop_guard_ops import record_bot_loop_suppressed_event_safe

        record_bot_loop_suppressed_event_safe(
            chat_id=cid,
            sender_id=sender_id,
            count=count,
        )
        return True, f"bot_loop_guard count={count}"
    return False, ""