gateway: add privacy guard plus reminders and mentor relay commands

This commit is contained in:
NODA1 System
2026-02-20 19:01:50 +01:00
parent 987ece5bac
commit c2f0b64604
4 changed files with 593 additions and 1 deletions

View File

@@ -14,9 +14,10 @@ import uuid
import httpx
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple
from datetime import datetime
from datetime import datetime, timedelta, timezone
from dataclasses import dataclass
from io import BytesIO
from zoneinfo import ZoneInfo
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
@@ -46,6 +47,11 @@ from behavior_policy import (
BehaviorDecision,
AGENT_NAME_VARIANTS,
)
from daarion_facade.reminders import (
create_reminder,
list_reminders,
cancel_reminder,
)
logger = logging.getLogger(__name__)
@@ -242,6 +248,279 @@ def preferred_language_label(lang: str) -> str:
}.get((lang or "").lower(), "Ukrainian")
def _csv_set(value: Optional[str]) -> set[str]:
if not value:
return set()
return {x.strip() for x in str(value).split(",") if x.strip()}
MENTOR_PRIVATE_HANDLES = _csv_set(os.getenv("MENTOR_PRIVATE_HANDLES", "ivantytar,archenvis,olegarch88"))
MENTOR_PRIVATE_NAMES = _csv_set(os.getenv("MENTOR_PRIVATE_NAMES", "Іван Титар,Александр Вертій,Олег Ковальчук"))
MENTOR_DISCLOSURE_ALLOWED_USER_IDS = _csv_set(os.getenv("MENTOR_DISCLOSURE_ALLOWED_USER_IDS", ""))
GLOBAL_RELAY_ALLOWED_USER_IDS = _csv_set(os.getenv("GLOBAL_RELAY_ALLOWED_USER_IDS", ""))
REMINDER_DEFAULT_TZ = os.getenv("DAARION_REMINDER_DEFAULT_TZ", "Europe/Kyiv")
def _agent_env_prefix(agent_config: "AgentConfig") -> str:
env = str(agent_config.telegram_token_env or "").strip()
if env.endswith("_TELEGRAM_BOT_TOKEN"):
return env[: -len("_TELEGRAM_BOT_TOKEN")]
return str(agent_config.agent_id or "").upper()
def _is_disclosure_allowed(user_id: str) -> bool:
return str(user_id) in MENTOR_DISCLOSURE_ALLOWED_USER_IDS
def _redact_private_mentions(answer_text: str, user_id: str) -> str:
if not answer_text or _is_disclosure_allowed(user_id):
return answer_text
out = answer_text
for handle in MENTOR_PRIVATE_HANDLES:
out = re.sub(rf"@{re.escape(handle)}\b", "@mentor", out, flags=re.IGNORECASE)
for name in MENTOR_PRIVATE_NAMES:
out = re.sub(re.escape(name), "ментор", out, flags=re.IGNORECASE)
return out
def _soften_unfulfillable_promises(answer_text: str) -> str:
if not answer_text:
return answer_text
lower = answer_text.lower()
has_dm_promise = any(
phrase in lower
for phrase in (
"особисті повідомлення",
"в dm",
"direct message",
"personal message",
"напишу менторам",
"надішлю менторам",
)
)
has_reminder_promise = any(
phrase in lower
for phrase in (
"нагадаю",
"напомню",
"i will remind",
)
)
hints: List[str] = []
if has_dm_promise:
hints.append("Для реальної передачі повідомлення менторам використай `/relay_mentors <текст>`.")
if has_reminder_promise:
hints.append("Для реального нагадування використай `/remind` або `/remind_in`.")
if not hints:
return answer_text
if "✅ Виконано дію:" in answer_text:
return answer_text
return f"{answer_text}\n\n {' '.join(hints)}"
def _sanitize_agent_answer(answer_text: str, user_id: str) -> str:
text = _redact_private_mentions(answer_text, user_id)
text = _soften_unfulfillable_promises(text)
return text
def _parse_duration_to_seconds(raw: str) -> Optional[int]:
m = re.match(r"^\s*(\d+)\s*([mhd])\s*$", raw.lower())
if not m:
return None
value = int(m.group(1))
unit = m.group(2)
if unit == "m":
return value * 60
if unit == "h":
return value * 3600
return value * 86400
def _parse_remind_datetime(raw: str) -> Optional[datetime]:
raw = raw.strip()
patterns = [
"%Y-%m-%d %H:%M",
"%Y-%m-%d %H:%M:%S",
]
parsed = None
for pattern in patterns:
try:
parsed = datetime.strptime(raw, pattern)
break
except ValueError:
continue
if parsed is None:
return None
try:
tz = ZoneInfo(REMINDER_DEFAULT_TZ)
except Exception:
tz = timezone.utc
return parsed.replace(tzinfo=tz).astimezone(timezone.utc)
def _relay_allowed_for_user(prefix: str, user_id: str) -> bool:
agent_allow = _csv_set(os.getenv(f"{prefix}_RELAY_ALLOWED_USER_IDS", ""))
allowed = GLOBAL_RELAY_ALLOWED_USER_IDS | agent_allow
return str(user_id) in allowed if allowed else False
def _mentor_chat_ids(prefix: str) -> List[str]:
return [x for x in os.getenv(f"{prefix}_MENTOR_CHAT_IDS", "").split(",") if x.strip()]
async def _handle_action_commands(
*,
agent_config: "AgentConfig",
text: str,
chat_id: str,
user_id: str,
username: str,
dao_id: str,
telegram_token: str,
) -> Optional[Dict[str, Any]]:
t = (text or "").strip()
if not t.startswith("/"):
return None
prefix = _agent_env_prefix(agent_config)
if t.startswith("/remind_in "):
parts = t.split(maxsplit=2)
if len(parts) < 3:
await send_telegram_message(chat_id, "Формат: `/remind_in 2h текст`", telegram_token)
return {"ok": True, "handled": "remind_in_help"}
seconds = _parse_duration_to_seconds(parts[1])
if not seconds or seconds <= 0:
await send_telegram_message(chat_id, "Некоректна тривалість. Приклад: `30m`, `2h`, `1d`.", telegram_token)
return {"ok": True, "handled": "remind_in_invalid"}
due_ts = int(time.time()) + seconds
item = await create_reminder(
agent_id=agent_config.agent_id,
chat_id=chat_id,
user_id=user_id,
text=parts[2],
due_ts=due_ts,
)
await send_telegram_message(
chat_id,
f"✅ Виконано дію: нагадування створено\nID: `{item['reminder_id']}`\nЧас: `{item['due_at']}`",
telegram_token,
)
await memory_client.save_chat_turn(
agent_id=agent_config.agent_id,
team_id=dao_id,
user_id=f"tg:{user_id}",
message=text,
response=f"✅ reminder_created {item['reminder_id']}",
channel_id=chat_id,
scope="short_term",
save_agent_response=True,
agent_metadata={"action": "reminder_create", "reminder_id": item["reminder_id"]},
username=username,
)
return {"ok": True, "handled": "remind_in", "reminder_id": item["reminder_id"]}
if t.startswith("/remind "):
payload = t[len("/remind ") :].strip()
m = re.match(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})(?:\s+)(.+)$", payload)
if not m:
await send_telegram_message(chat_id, "Формат: `/remind 2026-03-18 09:00 текст`", telegram_token)
return {"ok": True, "handled": "remind_help"}
due = _parse_remind_datetime(m.group(1))
if due is None:
await send_telegram_message(chat_id, "Не зміг розпізнати дату/час. Формат: `YYYY-MM-DD HH:MM`", telegram_token)
return {"ok": True, "handled": "remind_invalid_date"}
item = await create_reminder(
agent_id=agent_config.agent_id,
chat_id=chat_id,
user_id=user_id,
text=m.group(2),
due_ts=int(due.timestamp()),
)
await send_telegram_message(
chat_id,
f"✅ Виконано дію: нагадування створено\nID: `{item['reminder_id']}`\nЧас: `{item['due_at']}`",
telegram_token,
)
await memory_client.save_chat_turn(
agent_id=agent_config.agent_id,
team_id=dao_id,
user_id=f"tg:{user_id}",
message=text,
response=f"✅ reminder_created {item['reminder_id']}",
channel_id=chat_id,
scope="short_term",
save_agent_response=True,
agent_metadata={"action": "reminder_create", "reminder_id": item["reminder_id"]},
username=username,
)
return {"ok": True, "handled": "remind", "reminder_id": item["reminder_id"]}
if t == "/reminders":
items = await list_reminders(agent_id=agent_config.agent_id, chat_id=chat_id, user_id=user_id, limit=10)
if not items:
await send_telegram_message(chat_id, "Активних нагадувань не знайдено.", telegram_token)
return {"ok": True, "handled": "reminders_empty"}
rows = [f"- `{x['reminder_id']}` → `{x['due_at']}` :: {x['text'][:80]}" for x in items]
await send_telegram_message(chat_id, "🗓 Твої нагадування:\n" + "\n".join(rows), telegram_token)
return {"ok": True, "handled": "reminders_list", "count": len(items)}
if t.startswith("/cancel_reminder "):
reminder_id = t[len("/cancel_reminder ") :].strip()
ok = await cancel_reminder(reminder_id, agent_id=agent_config.agent_id, chat_id=chat_id, user_id=user_id)
await send_telegram_message(
chat_id,
f"{'' if ok else ''} {'Скасовано.' if ok else 'Не знайдено або немає доступу.'}",
telegram_token,
)
return {"ok": True, "handled": "cancel_reminder", "canceled": ok}
if t.startswith("/relay_mentors "):
if not _relay_allowed_for_user(prefix, user_id):
await send_telegram_message(chat_id, "⛔ Немає доступу до relay. Звернись до адміністратора.", telegram_token)
return {"ok": True, "handled": "relay_denied"}
recipients = _mentor_chat_ids(prefix)
if not recipients:
await send_telegram_message(chat_id, "⚠️ Не налаштовано mentor recipients.", telegram_token)
return {"ok": True, "handled": "relay_not_configured"}
payload = t[len("/relay_mentors ") :].strip()
if not payload:
await send_telegram_message(chat_id, "Формат: `/relay_mentors текст повідомлення`", telegram_token)
return {"ok": True, "handled": "relay_help"}
delivered = 0
body = (
f"📨 Relay from {agent_config.name}\n"
f"From: @{username or user_id} (tg:{user_id})\n"
f"Source chat: {chat_id}\n\n"
f"{payload}"
)
for rid in recipients:
try:
ok = await send_telegram_message(rid.strip(), body, telegram_token)
if ok:
delivered += 1
except Exception:
logger.exception("mentor_relay_send_failed recipient=%s", rid)
await send_telegram_message(chat_id, f"✅ Виконано дію: relay sent to {delivered}/{len(recipients)} mentor(s).", telegram_token)
await memory_client.save_chat_turn(
agent_id=agent_config.agent_id,
team_id=dao_id,
user_id=f"tg:{user_id}",
message=text,
response=f"✅ mentor_relay delivered={delivered}",
channel_id=chat_id,
scope="short_term",
save_agent_response=True,
agent_metadata={"action": "mentor_relay", "delivered": delivered, "requested": len(recipients)},
username=username,
)
return {"ok": True, "handled": "relay_mentors", "delivered": delivered, "requested": len(recipients)}
return None
def _extract_preferred_language_from_profile_fact(fact: Optional[Dict[str, Any]]) -> Optional[str]:
if not isinstance(fact, dict):
return None
@@ -2405,6 +2684,18 @@ async def handle_telegram_webhook(
text = caption
logger.info(f"{agent_config.name} Telegram message from {username} (tg:{user_id}) in chat {chat_id}: {text[:50]}")
command_result = await _handle_action_commands(
agent_config=agent_config,
text=text,
chat_id=chat_id,
user_id=user_id,
username=username,
dao_id=dao_id,
telegram_token=telegram_token,
)
if command_result is not None:
return command_result
mentioned_bots = extract_bot_mentions(text)
needs_complex_reasoning = requires_complex_reasoning(text)
@@ -2780,6 +3071,7 @@ async def handle_telegram_webhook(
force_detailed=force_detailed_reply,
needs_complex_reasoning=needs_complex_reasoning,
)
answer_text = _sanitize_agent_answer(answer_text, user_id=user_id)
# Skip Telegram sending for prober requests (chat_id=0)
if is_prober: