gateway: add privacy guard plus reminders and mentor relay commands
This commit is contained in:
@@ -263,6 +263,52 @@ services:
|
|||||||
retries: 3
|
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:
|
metrics-poller-node1:
|
||||||
build:
|
build:
|
||||||
context: ./gateway-bot
|
context: ./gateway-bot
|
||||||
|
|||||||
100
gateway-bot/daarion_facade/reminder_worker.py
Normal file
100
gateway-bot/daarion_facade/reminder_worker.py
Normal file
@@ -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
|
||||||
154
gateway-bot/daarion_facade/reminders.py
Normal file
154
gateway-bot/daarion_facade/reminders.py
Normal file
@@ -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
|
||||||
@@ -14,9 +14,10 @@ import uuid
|
|||||||
import httpx
|
import httpx
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional, List, Tuple
|
from typing import Dict, Any, Optional, List, Tuple
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta, timezone
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -46,6 +47,11 @@ from behavior_policy import (
|
|||||||
BehaviorDecision,
|
BehaviorDecision,
|
||||||
AGENT_NAME_VARIANTS,
|
AGENT_NAME_VARIANTS,
|
||||||
)
|
)
|
||||||
|
from daarion_facade.reminders import (
|
||||||
|
create_reminder,
|
||||||
|
list_reminders,
|
||||||
|
cancel_reminder,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -242,6 +248,279 @@ def preferred_language_label(lang: str) -> str:
|
|||||||
}.get((lang or "").lower(), "Ukrainian")
|
}.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]:
|
def _extract_preferred_language_from_profile_fact(fact: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||||
if not isinstance(fact, dict):
|
if not isinstance(fact, dict):
|
||||||
return None
|
return None
|
||||||
@@ -2405,6 +2684,18 @@ async def handle_telegram_webhook(
|
|||||||
text = caption
|
text = caption
|
||||||
|
|
||||||
logger.info(f"{agent_config.name} Telegram message from {username} (tg:{user_id}) in chat {chat_id}: {text[:50]}")
|
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)
|
mentioned_bots = extract_bot_mentions(text)
|
||||||
needs_complex_reasoning = requires_complex_reasoning(text)
|
needs_complex_reasoning = requires_complex_reasoning(text)
|
||||||
|
|
||||||
@@ -2780,6 +3071,7 @@ async def handle_telegram_webhook(
|
|||||||
force_detailed=force_detailed_reply,
|
force_detailed=force_detailed_reply,
|
||||||
needs_complex_reasoning=needs_complex_reasoning,
|
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)
|
# Skip Telegram sending for prober requests (chat_id=0)
|
||||||
if is_prober:
|
if is_prober:
|
||||||
|
|||||||
Reference in New Issue
Block a user