diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index 410886d9a..b341242e7 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -99,6 +99,92 @@ INCIDENT_UPDATE_GLOBAL_FAILURE_DEDUP_TTL_SECONDS = 10 * 60 # 相同失敗摘要 GROUPED_ALERT_DIGEST_DEDUP_PREFIX = "awoooi:tg_group_digest:" # {group_key} GROUPED_ALERT_DIGEST_DEDUP_TTL_SECONDS = 5 * 60 # 同一告警群組 5 分鐘只推一則 digest +_SRE_GROUP_MACHINE_COMMAND_RE = re.compile( + r""" + ^\s*(?: + (?:new|set|add|clear|copy|move|remove|rename|get|test|invoke|start|stop| + restart|register|unregister|write|out)-[a-z][\w-]*\b + |\$[a-z_][\w:.-]*\s*= + |(?:if|foreach|for|while|switch|try|catch|finally|function|param) + \s*(?:\(|\{) + |(?:kubectl|helm|docker(?:\s+compose)?|podman|systemctl|journalctl| + ansible(?:-playbook)?|terraform|tofu|nomad|consul|vault|curl|wget| + ssh|scp|rsync|git|gitea|python(?:3)?|node|npm|pnpm|yarn|bash|zsh| + sh|pwsh|powershell|cmd(?:\.exe)?)\b + |(?:[a-z]:\\|\\\\)[^\s]+ + |```|[|&>] + |[\{\[]\s*[\"'][\w.-]+[\"']\s*: + ) + """, + re.IGNORECASE | re.VERBOSE, +) +_SRE_GROUP_MACHINE_LOG_RE = re.compile( + r""" + ^\s*(?: + \d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2} + |\[(?:trace|debug|info|warn|warning|error|fatal|critical)\] + |(?:trace|debug|info|warn|warning|error|fatal|critical)\s+ + (?:\d{4}-\d{2}-\d{2}|[a-z_][\w.-]*=) + ) + """, + re.IGNORECASE | re.VERBOSE, +) +_SRE_GROUP_DIALOGUE_INTENT_RE = re.compile( + r"(?:為什麼|怎麼|如何|請問|什麼|是否|能否|可不可以|可以幫|請幫|" + r"幫我|解釋|分析|診斷|這是|發生什麼|" + r"\b(?:why|what|how|explain|analy[sz]e|diagnose|please|can you|" + r"could you|would you)\b)", + re.IGNORECASE, +) + + +def _sre_group_message_requires_ai_response( + text: str, + reply_to_message: Mapping[str, object] | None = None, +) -> bool: + """Reject unaddressed machine payloads before any provider call. + + Operators can still discuss a command or log by asking a question, using + an AI mention, or replying to a bot. Raw command/log transport is not a + conversational request and must not fan out into two model generations. + """ + + import unicodedata + + normalized = unicodedata.normalize("NFKC", str(text or "")).strip() + if not normalized: + return False + lowered = normalized.casefold() + if lowered.startswith("/"): + return True + if any( + token in lowered + for token in ( + "@openclawawoooi_bot", + "@nemotronawoooi_bot", + "小o", + "小賀", + "小贺", + ) + ): + return True + replied_from = ( + reply_to_message.get("from") + if isinstance(reply_to_message, Mapping) + else None + ) + if isinstance(replied_from, Mapping) and replied_from.get("is_bot") is True: + return True + dialogue_intent = bool( + normalized.endswith(("?", "?")) + or _SRE_GROUP_DIALOGUE_INTENT_RE.search(normalized) + ) + machine_payload = bool( + _SRE_GROUP_MACHINE_COMMAND_RE.search(normalized) + or _SRE_GROUP_MACHINE_LOG_RE.search(normalized) + ) + return dialogue_intent or not machine_payload + _HEARTBEAT_WARNING_FINGERPRINT_RULES: tuple[tuple[re.Pattern[str], str], ...] = ( # 2026-06-24 Codex + ogt: Telegram heartbeat dedupe must track the # actionable condition, not volatile probe details. HTTP status, timeout, @@ -14374,6 +14460,17 @@ class TelegramGateway: logger.error("hermes_nl_polling_failed", error=str(_hermes_err)) return + if not _sre_group_message_requires_ai_response(text, reply_to_message): + logger.info( + "telegram_group_machine_payload_ignored", + user_id=user_id, + message_id=message_id, + text_sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(), + text_length=len(text), + provider_call_performed=False, + ) + return + from src.services.chat_manager import get_chat_manager as _get_cm chat_mgr = _get_cm() diff --git a/apps/api/tests/test_telegram_group_chat_provider_router.py b/apps/api/tests/test_telegram_group_chat_provider_router.py index 7c8913f0c..c9cb33fc8 100644 --- a/apps/api/tests/test_telegram_group_chat_provider_router.py +++ b/apps/api/tests/test_telegram_group_chat_provider_router.py @@ -4,11 +4,14 @@ from unittest.mock import AsyncMock import pytest -from src.services import chat_manager as chat_module from src.services import ai_rate_limiter as limiter_module +from src.services import chat_manager as chat_module from src.services import telegram_gateway as gateway_module from src.services.chat_manager import ChatManager -from src.services.telegram_gateway import TelegramGateway +from src.services.telegram_gateway import ( + TelegramGateway, + _sre_group_message_requires_ai_response, +) class _ReceiptBackedCostLimiter: @@ -27,6 +30,66 @@ class _ReceiptBackedCostLimiter: } +@pytest.mark.parametrize( + "message", + [ + r"new-item -itemtype directory -force -path \wooo\agent99\tmp", + r"set-content -encoding ascii -path \wooo\agent99\tmp\fingerprint.txt", + r"add-content -encoding ascii -path \wooo\agent99\tmp\events.log", + "kubectl get pods -n awoooi-prod", + "2026-07-19T00:26:31+08:00 ERROR provider timeout", + '{"event":"agent99_runtime_receipt","status":"pending"}', + "```powershell\nRestart-Service alertmanager\n```", + ], +) +def test_unaddressed_machine_payload_does_not_request_ai(message: str) -> None: + assert _sre_group_message_requires_ai_response(message) is False + + +@pytest.mark.parametrize( + "message,reply_to_message", + [ + ("kubectl get pods 為什麼失敗?", None), + ("@OpenClawAwoooI_Bot kubectl get pods 失敗", None), + ("set-content 這行是什麼意思", None), + ("目前有哪些異常服務", None), + ( + "Restart-Service alertmanager", + {"from": {"is_bot": True, "username": "OpenClawAwoooI_Bot"}}, + ), + ], +) +def test_explicit_dialogue_about_machine_payload_still_requests_ai( + message: str, + reply_to_message: dict | None, +) -> None: + assert ( + _sre_group_message_requires_ai_response(message, reply_to_message) + is True + ) + + +@pytest.mark.asyncio +async def test_group_machine_payload_stops_before_context_or_provider( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(gateway_module.settings, "HERMES_NL_ENABLED", False) + + def _chat_manager_forbidden(): + raise AssertionError("machine payload must not build context or call AI") + + monkeypatch.setattr(chat_module, "get_chat_manager", _chat_manager_forbidden) + gateway = object.__new__(TelegramGateway) + + await gateway._handle_group_message( + r"new-item -itemtype directory -force -path \wooo\agent99\tmp", + user_id=99, + username="operator", + chat_id=-100123, + message_id=455, + ) + + @pytest.mark.asyncio async def test_nemotron_group_mention_uses_structured_provider_status_router( monkeypatch: pytest.MonkeyPatch,