From c2f0b64604abe3232933c2c2eddb452e9576feb3 Mon Sep 17 00:00:00 2001 From: NODA1 System Date: Fri, 20 Feb 2026 19:01:50 +0100 Subject: [PATCH] gateway: add privacy guard plus reminders and mentor relay commands --- docker-compose.node1.yml | 46 +++ gateway-bot/daarion_facade/reminder_worker.py | 100 ++++++ gateway-bot/daarion_facade/reminders.py | 154 +++++++++ gateway-bot/http_api.py | 294 +++++++++++++++++- 4 files changed, 593 insertions(+), 1 deletion(-) create mode 100644 gateway-bot/daarion_facade/reminder_worker.py create mode 100644 gateway-bot/daarion_facade/reminders.py diff --git a/docker-compose.node1.yml b/docker-compose.node1.yml index a95017aa..823ee205 100644 --- a/docker-compose.node1.yml +++ b/docker-compose.node1.yml @@ -263,6 +263,52 @@ services: retries: 3 + gateway-reminder-worker: + build: + context: ./gateway-bot + dockerfile: Dockerfile + container_name: dagi-gateway-reminder-worker-node1 + command: ["python", "-m", "daarion_facade.reminder_worker"] + environment: + - REDIS_URL=redis://redis:6379/0 + - DAARION_REMINDER_POLL_SECONDS=${DAARION_REMINDER_POLL_SECONDS:-2} + - DAARION_REMINDER_TTL_SECONDS=${DAARION_REMINDER_TTL_SECONDS:-2592000} + - DAARION_REMINDER_DEFAULT_TZ=${DAARION_REMINDER_DEFAULT_TZ:-Europe/Kyiv} + - GLOBAL_RELAY_ALLOWED_USER_IDS=${GLOBAL_RELAY_ALLOWED_USER_IDS:-} + - MENTOR_PRIVATE_HANDLES=${MENTOR_PRIVATE_HANDLES:-ivantytar,archenvis,olegarch88} + - MENTOR_PRIVATE_NAMES=${MENTOR_PRIVATE_NAMES:-Іван Титар,Александр Вертій,Олег Ковальчук} + - MENTOR_DISCLOSURE_ALLOWED_USER_IDS=${MENTOR_DISCLOSURE_ALLOWED_USER_IDS:-} + - HELION_MENTOR_CHAT_IDS=${HELION_MENTOR_CHAT_IDS:-} + - HELION_RELAY_ALLOWED_USER_IDS=${HELION_RELAY_ALLOWED_USER_IDS:-} + - DAARWIZZ_TELEGRAM_BOT_TOKEN=${DAARWIZZ_TELEGRAM_BOT_TOKEN:-8323412397:AAGZbAR22LuOiGD8xVC3OXMjahQ8rs2lJwo} + - HELION_TELEGRAM_BOT_TOKEN=${HELION_TELEGRAM_BOT_TOKEN:-8112062582:AAGS-HwRLEI269lDutLtAJTFArsIq31YNhE} + - GREENFOOD_TELEGRAM_BOT_TOKEN=${GREENFOOD_TELEGRAM_BOT_TOKEN:-7495165343:AAGR1XEOzg7DkPFPCzL_eYLCJfxJuonCxug} + - AGROMATRIX_TELEGRAM_BOT_TOKEN=${AGROMATRIX_TELEGRAM_BOT_TOKEN:-8580290441:AAFuDBmFJtpl-3I_WfkH7Hkb59X0fhYNMOE} + - ALATEYA_TELEGRAM_BOT_TOKEN=${ALATEYA_TELEGRAM_BOT_TOKEN:-8436880945:AAEi-HS6GEctddoqBUd37MHfweZQP-OjRlo} + - NUTRA_TELEGRAM_BOT_TOKEN=${NUTRA_TELEGRAM_BOT_TOKEN:-8517315428:AAGTLcKxBAZDsMgx28agKTvl1SqJGi0utH4} + - DRUID_TELEGRAM_BOT_TOKEN=${DRUID_TELEGRAM_BOT_TOKEN:-8145618489:AAFR714mBsNmiuF-rjCw-295iORBReJQZ70} + - CLAN_TELEGRAM_BOT_TOKEN=${CLAN_TELEGRAM_BOT_TOKEN:-8516872152:AAHH26wU8hJZJbSCJXb4vbmPmakTP77ok5E} + - EONARCH_TELEGRAM_BOT_TOKEN=${EONARCH_TELEGRAM_BOT_TOKEN:-7962391584:AAFYkelLRG3VR_Lxuu6pEGG76t4vZdANtz4} + - SENPAI_TELEGRAM_BOT_TOKEN=${SENPAI_TELEGRAM_BOT_TOKEN:-8510265026:AAGFrFBIIEihsLptZSxuKdmW2RoRPQDY9FE} + - ONEOK_TELEGRAM_BOT_TOKEN=${ONEOK_TELEGRAM_BOT_TOKEN} + - SOUL_TELEGRAM_BOT_TOKEN=${SOUL_TELEGRAM_BOT_TOKEN:-8041596416:AAHhpfCtY8paCm_9AD-4stJJg-Vw-CBf6Qk} + - YAROMIR_TELEGRAM_BOT_TOKEN=${YAROMIR_TELEGRAM_BOT_TOKEN:-8128180674:AAGNZdG3LwECI4z_803smsuRHsK3nPdjMLY} + - SOFIIA_TELEGRAM_BOT_TOKEN=${SOFIIA_TELEGRAM_BOT_TOKEN:-8589292566:AAEmPvS6nY9e-Y-TZm04CAHWlaFnWVxajE4} + volumes: + - ${DEPLOY_ROOT:-.}/gateway-bot:/app/gateway-bot:ro + - ${DEPLOY_ROOT:-.}/logs:/app/logs + depends_on: + - redis + networks: + - dagi-network + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "print(\"ok\")"] + interval: 30s + timeout: 5s + retries: 3 + + metrics-poller-node1: build: context: ./gateway-bot diff --git a/gateway-bot/daarion_facade/reminder_worker.py b/gateway-bot/daarion_facade/reminder_worker.py new file mode 100644 index 00000000..4f4337cf --- /dev/null +++ b/gateway-bot/daarion_facade/reminder_worker.py @@ -0,0 +1,100 @@ +import asyncio +import logging +import os +from typing import Dict + +import httpx + +from .reminders import close_redis, pop_due_reminders + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") +logger = logging.getLogger("daarion-reminder-worker") + +POLL_SECONDS = float(os.getenv("DAARION_REMINDER_POLL_SECONDS", "2")) +TELEGRAM_TIMEOUT = float(os.getenv("DAARION_REMINDER_TELEGRAM_TIMEOUT", "20")) + +AGENT_TOKEN_ENV: Dict[str, str] = { + "daarwizz": "DAARWIZZ_TELEGRAM_BOT_TOKEN", + "helion": "HELION_TELEGRAM_BOT_TOKEN", + "greenfood": "GREENFOOD_TELEGRAM_BOT_TOKEN", + "agromatrix": "AGROMATRIX_TELEGRAM_BOT_TOKEN", + "alateya": "ALATEYA_TELEGRAM_BOT_TOKEN", + "nutra": "NUTRA_TELEGRAM_BOT_TOKEN", + "druid": "DRUID_TELEGRAM_BOT_TOKEN", + "clan": "CLAN_TELEGRAM_BOT_TOKEN", + "eonarch": "EONARCH_TELEGRAM_BOT_TOKEN", + "senpai": "SENPAI_TELEGRAM_BOT_TOKEN", + "oneok": "ONEOK_TELEGRAM_BOT_TOKEN", + "soul": "SOUL_TELEGRAM_BOT_TOKEN", + "yaromir": "YAROMIR_TELEGRAM_BOT_TOKEN", + "sofiia": "SOFIIA_TELEGRAM_BOT_TOKEN", +} + + +def _token_for_agent(agent_id: str) -> str: + env = AGENT_TOKEN_ENV.get((agent_id or "").lower(), "") + return os.getenv(env, "") if env else "" + + +async def _send_reminder(item: Dict[str, str]) -> bool: + agent_id = str(item.get("agent_id", "")) + chat_id = str(item.get("chat_id", "")) + reminder_text = str(item.get("text", "")).strip() + due_at = str(item.get("due_at", "")) + + token = _token_for_agent(agent_id) + if not token: + logger.warning("reminder_skip_no_token agent=%s reminder_id=%s", agent_id, item.get("reminder_id")) + return False + + if not chat_id or not reminder_text: + logger.warning("reminder_skip_invalid_payload reminder_id=%s", item.get("reminder_id")) + return False + + body = { + "chat_id": chat_id, + "text": f"⏰ Нагадування ({agent_id})\n\n{reminder_text}\n\n🕒 {due_at}", + } + + url = f"https://api.telegram.org/bot{token}/sendMessage" + async with httpx.AsyncClient(timeout=TELEGRAM_TIMEOUT) as client: + resp = await client.post(url, json=body) + if resp.status_code != 200: + logger.warning( + "reminder_send_failed reminder_id=%s status=%s body=%s", + item.get("reminder_id"), + resp.status_code, + resp.text[:300], + ) + return False + + logger.info("reminder_sent reminder_id=%s agent=%s chat=%s", item.get("reminder_id"), agent_id, chat_id) + return True + + +async def worker_loop() -> None: + logger.info("reminder_worker_started poll_seconds=%s", POLL_SECONDS) + while True: + try: + items = await pop_due_reminders(limit=20) + if items: + for item in items: + try: + await _send_reminder(item) + except Exception: + logger.exception("reminder_send_exception reminder_id=%s", item.get("reminder_id")) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("reminder_worker_cycle_failed") + await asyncio.sleep(POLL_SECONDS) + + +if __name__ == "__main__": + try: + asyncio.run(worker_loop()) + finally: + try: + asyncio.run(close_redis()) + except Exception: + pass diff --git a/gateway-bot/daarion_facade/reminders.py b/gateway-bot/daarion_facade/reminders.py new file mode 100644 index 00000000..dd15ff8f --- /dev/null +++ b/gateway-bot/daarion_facade/reminders.py @@ -0,0 +1,154 @@ +import json +import os +import time +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from redis.asyncio import Redis + +REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") +REMINDER_PREFIX = "daarion:reminders" +REMINDER_BY_ID = f"{REMINDER_PREFIX}:by_id" +REMINDER_SCHEDULE = f"{REMINDER_PREFIX}:schedule" +REMINDER_TTL_SECONDS = int(os.getenv("DAARION_REMINDER_TTL_SECONDS", str(30 * 24 * 3600))) + +_redis: Optional[Redis] = None + + +@dataclass +class Reminder: + reminder_id: str + agent_id: str + chat_id: str + user_id: str + text: str + due_ts: int + created_at: str + + def to_dict(self) -> Dict[str, Any]: + return { + "reminder_id": self.reminder_id, + "agent_id": self.agent_id, + "chat_id": self.chat_id, + "user_id": self.user_id, + "text": self.text, + "due_ts": self.due_ts, + "created_at": self.created_at, + } + + +async def redis_client() -> Redis: + global _redis + if _redis is None: + _redis = Redis.from_url(REDIS_URL, decode_responses=True) + return _redis + + +async def close_redis() -> None: + global _redis + if _redis is not None: + await _redis.close() + _redis = None + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _iso_from_ts(ts: int) -> str: + return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() + + +async def create_reminder(agent_id: str, chat_id: str, user_id: str, text: str, due_ts: int) -> Dict[str, Any]: + reminder = Reminder( + reminder_id=f"rem_{uuid.uuid4().hex[:16]}", + agent_id=agent_id, + chat_id=str(chat_id), + user_id=str(user_id), + text=text.strip(), + due_ts=int(due_ts), + created_at=_iso_now(), + ) + + r = await redis_client() + key = f"{REMINDER_BY_ID}:{reminder.reminder_id}" + payload = json.dumps(reminder.to_dict(), ensure_ascii=False) + + await r.set(key, payload, ex=REMINDER_TTL_SECONDS) + await r.zadd(REMINDER_SCHEDULE, {reminder.reminder_id: float(reminder.due_ts)}) + + result = reminder.to_dict() + result["due_at"] = _iso_from_ts(reminder.due_ts) + return result + + +async def list_reminders(agent_id: str, chat_id: str, user_id: str, limit: int = 10) -> List[Dict[str, Any]]: + r = await redis_client() + now_ts = int(time.time()) + ids = await r.zrangebyscore(REMINDER_SCHEDULE, min=now_ts - 365 * 24 * 3600, max="+inf", start=0, num=max(1, limit * 5)) + + out: List[Dict[str, Any]] = [] + for reminder_id in ids: + raw = await r.get(f"{REMINDER_BY_ID}:{reminder_id}") + if not raw: + continue + try: + item = json.loads(raw) + except json.JSONDecodeError: + continue + if item.get("agent_id") != agent_id: + continue + if str(item.get("chat_id")) != str(chat_id): + continue + if str(item.get("user_id")) != str(user_id): + continue + item["due_at"] = _iso_from_ts(int(item.get("due_ts", 0))) + out.append(item) + if len(out) >= limit: + break + return out + + +async def cancel_reminder(reminder_id: str, agent_id: str, chat_id: str, user_id: str) -> bool: + r = await redis_client() + key = f"{REMINDER_BY_ID}:{reminder_id}" + raw = await r.get(key) + if not raw: + return False + try: + item = json.loads(raw) + except json.JSONDecodeError: + return False + + if item.get("agent_id") != agent_id or str(item.get("chat_id")) != str(chat_id) or str(item.get("user_id")) != str(user_id): + return False + + await r.delete(key) + await r.zrem(REMINDER_SCHEDULE, reminder_id) + return True + + +async def pop_due_reminders(limit: int = 20) -> List[Dict[str, Any]]: + r = await redis_client() + now_ts = int(time.time()) + ids = await r.zrangebyscore(REMINDER_SCHEDULE, min="-inf", max=now_ts, start=0, num=max(1, limit)) + out: List[Dict[str, Any]] = [] + + for reminder_id in ids: + removed = await r.zrem(REMINDER_SCHEDULE, reminder_id) + if removed == 0: + continue + raw = await r.get(f"{REMINDER_BY_ID}:{reminder_id}") + if not raw: + continue + await r.delete(f"{REMINDER_BY_ID}:{reminder_id}") + try: + item = json.loads(raw) + item["due_at"] = _iso_from_ts(int(item.get("due_ts", now_ts))) + out.append(item) + except json.JSONDecodeError: + continue + + return out diff --git a/gateway-bot/http_api.py b/gateway-bot/http_api.py index b177e173..2466f166 100644 --- a/gateway-bot/http_api.py +++ b/gateway-bot/http_api.py @@ -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: