Files
microdao-daarion/gateway-bot/http_api.py
Apple 90080c632a fix(fabric): use broadcast subject for NATS capabilities discovery
NATS wildcards (node.*.capabilities.get) only work for subscriptions,
not for publish. Switch to a dedicated broadcast subject
(fabric.capabilities.discover) that all NCS instances subscribe to,
enabling proper scatter-gather discovery across nodes.

Made-with: Cursor
2026-02-27 03:20:13 -08:00

5131 lines
222 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Bot Gateway HTTP API
Handles incoming webhooks from Telegram, Discord, etc.
"""
import asyncio
import base64
import copy
import hashlib
import json
import re
import logging
import os
import sys
import time
import uuid
import httpx
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple
from datetime import datetime
from dataclasses import dataclass
from io import BytesIO
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from router_client import send_to_router
from memory_client import memory_client
from vision_guard import (
extract_label_from_response as _vg_extract_label,
get_vision_lock as _vg_get_lock,
set_vision_lock as _vg_set_lock,
clear_vision_lock as _vg_clear_lock,
set_user_label as _vg_set_user_label,
detect_user_override as _vg_detect_override,
should_skip_reanalysis as _vg_should_skip,
build_low_confidence_clarifier as _vg_build_low_conf,
build_locked_reply as _vg_build_locked_reply,
)
from services.doc_service import (
parse_document,
ingest_document,
ask_about_document,
get_doc_context,
save_chat_doc_context,
get_chat_doc_context,
fetch_telegram_file_bytes,
extract_summary_from_bytes,
upsert_chat_doc_context_with_summary,
)
from behavior_policy import (
should_respond,
analyze_message,
detect_media_question,
detect_explicit_request,
detect_url,
detect_agent_mention,
is_no_output_response,
record_interaction,
record_ack,
get_ack_text,
is_prober_request,
has_agent_chat_participation,
has_recent_interaction,
NO_OUTPUT,
BehaviorDecision,
AGENT_NAME_VARIANTS,
)
logger = logging.getLogger(__name__)
def _safe_has_recent_interaction(agent_id: str, chat_id: str, user_id: str) -> bool:
"""Guard: avoid 500 if has_recent_interaction is missing or raises. Returns False on any error."""
try:
return bool(has_recent_interaction(agent_id, str(chat_id), str(user_id)))
except Exception as e:
logger.warning("has_recent_interaction failed, treating as False: %s", e)
return False
# Telegram message length limits
TELEGRAM_MAX_MESSAGE_LENGTH = 4096
TELEGRAM_SAFE_LENGTH = 3500 # Leave room for formatting
# Operator pending state cache (chat_id -> {ts, items})
LAST_PENDING_STATE: Dict[str, Dict[str, Any]] = {}
PENDING_STATE_TTL = 1800 # 30 minutes
# Per-user language preference cache (chat_id:user_id -> {lang, ts})
USER_LANGUAGE_PREFS: Dict[str, Dict[str, Any]] = {}
USER_LANGUAGE_PREF_TTL = 30 * 24 * 3600 # 30 days
# Per-user response style cache (agent:chat:user -> {style, ts})
USER_RESPONSE_STYLE_PREFS: Dict[str, Dict[str, Any]] = {}
USER_RESPONSE_STYLE_PREF_TTL = 30 * 24 * 3600 # 30 days
# Recent photo context for follow-up questions in chat (agent:chat:user -> {file_id, ts})
RECENT_PHOTO_CONTEXT: Dict[str, Dict[str, Any]] = {}
RECENT_PHOTO_TTL = 30 * 60 # 30 minutes
AGROMATRIX_GLOBAL_KNOWLEDGE_USER_ID = "agent:agromatrix:global"
def _cleanup_recent_photo_context() -> None:
now = time.time()
expired = [k for k, v in RECENT_PHOTO_CONTEXT.items() if now - float(v.get("ts", 0)) > RECENT_PHOTO_TTL]
for k in expired:
del RECENT_PHOTO_CONTEXT[k]
def _set_recent_photo_context(agent_id: str, chat_id: str, user_id: str, file_id: str) -> None:
_cleanup_recent_photo_context()
key = f"{agent_id}:{chat_id}:{user_id}"
RECENT_PHOTO_CONTEXT[key] = {"file_id": file_id, "ts": time.time()}
def _get_recent_photo_file_id(agent_id: str, chat_id: str, user_id: str) -> Optional[str]:
_cleanup_recent_photo_context()
key = f"{agent_id}:{chat_id}:{user_id}"
rec = RECENT_PHOTO_CONTEXT.get(key)
if not rec:
return None
return rec.get("file_id")
def _extract_recent_photo_file_id_from_memory(memory_context: Dict[str, Any]) -> Optional[str]:
"""
Extract last seen Telegram photo file_id from memory context.
Looks for patterns like: [Photo: <file_id>]
"""
if not memory_context:
return None
pattern = re.compile(r"\[Photo:\s*([^\]\s]+)\]")
recent_events = memory_context.get("recent_events", []) or []
for ev in reversed(recent_events):
body = (ev.get("body_text") or "").strip()
if not body:
continue
m = pattern.search(body)
if m:
return m.group(1)
local_text = memory_context.get("local_context_text") or ""
for line in reversed(local_text.splitlines()):
m = pattern.search(line)
if m:
return m.group(1)
return None
def _looks_like_photo_followup(text: str) -> bool:
if not text:
return False
t = text.strip().lower()
direct_markers = [
"що ти бачиш", "що на фото", "що на зображенні", "опиши фото", "подивись фото",
"що на цьому фото", "що на цій фотографії", "що на цій світлині",
"проаналізуй фото", "аналіз фото", "переглянь фото", "повернись до фото",
"яка це рослина", "що це за рослина", "що за рослина", "що за культура",
"яка культура", "визнач рослину",
"what do you see", "what is in the image", "describe the photo",
"analyze the photo", "analyze image", "what plant is this",
"что ты видишь", "что на фото", "опиши фото", "посмотри фото",
"проанализируй фото", "какое это растение", "что за растение",
]
if any(m in t for m in direct_markers):
return True
# Flexible forms: "що на ... фото/зображенні/світлині"
if re.search(r"(що|what|что)\s+на\s+.*(фото|зображ|світлин|image|photo)", t):
# Exclude common meta-questions
meta_exclude = ["канал", "чат", "бот", "нормально"]
if not any(ex in t for ex in meta_exclude):
return True
return False
def _extract_agromatrix_correction_label(text: str) -> Optional[str]:
"""
Extract corrected plant label from free-form user feedback.
Examples:
- "це соняшник"
- "це не кабачок, а гарбуз"
- "правильна відповідь: кукурудза"
"""
raw = (text or "").strip()
if not raw:
return None
t = re.sub(r"\s+", " ", raw.lower())
patterns = [
r"правильн\w*\s+відповід\w*[:\-]?\s*([a-zаіїєґ0-9'\-\s]{2,60})",
r"це\s+не\s+[a-zаіїєґ0-9'\-\s]{1,60},?\s+а\s+([a-zаіїєґ0-9'\-\s]{2,60})",
]
for pat in patterns:
m = re.search(pat, t)
if not m:
continue
label = re.sub(r"\s+", " ", (m.group(1) or "").strip(" .,!?:;\"'()[]{}"))
if not label:
continue
if len(label.split()) > 4:
continue
if label in {"не знаю", "помилка", "невірно", "не вірно"}:
continue
# Filter imperative/meta phrases that are not plant labels.
bad_prefixes = (
"не ", "в чат", "зробити", "напиши", "потрібно", "навіщо",
"ти ", "він ", "вона ", "це ", "а ", "і ",
)
if label.startswith(bad_prefixes):
continue
if any(x in label for x in ("повідом", "чат", "відповід", "потрібно", "не потрібно")):
continue
if re.search(r"\d", label):
continue
return label
return None
def _is_agromatrix_negative_feedback(text: str) -> bool:
t = (text or "").strip().lower()
if not t:
return False
markers = (
"це помилка",
"це не вірно",
"це невірно",
"неправильно",
"не вірно",
"невірно",
"не так",
"помилка у відповіді",
"відповідь не вірна",
"відповідь невірна",
)
return any(m in t for m in markers)
def _is_agromatrix_correction_only_message(text: str) -> bool:
t = (text or "").strip().lower()
if not t:
return False
# Treat as "correction only" when there is no direct question.
if "?" in t:
return False
markers = (
"це ", "правильна відповідь", "невірно", "не вірно", "це не",
"не так", "неправильно", "виправ",
)
return any(m in t for m in markers)
def _truncate_context_for_prompt(raw: str, *, max_chars: int = 2200, max_lines: int = 28) -> str:
if not raw:
return ""
lines = [ln for ln in raw.splitlines() if ln.strip()]
if len(lines) > max_lines:
lines = lines[-max_lines:]
out = "\n".join(lines)
if len(out) > max_chars:
out = out[-max_chars:]
# try to cut from next line boundary for cleaner prompt
pos = out.find("\n")
if 0 <= pos < 200:
out = out[pos + 1 :]
return out.strip()
def _agromatrix_observation_doc_id(file_id: str, label: str) -> str:
digest = hashlib.sha1(f"{file_id}:{label}".encode("utf-8")).hexdigest()[:16]
return f"agromatrix-photo-{digest}"
async def _save_agromatrix_photo_learning(
*,
file_id: str,
label: str,
source: str,
chat_id: str,
user_id: str,
dao_id: str,
) -> None:
"""
Persist non-private photo learning:
1) fact keyed by file_id for deterministic follow-ups
2) anonymized doc card in agromatrix_docs for semantic reuse
"""
if not file_id or not label:
return
now_iso = datetime.utcnow().isoformat()
try:
await memory_client.upsert_fact(
user_id=AGROMATRIX_GLOBAL_KNOWLEDGE_USER_ID,
fact_key=f"agromatrix:photo_label:{file_id}",
fact_value=label,
fact_value_json={
"label": label,
"source": source,
"updated_at": now_iso,
},
team_id=dao_id,
)
except Exception as e:
logger.warning(f"AgroMatrix photo learning fact save failed: {e}")
# Best-effort semantic card, no personal data/chat text.
card_text = (
f"AgroMatrix plant observation.\n"
f"Validated label: {label}.\n"
f"Use as a prior hint for similar seedling/leaf photos.\n"
f"Source: {source}. Updated: {now_iso}."
)
try:
router_url = os.getenv("ROUTER_URL", "http://router:8000").rstrip("/")
async with httpx.AsyncClient(timeout=20.0) as client:
await client.post(
f"{router_url}/v1/documents/ingest",
json={
"agent_id": "agromatrix",
"doc_id": _agromatrix_observation_doc_id(file_id, label),
"file_name": f"agromatrix_photo_learning_{label}.txt",
"text": card_text,
"dao_id": dao_id,
"user_id": AGROMATRIX_GLOBAL_KNOWLEDGE_USER_ID,
},
)
except Exception as e:
logger.warning(f"AgroMatrix photo learning ingest failed: {e}")
async def _invalidate_agromatrix_photo_learning(
*,
file_id: str,
reason: str,
dao_id: str,
) -> None:
if not file_id:
return
try:
await memory_client.upsert_fact(
user_id=AGROMATRIX_GLOBAL_KNOWLEDGE_USER_ID,
fact_key=f"agromatrix:photo_label:{file_id}",
fact_value="",
fact_value_json={
"label": "",
"invalidated": True,
"reason": reason,
"updated_at": datetime.utcnow().isoformat(),
},
team_id=dao_id,
)
except Exception as e:
logger.warning(f"AgroMatrix photo learning invalidation failed: {e}")
async def _get_agromatrix_photo_prior(file_id: str, dao_id: str) -> Optional[str]:
if not file_id:
return None
try:
fact = await memory_client.get_fact(
user_id=AGROMATRIX_GLOBAL_KNOWLEDGE_USER_ID,
fact_key=f"agromatrix:photo_label:{file_id}",
team_id=dao_id,
)
if not fact:
return None
data = fact.get("fact_value_json") if isinstance(fact, dict) else None
if isinstance(data, dict):
if bool(data.get("invalidated")):
return None
label = str(data.get("label") or "").strip()
if label:
return label
label = str(fact.get("fact_value") or "").strip() if isinstance(fact, dict) else ""
return label or None
except Exception as e:
logger.warning(f"AgroMatrix photo prior lookup failed: {e}")
return None
async def _set_agromatrix_last_photo_ref(*, chat_id: str, user_id: str, file_id: str, dao_id: str) -> None:
if not (chat_id and user_id and file_id):
return
try:
await memory_client.upsert_fact(
user_id=AGROMATRIX_GLOBAL_KNOWLEDGE_USER_ID,
fact_key=f"agromatrix:last_photo:{chat_id}:{user_id}",
fact_value=file_id,
fact_value_json={"file_id": file_id, "updated_at": datetime.utcnow().isoformat()},
team_id=dao_id,
)
except Exception as e:
logger.warning(f"AgroMatrix last photo ref save failed: {e}")
async def _get_agromatrix_last_photo_ref(*, chat_id: str, user_id: str, dao_id: str) -> Optional[str]:
if not (chat_id and user_id):
return None
try:
fact = await memory_client.get_fact(
user_id=AGROMATRIX_GLOBAL_KNOWLEDGE_USER_ID,
fact_key=f"agromatrix:last_photo:{chat_id}:{user_id}",
team_id=dao_id,
)
if not fact:
return None
data = fact.get("fact_value_json") if isinstance(fact, dict) else None
if isinstance(data, dict):
file_id = str(data.get("file_id") or "").strip()
if file_id:
return file_id
file_id = str(fact.get("fact_value") or "").strip() if isinstance(fact, dict) else ""
return file_id or None
except Exception as e:
logger.warning(f"AgroMatrix last photo ref lookup failed: {e}")
return None
def _needs_photo_only_response(text: str) -> bool:
"""
Return True only for explicit requests to analyze/describe image content.
Do not trigger on meta-dialogue about previous mistakes.
"""
t = (text or "").strip().lower()
if not t:
return False
explicit_patterns = [
r"(що|what|что).{0,24}(на|in).{0,24}(фото|зображ|світлин|image|photo)",
r"(опиши|describe|проаналізуй|analyz|анализируй).{0,32}(фото|зображ|image|photo)",
r"(яка|какая|what).{0,28}(рослин|plant|культура).{0,28}(на|in).{0,28}(фото|image|photo)",
]
return any(re.search(p, t) for p in explicit_patterns)
def _is_simple_greeting(text: str) -> bool:
t = (text or "").strip().lower()
if not t:
return False
compact = re.sub(r"[^a-zаіїєґ0-9 ]+", "", t).strip()
greetings = {
"привіт", "вітаю", "добрий день", "доброго дня", "доброго вечора",
"hello", "hi", "hey", "good morning", "good evening",
}
if compact in greetings:
return True
# Short greeting variants like "привіт!" / "hi!"
return len(compact.split()) <= 3 and any(g in compact for g in greetings)
def _extract_unanswered_user_messages(
memory_context: Dict[str, Any],
current_user_id: str,
max_items: int = 3,
) -> List[str]:
"""
Extract unresolved user questions from structured memory events.
A user message is considered unresolved if no later agent reply exists.
"""
events = memory_context.get("recent_events") or []
if not isinstance(events, list) or not current_user_id:
return []
def _normalize_tokens(raw: str) -> set:
toks = re.findall(r"[a-zA-Zа-яА-ЯіїєґІЇЄҐ0-9]{3,}", (raw or "").lower())
stop = {
"що", "як", "коли", "де", "хто", "чому", "який", "яка", "яке", "скільки", "чи",
"what", "how", "when", "where", "who", "why", "which",
"and", "for", "the", "this", "that", "with", "from",
}
return {t for t in toks if t not in stop}
def _looks_like_ack_or_generic(raw: str) -> bool:
t = (raw or "").strip().lower()
if not t:
return True
markers = [
"привіт", "вітаю", "чим можу допомогти", "ок", "добре", "дякую", "готово",
"hello", "hi", "how can i help", "thanks", "okay", "done",
]
return any(m in t for m in markers) and len(t) < 180
def _assistant_resolves_question(question_text: str, assistant_text: str) -> bool:
if _looks_like_ack_or_generic(assistant_text):
return False
q_tokens = _normalize_tokens(question_text)
a_tokens = _normalize_tokens(assistant_text)
if not q_tokens or not a_tokens:
return False
overlap = len(q_tokens.intersection(a_tokens))
# Require at least partial semantic overlap, otherwise do not auto-close.
return overlap >= 2 or (overlap >= 1 and len(q_tokens) <= 3)
pending: List[Dict[str, str]] = []
for ev in events:
role = str(ev.get("role") or ev.get("type") or "").lower()
text = str(ev.get("body_text") or "").strip()
if not text:
continue
if role == "user" and str(ev.get("user_id") or "") == current_user_id and _is_question_like(text):
pending.append({"text": text})
continue
if role in ("assistant", "agent") and pending:
# Resolve only matching question; do not auto-close all pending items.
resolved_idx = None
for idx, item in enumerate(pending):
if _assistant_resolves_question(item["text"], text):
resolved_idx = idx
break
if resolved_idx is not None:
pending.pop(resolved_idx)
# Keep the latest unresolved items only.
if len(pending) > max_items:
pending = pending[-max_items:]
return [p["text"] for p in pending]
def _is_question_like(text: str) -> bool:
"""
Detect user questions without false positives from substring matches
(e.g. 'схоже' should not match 'що').
"""
if not text:
return False
t = text.strip().lower()
if "?" in t:
return True
# Ukrainian / Russian / English interrogatives with word boundaries.
return bool(
re.search(
r"\b(що|як|чому|коли|де|хто|чи|what|why|how|when|where|who|зачем|почему|когда|где|кто|ли)\b",
t,
)
)
def _cleanup_user_language_prefs() -> None:
now = time.time()
expired = [k for k, v in USER_LANGUAGE_PREFS.items() if now - float(v.get("ts", 0)) > USER_LANGUAGE_PREF_TTL]
for k in expired:
del USER_LANGUAGE_PREFS[k]
def _cleanup_user_response_style_prefs() -> None:
now = time.time()
expired = [k for k, v in USER_RESPONSE_STYLE_PREFS.items() if now - float(v.get("ts", 0)) > USER_RESPONSE_STYLE_PREF_TTL]
for k in expired:
del USER_RESPONSE_STYLE_PREFS[k]
def _normalize_lang_code(raw: Optional[str]) -> Optional[str]:
if not raw:
return None
code = str(raw).strip().lower().replace("_", "-")
if code.startswith("uk"):
return "uk"
if code.startswith("ru"):
return "ru"
if code.startswith("en"):
return "en"
return None
def _detect_language_from_text(text: str) -> Optional[str]:
if not text:
return None
t = text.lower()
letters = [ch for ch in t if ch.isalpha()]
if not letters:
return None
cyr = sum(1 for ch in letters if "а" <= ch <= "я" or ch in "іїєґё")
lat = sum(1 for ch in letters if "a" <= ch <= "z")
if cyr >= 3 and cyr >= lat:
# Ukrainian-specific letters strongly indicate Ukrainian.
if any(ch in t for ch in "іїєґ"):
return "uk"
# Russian-specific letters/symbols.
if any(ch in t for ch in "ёыэъ"):
return "ru"
# Soft lexical preference.
uk_hits = sum(1 for w in ("що", "який", "дякую", "будь", "будь ласка", "привіт") if w in t)
ru_hits = sum(1 for w in ("что", "какой", "спасибо", "пожалуйста", "привет") if w in t)
if uk_hits > ru_hits:
return "uk"
if ru_hits > uk_hits:
return "ru"
return "uk"
if lat >= 3 and lat > cyr:
return "en"
return None
def resolve_preferred_language(chat_id: str, user_id: str, text: str, telegram_lang_code: Optional[str]) -> str:
_cleanup_user_language_prefs()
key = f"{chat_id}:{user_id}"
text_lang = _detect_language_from_text(text)
tg_lang = _normalize_lang_code(telegram_lang_code)
cached_lang = USER_LANGUAGE_PREFS.get(key, {}).get("lang")
preferred = text_lang or tg_lang or cached_lang or "uk"
USER_LANGUAGE_PREFS[key] = {"lang": preferred, "ts": time.time()}
return preferred
def preferred_language_label(lang: str) -> str:
return {
"uk": "Ukrainian",
"ru": "Russian",
"en": "English",
}.get((lang or "").lower(), "Ukrainian")
def _extract_preferred_language_from_profile_fact(fact: Optional[Dict[str, Any]]) -> Optional[str]:
if not isinstance(fact, dict):
return None
data = fact.get("fact_value_json")
if not isinstance(data, dict):
return None
preferred = _normalize_lang_code(data.get("preferred_language"))
if preferred:
return preferred
return _normalize_lang_code(data.get("language_code"))
async def resolve_preferred_language_persistent(
chat_id: str,
user_id: str,
text: str,
telegram_lang_code: Optional[str],
team_id: Optional[str] = None,
) -> str:
"""Resolve language with memory-service fallback for post-restart continuity."""
_cleanup_user_language_prefs()
key = f"{chat_id}:{user_id}"
text_lang = _detect_language_from_text(text)
tg_lang = _normalize_lang_code(telegram_lang_code)
cached_lang = USER_LANGUAGE_PREFS.get(key, {}).get("lang")
if text_lang or tg_lang or cached_lang:
preferred = text_lang or tg_lang or cached_lang or "uk"
USER_LANGUAGE_PREFS[key] = {"lang": preferred, "ts": time.time()}
return preferred
try:
fact = await memory_client.get_fact(
user_id=f"tg:{user_id}",
fact_key="profile",
team_id=team_id,
)
fact_lang = _extract_preferred_language_from_profile_fact(fact)
if fact_lang:
USER_LANGUAGE_PREFS[key] = {"lang": fact_lang, "ts": time.time()}
return fact_lang
except Exception as e:
logger.debug(f"preferred language fact lookup failed: {e}")
USER_LANGUAGE_PREFS[key] = {"lang": "uk", "ts": time.time()}
return "uk"
def _pending_state_cleanup():
now = time.time()
expired = [cid for cid, rec in LAST_PENDING_STATE.items() if now - rec.get('ts', 0) > PENDING_STATE_TTL]
for cid in expired:
del LAST_PENDING_STATE[cid]
def _get_last_pending(chat_id: str) -> list | None:
_pending_state_cleanup()
rec = LAST_PENDING_STATE.get(str(chat_id))
if not rec:
return None
return rec.get('items')
def _find_doc_in_history(history_text: str) -> dict | None:
"""
Шукає посилання на документ у тексті chat history.
Якщо знаходить рядок '[Документ: ...]' — повертає doc_context stub.
Це дозволяє Степану знати про документ навіть без збереженого extracted_summary.
"""
import re as _re
if not history_text:
return None
# Шукаємо паттерн [Документ: filename.xlsx]
matches = _re.findall(r'\[Документ:\s*([^\]]+)\]', history_text)
if not matches:
# Також шукаємо assistant-повідомлення про документ
matches = _re.findall(r'📄[^\n]*\*\*([^*]+)\*\*', history_text)
if matches:
file_name = matches[-1].strip() # Беремо найновіший
return {
"doc_id": "",
"title": file_name,
"extracted_summary": "", # немає вмісту — але є назва
"from_history": True,
}
return None
def _set_last_pending(chat_id: str, items: list):
LAST_PENDING_STATE[str(chat_id)] = {"ts": time.time(), "items": items}
def _chunk_text(text: str, max_len: int = 4096):
if not text:
return [""]
chunks = []
current = []
current_len = 0
for line in text.split("\n"):
add_len = len(line) + (1 if current else 0)
if current_len + add_len <= max_len:
current.append(line)
current_len += add_len
continue
if current:
chunks.append("\n".join(current))
current = []
current_len = 0
while len(line) > max_len:
chunks.append(line[:max_len])
line = line[max_len:]
current.append(line)
current_len = len(line)
if current:
chunks.append("\n".join(current))
return chunks
# Training groups - agents respond to ALL messages without mention requirement
TRAINING_GROUP_IDS = {
"-1003556680911", # Agent Preschool Daarion.city
}
# Brand stack services
BRAND_INTAKE_URL = os.getenv("BRAND_INTAKE_URL", "http://brand-intake:9211").rstrip("/")
BRAND_REGISTRY_URL = os.getenv("BRAND_REGISTRY_URL", "http://brand-registry:9210").rstrip("/")
PRESENTATION_RENDERER_URL = os.getenv("PRESENTATION_RENDERER_URL", "http://presentation-renderer:9212").rstrip("/")
ARTIFACT_REGISTRY_URL = os.getenv("ARTIFACT_REGISTRY_URL", "http://artifact-registry:9220").rstrip("/")
router = APIRouter()
# ========================================
# Agent Configuration
# ========================================
@dataclass
class AgentConfig:
"""Конфігурація агента для стандартизації обробки повідомлень"""
agent_id: str
name: str
prompt_path: str
telegram_token_env: str
default_prompt: str
system_prompt: str = "" # Буде встановлено після завантаження
def load_prompt(self) -> str:
"""Завантажити system prompt з файлу"""
try:
p = Path(self.prompt_path)
if not p.exists():
logger.warning(f"{self.name} prompt file not found: {self.prompt_path}")
return self.default_prompt
prompt = p.read_text(encoding="utf-8")
logger.info(f"{self.name} system prompt loaded ({len(prompt)} chars)")
return prompt
except Exception as e:
logger.error(f"Error loading {self.name} prompt: {e}")
return self.default_prompt
def get_telegram_token(self) -> Optional[str]:
"""Отримати Telegram токен агента"""
return os.getenv(self.telegram_token_env)
def load_agent_config(agent_id: str, name: str, prompt_path: str,
telegram_token_env: str, default_prompt: str) -> AgentConfig:
"""Створити та завантажити конфігурацію агента"""
config = AgentConfig(
agent_id=agent_id,
name=name,
prompt_path=prompt_path,
telegram_token_env=telegram_token_env,
default_prompt=default_prompt,
system_prompt="" # Тимчасове значення
)
# Завантажити prompt
config.system_prompt = config.load_prompt()
return config
# ========================================
# Agent Configurations
# ========================================
# DAARWIZZ Configuration
DAARWIZZ_CONFIG = load_agent_config(
agent_id="daarwizz",
name=os.getenv("DAARWIZZ_NAME", "DAARWIZZ"),
prompt_path=os.getenv(
"DAARWIZZ_PROMPT_PATH",
str(Path(__file__).parent / "daarwizz_prompt.txt"),
),
telegram_token_env="DAARWIZZ_TELEGRAM_BOT_TOKEN",
default_prompt=f"Ти — {os.getenv('DAARWIZZ_NAME', 'DAARWIZZ')}, AI-агент екосистеми DAARION.city. Допомагай учасникам з DAO-процесами."
)
# HELION Configuration
HELION_CONFIG = load_agent_config(
agent_id="helion",
name=os.getenv("HELION_NAME", "Helion"),
prompt_path=os.getenv(
"HELION_PROMPT_PATH",
str(Path(__file__).parent / "helion_prompt.txt"),
),
telegram_token_env="HELION_TELEGRAM_BOT_TOKEN",
default_prompt=f"Ти — {os.getenv('HELION_NAME', 'Helion')}, AI-агент платформи Energy Union. Допомагай учасникам з технологіями та токеномікою."
)
# GREENFOOD Configuration
GREENFOOD_CONFIG = load_agent_config(
agent_id="greenfood",
name=os.getenv("GREENFOOD_NAME", "GREENFOOD"),
prompt_path=os.getenv(
"GREENFOOD_PROMPT_PATH",
str(Path(__file__).parent / "greenfood_prompt.txt"),
),
telegram_token_env="GREENFOOD_TELEGRAM_BOT_TOKEN",
default_prompt="Ти — GREENFOOD Assistant, AI-ERP для крафтових виробників та кооперативів. Допомагай з обліком партій, логістикою, бухгалтерією та продажами."
)
# AGROMATRIX Configuration
AGROMATRIX_CONFIG = load_agent_config(
agent_id="agromatrix",
name=os.getenv("AGROMATRIX_NAME", "AgroMatrix"),
prompt_path=os.getenv(
"AGROMATRIX_PROMPT_PATH",
str(Path(__file__).parent / "agromatrix_prompt.txt"),
),
telegram_token_env="AGROMATRIX_TELEGRAM_BOT_TOKEN",
default_prompt="Ти — AgroMatrix, AI-агент для агроаналітики, планування сезонів та кооперації фермерів. Допомагай з порадами щодо полів, процесів і ринків."
)
# ALATEYA Configuration
ALATEYA_CONFIG = load_agent_config(
agent_id="alateya",
name=os.getenv("ALATEYA_NAME", "Alateya"),
prompt_path=os.getenv(
"ALATEYA_PROMPT_PATH",
str(Path(__file__).parent / "alateya_prompt.txt"),
),
telegram_token_env="ALATEYA_TELEGRAM_BOT_TOKEN",
default_prompt="Ти — Alateya, AI-агент R&D та біотех-інновацій. Допомагай з дослідженнями, протоколами й експериментальними дизайнами."
)
# NUTRA Configuration
NUTRA_CONFIG = load_agent_config(
agent_id="nutra",
name=os.getenv("NUTRA_NAME", "NUTRA"),
prompt_path=os.getenv(
"NUTRA_PROMPT_PATH",
str(Path(__file__).parent / "nutra_prompt.txt"),
),
telegram_token_env="NUTRA_TELEGRAM_BOT_TOKEN",
default_prompt="Ти — NUTRA, нутріцевтичний агент платформи DAARION. Допомагаєш з формулами нутрієнтів, біомедичних добавок та лабораторних інтерпретацій. Консультуєш з питань харчування, вітамінів та оптимізації здоров'я."
)
# Registry of all agents (для легкого додавання нових агентів)
#
# Щоб додати нового агента:
# 1. Створіть конфігурацію через load_agent_config():
# NEW_AGENT_CONFIG = load_agent_config(
# agent_id="new_agent",
# name=os.getenv("NEW_AGENT_NAME", "New Agent"),
# prompt_path=os.getenv("NEW_AGENT_PROMPT_PATH", str(Path(__file__).parent / "new_agent_prompt.txt")),
# telegram_token_env="NEW_AGENT_TELEGRAM_BOT_TOKEN",
# default_prompt="Ти — New Agent, AI-агент..."
# )
# 2. Додайте до реєстру:
# DRUID Configuration
DRUID_CONFIG = load_agent_config(
agent_id="druid",
name=os.getenv("DRUID_NAME", "DRUID"),
prompt_path=os.getenv(
"DRUID_PROMPT_PATH",
str(Path(__file__).parent / "druid_prompt.txt"),
),
telegram_token_env="DRUID_TELEGRAM_BOT_TOKEN",
default_prompt="Ти — DRUID, агент платформи DAARION. Допомагай користувачам з аналізом даних, рекомендаціями та інтеграцією RAG.",
)
# CLAN (Spirit) Configuration
CLAN_CONFIG = load_agent_config(
agent_id="clan",
name=os.getenv("CLAN_NAME", "Spirit"),
prompt_path=os.getenv(
"CLAN_PROMPT_PATH",
str(Path(__file__).parent / "clan_prompt.txt"),
),
telegram_token_env="CLAN_TELEGRAM_BOT_TOKEN",
default_prompt="Ти — CLAN (Spirit), Дух Общини в екосистемі DAARION.city. Підтримуєш зв'язки між учасниками спільноти, зберігаєш традиції та допомагаєш в прийнятті колективних рішень.",
)
# EONARCH Configuration
EONARCH_CONFIG = load_agent_config(
agent_id="eonarch",
name=os.getenv("EONARCH_NAME", "EONARCH"),
prompt_path=os.getenv(
"EONARCH_PROMPT_PATH",
str(Path(__file__).parent / "eonarch_prompt.txt"),
),
telegram_token_env="EONARCH_TELEGRAM_BOT_TOKEN",
default_prompt="Ти — EONARCH, провідник еволюції свідомості в екосистемі DAARION.city. Супроводжуєш людство на шляху трансформації свідомості до колективної мудрості.",
)
# SENPAI (Gordon Senpai) Configuration
SENPAI_CONFIG = load_agent_config(
agent_id="senpai",
name=os.getenv("SENPAI_NAME", "SENPAI"),
prompt_path=os.getenv(
"SENPAI_PROMPT_PATH",
str(Path(__file__).parent / "senpai_prompt.txt"),
),
telegram_token_env="SENPAI_TELEGRAM_BOT_TOKEN",
default_prompt="Ти — Гордон Сенпай (Gordon Senpai), радник з ринків капіталу та цифрових активів. Допомагаєш з трейдингом, ризик-менеджментом, аналізом ринків.",
)
# 1OK Configuration
ONEOK_CONFIG = load_agent_config(
agent_id="oneok",
name=os.getenv("ONEOK_NAME", "1OK"),
prompt_path=os.getenv(
"ONEOK_PROMPT_PATH",
str(Path(__file__).parent / "oneok_prompt.txt"),
),
telegram_token_env="ONEOK_TELEGRAM_BOT_TOKEN",
default_prompt="Ти — 1OK, асистент віконного майстра. Допомагаєш з кваліфікацією ліда, підготовкою заміру та формуванням комерційної пропозиції.",
)
# SOUL / Athena Configuration
SOUL_CONFIG = load_agent_config(
agent_id="soul",
name=os.getenv("SOUL_NAME", "Athena"),
prompt_path=os.getenv(
"SOUL_PROMPT_PATH",
str(Path(__file__).parent / "soul_prompt.txt"),
),
telegram_token_env="SOUL_TELEGRAM_BOT_TOKEN",
default_prompt="Ти — Athena, духовний гід та ментор спільноти DAARION.city. Підтримуєш місію, цінності та зв\'язки між учасниками.",
)
# YAROMIR Configuration
YAROMIR_CONFIG = load_agent_config(
agent_id="yaromir",
name=os.getenv("YAROMIR_NAME", "Yaromir"),
prompt_path=os.getenv(
"YAROMIR_PROMPT_PATH",
str(Path(__file__).parent / "yaromir_prompt.txt"),
),
telegram_token_env="YAROMIR_TELEGRAM_BOT_TOKEN",
default_prompt="Ти — Yaromir, стратег та наставник в екосистемі DAARION.city. Стратегія, наставництво, психологічна підтримка команди.",
)
# SOFIIA (Sophia) Configuration
SOFIIA_CONFIG = load_agent_config(
agent_id="sofiia",
name=os.getenv("SOFIIA_NAME", "Sophia"),
prompt_path=os.getenv(
"SOFIIA_PROMPT_PATH",
str(Path(__file__).parent / "sofiia_prompt.txt"),
),
telegram_token_env="SOFIIA_TELEGRAM_BOT_TOKEN",
default_prompt="Ти — Sophia (Софія), Chief AI Architect та Technical Sovereign екосистеми DAARION.city. Координуєш R&D, архітектуру, безпеку та еволюцію платформи.",
)
# Registry of all agents (для легкого додавання нових агентів)
AGENT_REGISTRY: Dict[str, AgentConfig] = {
"daarwizz": DAARWIZZ_CONFIG,
"helion": HELION_CONFIG,
"greenfood": GREENFOOD_CONFIG,
"agromatrix": AGROMATRIX_CONFIG,
"alateya": ALATEYA_CONFIG,
"nutra": NUTRA_CONFIG,
"druid": DRUID_CONFIG,
"clan": CLAN_CONFIG,
"eonarch": EONARCH_CONFIG,
"senpai": SENPAI_CONFIG,
"oneok": ONEOK_CONFIG,
"soul": SOUL_CONFIG,
"yaromir": YAROMIR_CONFIG,
"sofiia": SOFIIA_CONFIG,
}
# 3. Створіть endpoint (опціонально, якщо потрібен окремий webhook):
# @router.post("/new_agent/telegram/webhook")
# async def new_agent_telegram_webhook(update: TelegramUpdate):
# return await handle_telegram_webhook(NEW_AGENT_CONFIG, update)
#
# Новий агент автоматично отримає:
# - Обробку фото через Swapper vision-8b
# - Обробку PDF документів
# - Обробку голосових повідомлень (коли буде реалізовано)
# - RAG запити по документам
# - Memory context
# AGENT_REGISTRY["new_agent"] = NEW_AGENT_CONFIG
# 3. Створіть endpoint (опціонально, якщо потрібен окремий webhook):
# Backward compatibility
DAARWIZZ_NAME = DAARWIZZ_CONFIG.name
DAARWIZZ_SYSTEM_PROMPT = DAARWIZZ_CONFIG.system_prompt
HELION_NAME = HELION_CONFIG.name
HELION_SYSTEM_PROMPT = HELION_CONFIG.system_prompt
GREENFOOD_NAME = GREENFOOD_CONFIG.name
GREENFOOD_SYSTEM_PROMPT = GREENFOOD_CONFIG.system_prompt
# ========================================
# Request Models
# ========================================
class TelegramUpdate(BaseModel):
"""Simplified Telegram update model"""
update_id: Optional[int] = None
message: Optional[Dict[str, Any]] = None
channel_post: Optional[Dict[str, Any]] = None
# DRUID webhook endpoint
@router.post("/druid/telegram/webhook")
async def druid_telegram_webhook(update: TelegramUpdate):
return await handle_telegram_webhook(DRUID_CONFIG, update)
# AGROMATRIX webhook endpoint
# AGX_STEPAN_MODE: inproc = run Crew in-process (default); http = call crewai-service (9010).
_STEPAN_MODE = None
def _get_stepan_mode() -> str:
global _STEPAN_MODE
if _STEPAN_MODE is None:
_STEPAN_MODE = (os.getenv("AGX_STEPAN_MODE", "inproc") or "inproc").strip().lower()
if _STEPAN_MODE not in ("inproc", "http"):
_STEPAN_MODE = "inproc"
logger.info("Stepan mode=%s (AGX_STEPAN_MODE)", _STEPAN_MODE)
return _STEPAN_MODE
async def handle_stepan_message(update: TelegramUpdate, agent_config: AgentConfig) -> Dict[str, Any]:
update_id = getattr(update, 'update_id', None) or update.update_id
if update_id:
if update_id in _PROCESSED_UPDATES:
return {"ok": True, "status": "duplicate"}
_PROCESSED_UPDATES[update_id] = _time.time()
if len(_PROCESSED_UPDATES) > _DEDUP_MAX_SIZE:
_dedup_cleanup()
message = update.message or update.channel_post or {}
text = message.get('text') or message.get('caption') or ''
user = message.get('from', {}) or {}
chat = message.get('chat', {}) or {}
user_id = str(user.get('id', ''))
chat_id = str(chat.get('id', ''))
# ── DOC HANDOFF + EXTRACT-ON-UPLOAD (v3.4 / PROMPT 30) ─────────────────
# При отриманні документа (operator path):
# 1) зберегти базовий doc_ctx (doc_id, file_name)
# 2) для XLSX/XLS/CSV: завантажити байти через Bot API, витягнути summary,
# оновити doc_context_chat з extracted_summary → Stepan бачить дані одразу
_doc_obj = message.get("document")
if _doc_obj and _doc_obj.get("file_id"):
_file_id_tg = _doc_obj.get("file_id")
_fu_id = _doc_obj.get("file_unique_id") or _file_id_tg
_fname = _doc_obj.get("file_name") or ""
_bot_token = agent_config.get_telegram_token() or ""
_doc_ctx_to_save: dict = {
"doc_id": _fu_id,
"file_unique_id": _fu_id,
"file_id": _file_id_tg,
"file_name": _fname,
"source": "telegram",
# Fix D: явно фіксуємо anchor одразу при upload — run.py може читати без парсингу doc_id
"active_doc_id": _fu_id,
}
# Крок 1: зберегти базовий doc_ctx (await = race-safe)
await save_chat_doc_context(chat_id, agent_config.agent_id, _doc_ctx_to_save)
logger.info("Doc Handoff: saved base doc_id=%s file=%s", str(_fu_id)[:16], _fname)
# Крок 2: Extract-on-upload для табличних форматів
_fname_lower = _fname.lower()
_extractable = _fname_lower.endswith((".xlsx", ".xls", ".csv"))
_extract_ok = False
if _extractable and _bot_token:
# Fix 1: One-shot cache — якщо summary вже є для того самого file_unique_id → skip
_existing_ctx = await get_chat_doc_context(chat_id, agent_config.agent_id)
_already_have = (
_existing_ctx
and _existing_ctx.get("extracted_summary")
and (_existing_ctx.get("file_unique_id") or _existing_ctx.get("doc_id")) == _fu_id
)
if _already_have:
_extract_ok = True
logger.info("doc_extract_skipped reason=already_have_summary chat_id=%s fuid=%s",
chat_id, str(_fu_id)[:16])
else:
logger.info("doc_extract_started chat_id=%s file=%s", chat_id, _fname)
try:
_file_bytes = await fetch_telegram_file_bytes(_bot_token, _file_id_tg)
_extract_summary = extract_summary_from_bytes(_fname, _file_bytes)
if _extract_summary:
await upsert_chat_doc_context_with_summary(
chat_id, agent_config.agent_id, _doc_ctx_to_save, _extract_summary
)
_extract_ok = True
logger.info("doc_extract_done ok=true chat_id=%s chars=%d",
chat_id, len(_extract_summary))
else:
logger.warning("doc_extract_done ok=false reason=empty_summary chat_id=%s", chat_id)
except Exception as _ee:
logger.warning("doc_extract_done ok=false reason=%s chat_id=%s",
str(_ee)[:80], chat_id)
# Якщо тексту/caption немає — підтверджуємо отримання і виходимо
if not text:
if _extract_ok:
_reply = (
f"Прочитав «{_fname}». Можу: (1) витягнути прибуток/витрати, "
f"(2) сценарій — добрива×2, (3) зведення грн/га. Що потрібно?"
)
elif _extractable:
_reply = (
f"Отримав «{_fname}», але не зміг витягнути дані автоматично. "
f"Постав питання — перегляну через пошук по документу."
)
else:
_reply = (
f"Бачу «{_fname}». Що зробити: витягнути прибуток/витрати, "
f"сценарій, чи звести по га?"
)
await send_telegram_message(chat_id, _reply, bot_token=_bot_token)
return {"ok": True, "status": "doc_saved"}
# ── PHOTO BRIDGE (v3.5) ─────────────────────────────────────────────────
# Фото в operator path раніше провалювалося через "if not text" і тихо ігнорувалося.
# Тепер: делегуємо до process_photo (vision-8b через Router) — той самий шлях,
# що використовують всі інші агенти. Агент AgroMatrix вже має спеціальний контекст
# (prior_label + agricultural system prompt) у process_photo.
_photo_obj = message.get("photo")
if _photo_obj and not text:
# text може бути caption — вже вище: text = message.get('caption') or ''
# якщо caption не порожній — photo+caption піде в text-гілку нижче (Stepan відповідає)
# тут тільки "фото без тексту"
_username = (user.get('username') or user.get('first_name') or str(user_id))
_dao_id = os.getenv("AGX_DAO_ID", "agromatrix-dao")
_bot_tok = agent_config.get_telegram_token() or ""
logger.info("Photo bridge: routing photo to process_photo chat_id=%s", chat_id)
try:
_photo_result = await process_photo(
agent_config=agent_config,
update=update,
chat_id=chat_id,
user_id=user_id,
username=_username,
dao_id=_dao_id,
photo=_photo_obj,
caption_override=None,
bypass_media_gate=True, # operator path = завжди відповідати
)
# v3.5 fix: зберігаємо timestamp фото в session Степана
# щоб наступний текстовий запит (words=1) знав що щойно було фото
try:
from crews.agromatrix_crew.session_context import update_session
import time as _ts_mod
update_session(
chat_id, "[photo]", depth="light", agents=[],
last_question=None,
last_photo_ts=_ts_mod.time(),
)
logger.info("Photo bridge: last_photo_ts saved chat_id=%s", chat_id)
except Exception:
pass
return _photo_result
except Exception as _pe:
logger.warning("Photo bridge error: %s", _pe)
await send_telegram_message(
chat_id,
"Не вдалося обробити фото. Спробуй ще раз або напиши що на фото.",
bot_token=_bot_tok,
)
return {"ok": True, "status": "photo_error"}
if not text:
return {"ok": True, "status": "no_text"}
# ── PHOTO+TEXT: якщо є caption → Stepan отримує опис через doc_context ─────
# Якщо text (caption) є + фото → стандартний flow Степана + зберігаємо file_id
# щоб він міг згадати фото у відповіді.
if _photo_obj and text:
_photo_largest = _photo_obj[-1] if isinstance(_photo_obj, list) else _photo_obj
_photo_file_id = _photo_largest.get("file_id") if isinstance(_photo_largest, dict) else None
if _photo_file_id:
_set_recent_photo_context(agent_config.agent_id, chat_id, user_id, _photo_file_id)
# ── VISION CONSISTENCY GUARD: Хук C — User Override ─────────────────────
# Whitelist + negation guard: "це соняшник" → user_label;
# "це не соняшник" → ігноруємо.
if text:
try:
_vg_override = _vg_detect_override(text)
if _vg_override:
_vg_set_user_label(agent_config.agent_id, chat_id, _vg_override)
logger.info(
"vision_user_override_set agent=%s chat_id=%s label=%s",
agent_config.agent_id, chat_id, _vg_override,
)
except Exception:
pass
# ─────────────────────────────────────────────────────────────────────────
# ops mode if operator
ops_mode = False
op_ids = [s.strip() for s in os.getenv('AGX_OPERATOR_IDS', '').split(',') if s.strip()]
op_chat = os.getenv('AGX_OPERATOR_CHAT_ID', '').strip()
if op_chat and chat_id == op_chat:
ops_mode = True
if user_id and user_id in op_ids:
ops_mode = True
trace_id = str(uuid.uuid4())
stepan_mode = _get_stepan_mode()
if stepan_mode == "http":
logger.warning("Stepan http mode not implemented; use AGX_STEPAN_MODE=inproc.")
bot_token = agent_config.get_telegram_token()
await send_telegram_message(
chat_id,
"Степан у режимі HTTP зараз недоступний. Встановіть AGX_STEPAN_MODE=inproc.",
bot_token=bot_token,
)
return {"ok": False, "status": "stepan_http_not_implemented"}
try:
import gateway_boot
except ImportError:
gateway_boot = type(sys)("gateway_boot")
gateway_boot.STEPAN_IMPORTS_OK = False
if not getattr(gateway_boot, "STEPAN_IMPORTS_OK", False):
logger.warning("Stepan inproc disabled: crews/agromatrix_tools not available at startup")
bot_token = agent_config.get_telegram_token()
await send_telegram_message(
chat_id,
"Степан тимчасово недоступний (не встановлено crews або agromatrix-tools).",
bot_token=bot_token,
)
return {"ok": False, "status": "stepan_disabled"}
try:
# v3: crews/ is in /app/gateway-bot/crews (volume-mounted copy)
# AGX_REPO_ROOT can override for dev/alt deployments
repo_root = os.getenv("AGX_REPO_ROOT", "")
_gw = "/app/gateway-bot"
_at = "/app/gateway-bot/agromatrix-tools"
for _p in [_at, _gw, repo_root]:
if _p and _p not in sys.path:
sys.path.insert(0, _p)
from crews.agromatrix_crew.run import handle_message
# Doc Bridge (v3.3): отримати активний doc_context для цього chat.
# Пріоритет: chat-scoped (doc_context_chat:) > session-scoped (doc_context:).
_stepan_doc_ctx: dict | None = None
try:
# 1) Спочатку пробуємо chat-scoped (надійніший при зміні session_id)
_chat_dc = await get_chat_doc_context(chat_id, agent_config.agent_id)
if _chat_dc and (_chat_dc.get("doc_id") or _chat_dc.get("file_unique_id")):
_chat_doc_id = _chat_dc.get("doc_id") or _chat_dc.get("file_unique_id")
_chat_extracted = _chat_dc.get("extracted_summary") or ""
_chat_fname = _chat_dc.get("file_name") or ""
# Якщо chat-scoped є але без extracted_summary → шукаємо в session-scoped
if not _chat_extracted:
try:
_dc_sess = await get_doc_context(f"telegram:{chat_id}", agent_id=agent_config.agent_id)
if _dc_sess and getattr(_dc_sess, "extracted_summary", None):
_chat_extracted = _dc_sess.extracted_summary
except Exception:
pass
# Якщо ще немає — RAG fallback
if not _chat_extracted and _chat_doc_id:
try:
_qa = await ask_about_document(
session_id=f"telegram:{chat_id}",
question=text,
doc_id=_chat_doc_id,
dao_id=os.getenv("AGX_DAO_ID", "agromatrix-dao"),
user_id=f"tg:{user_id}",
agent_id=agent_config.agent_id,
)
if _qa and getattr(_qa, "answer", None):
_chat_extracted = f"[RAG відповідь по документу]: {_qa.answer}"
logger.info("Doc Bridge: RAG answer retrieved for chat doc_id=%s", _chat_doc_id)
except Exception as _qae:
logger.debug("Doc Bridge RAG fallback failed: %s", _qae)
_stepan_doc_ctx = {
"doc_id": _chat_doc_id,
"title": _chat_fname,
"extracted_summary": _chat_extracted,
"file_unique_id": _chat_dc.get("file_unique_id") or _chat_doc_id,
}
logger.info("Doc Bridge: chat-scoped doc_id=%s found=true", _chat_doc_id[:16] if _chat_doc_id else "")
else:
# 2) Fallback: session-scoped (старий ключ)
_dc = await get_doc_context(f"telegram:{chat_id}", agent_id=agent_config.agent_id)
if _dc and getattr(_dc, "doc_id", None):
_extracted = getattr(_dc, "extracted_summary", "") or ""
if not _extracted and getattr(_dc, "doc_id", None):
try:
_qa = await ask_about_document(
session_id=f"telegram:{chat_id}",
question=text,
doc_id=_dc.doc_id,
dao_id=os.getenv("AGX_DAO_ID", "agromatrix-dao"),
user_id=f"tg:{user_id}",
agent_id=agent_config.agent_id,
)
if _qa and getattr(_qa, "answer", None):
_extracted = f"[RAG відповідь по документу]: {_qa.answer}"
logger.info("Doc Bridge: session-scoped RAG retrieved for doc_id=%s", _dc.doc_id)
except Exception as _qae:
logger.debug("Doc Bridge session RAG fallback failed: %s", _qae)
_stepan_doc_ctx = {
"doc_id": _dc.doc_id,
"title": getattr(_dc, "file_name", "") or "",
"extracted_summary": _extracted,
"file_unique_id": _dc.doc_id,
}
logger.info("Doc Bridge: session-scoped doc_id=%s found=true", _dc.doc_id)
except Exception as _dce:
logger.debug("Doc Bridge: could not fetch doc_context: %s", _dce)
# Chat History Bridge (v3.2): передаємо history з memory-service в Степана.
# Степан інакше не має доступу до переписки — він викликається поза Router pipeline.
_stepan_chat_history: str = ""
try:
_ctx = await memory_client.get_context(
user_id=f"tg:{user_id}",
agent_id=agent_config.agent_id,
team_id=os.getenv("AGX_DAO_ID", "agromatrix-dao"),
channel_id=chat_id,
limit=40,
)
_stepan_chat_history = _ctx.get("local_context_text", "") or ""
# Якщо в history є документ — і _stepan_doc_ctx порожній, шукаємо в history
if not _stepan_doc_ctx and _stepan_chat_history:
_doc_in_history = _find_doc_in_history(_stepan_chat_history)
if _doc_in_history:
_stepan_doc_ctx = _doc_in_history
logger.info("Doc Bridge: found doc reference in chat history: %s",
_doc_in_history.get("title", ""))
except Exception as _che:
logger.debug("Chat History Bridge failed (non-blocking): %s", _che)
started = time.time()
last_pending = _get_last_pending(chat_id)
response_text = await asyncio.wait_for(
asyncio.to_thread(
handle_message, text, user_id, chat_id, trace_id, ops_mode, last_pending,
None, None, bool(_stepan_doc_ctx), _stepan_doc_ctx,
_stepan_chat_history,
),
timeout=55
)
duration_ms = int((time.time() - started) * 1000)
except Exception as e:
logger.error(f"Stepan handler error: {e}; trace_id={trace_id}")
# SANITIZE: без trace_id для юзера (trace_id тільки в логах)
response_text = "Щось пішло не так. Спробуй ще раз або переформулюй запит."
duration_ms = 0
# If JSON, try to show summary
try:
parsed = json.loads(response_text)
summary = parsed.get('summary')
if summary:
response_text = summary
if parsed.get('details'):
response_text += "\n(details truncated)"
except Exception:
pass
# chunk and send
bot_token = agent_config.get_telegram_token()
for chunk in _chunk_text(response_text, max_len=4096):
await send_telegram_message(chat_id, chunk, bot_token=bot_token)
logger.info(f"Stepan reply sent: trace_id={trace_id}, user_id={user_id}, chat_id={chat_id}, update_id={update_id}, duration_ms={duration_ms}")
return {"ok": True}
@router.post("/agromatrix/telegram/webhook")
async def agromatrix_telegram_webhook(update: TelegramUpdate):
# Check if this is an operator request (slash command or NL operator intent)
message = (update.message or update.channel_post or {})
msg_text = message.get('text') or message.get('caption') or ''
user = message.get('from', {}) or {}
chat = message.get('chat', {}) or {}
user_id = str(user.get('id', ''))
chat_id = str(chat.get('id', ''))
is_slash = msg_text.strip().startswith('/')
is_ops = False
op_ids = [s.strip() for s in os.getenv('AGX_OPERATOR_IDS', '').split(',') if s.strip()]
op_chat = os.getenv('AGX_OPERATOR_CHAT_ID', '').strip()
if op_chat and chat_id == op_chat:
is_ops = True
if user_id and user_id in op_ids:
is_ops = True
# Operator: any message (not only slash) goes to Stepan when is_ops.
# v3: stepan_enabled checks DEEPSEEK_API_KEY (preferred) OR OPENAI_API_KEY (fallback)
stepan_enabled = bool(
os.getenv("DEEPSEEK_API_KEY", "").strip()
or os.getenv("OPENAI_API_KEY", "").strip()
)
if stepan_enabled and is_ops:
return await handle_stepan_message(update, AGROMATRIX_CONFIG)
if is_ops and not stepan_enabled:
logger.warning(
"Stepan handler disabled (no DEEPSEEK_API_KEY / OPENAI_API_KEY); "
"falling back to Router pipeline "
f"for chat_id={chat_id}, user_id={user_id}"
)
# General conversation -> standard Router pipeline (like all other agents)
return await handle_telegram_webhook(AGROMATRIX_CONFIG, update)
# ALATEYA webhook endpoint
@router.post("/alateya/telegram/webhook")
async def alateya_telegram_webhook(update: TelegramUpdate):
return await handle_telegram_webhook(ALATEYA_CONFIG, update)
# CLAN (Spirit) webhook endpoint
@router.post("/clan/telegram/webhook")
async def clan_telegram_webhook(update: TelegramUpdate):
return await handle_telegram_webhook(CLAN_CONFIG, update)
# EONARCH webhook endpoint
@router.post("/eonarch/telegram/webhook")
async def eonarch_telegram_webhook(update: TelegramUpdate):
return await handle_telegram_webhook(EONARCH_CONFIG, update)
# SENPAI (Gordon Senpai) webhook endpoint
@router.post("/senpai/telegram/webhook")
async def senpai_telegram_webhook(update: TelegramUpdate):
return await handle_telegram_webhook(SENPAI_CONFIG, update)
# 1OK webhook endpoint
@router.post("/oneok/telegram/webhook")
async def oneok_telegram_webhook(update: TelegramUpdate):
return await handle_telegram_webhook(ONEOK_CONFIG, update)
# SOUL / Athena webhook endpoint
@router.post("/soul/telegram/webhook")
async def soul_telegram_webhook(update: TelegramUpdate):
return await handle_telegram_webhook(SOUL_CONFIG, update)
# YAROMIR webhook endpoint
@router.post("/yaromir/telegram/webhook")
async def yaromir_telegram_webhook(update: TelegramUpdate):
return await handle_telegram_webhook(YAROMIR_CONFIG, update)
# SOFIIA (Sophia) webhook endpoint
@router.post("/sofiia/telegram/webhook")
async def sofiia_telegram_webhook(update: TelegramUpdate):
return await handle_telegram_webhook(SOFIIA_CONFIG, update)
class DiscordMessage(BaseModel):
"""Simplified Discord message model"""
content: Optional[str] = None
author: Optional[Dict[str, Any]] = None
channel_id: Optional[str] = None
guild_id: Optional[str] = None
# ========================================
# DAO Mapping (temporary)
# ========================================
# Map agent_id to DAO ID
AGENT_TO_DAO = {
"helion": "helion-dao",
"greenfood": "greenfood-dao",
"agromatrix": "agromatrix-dao",
"nutra": "nutra-dao",
"druid": "druid-dao",
"daarwizz": "daarwizz-dao",
"clan": "clan-dao",
"alateya": "alateya-dao",
"eonarch": "eonarch-dao",
"senpai": "senpai-dao",
"soul": "soul-dao",
"yaromir": "yaromir-dao",
}
# Legacy: Map chat/channel ID to DAO ID
CHAT_TO_DAO = {
"default": "daarion-dao",
}
def get_dao_id(chat_id: str, source: str, agent_id: str = None) -> str:
"""Get DAO ID from agent_id or chat ID"""
if agent_id and agent_id in AGENT_TO_DAO:
return AGENT_TO_DAO[agent_id]
key = f"{source}:{chat_id}"
return CHAT_TO_DAO.get(key, CHAT_TO_DAO["default"])
# ========================================
# Helper Functions
# ========================================
SERVICE_ACK_PREFIXES = (
"📥 Імпортую",
"📄 Обробляю",
"Обробляю голосове",
"🎤",
)
def is_service_response(text: str) -> bool:
"""Heuristic: визначає, чи відповідь є службовою (вітальна/ack)."""
if not text:
return True
stripped = text.strip()
if not stripped:
return True
if len(stripped) < 5:
return True
lower = stripped.lower()
return any(lower.startswith(prefix.lower()) for prefix in SERVICE_ACK_PREFIXES)
def extract_bot_mentions(text: str) -> List[str]:
"""Витягує згадки інших ботів виду @NameBot."""
if not text:
return []
mentions = []
for token in text.split():
if token.startswith("@") and token[1:].lower().endswith("bot"):
mentions.append(token[1:])
return mentions
def should_force_detailed_reply(text: str) -> bool:
"""Soft signal: user explicitly asks for details/long format."""
if not text:
return False
lower = text.strip().lower()
detail_markers = [
"детально", "подробно", "розгорну", "розпиши", "по всіх пунктах",
"step by step", "покроково", "з прикладами", "глибоко", "deep dive",
"full", "повний розбір", "максимально детально",
]
return any(m in lower for m in detail_markers)
def should_force_concise_reply(text: str) -> bool:
"""Soft concise mode by default, unless user asks for detailed answer."""
if not text:
return True
stripped = text.strip()
if not stripped:
return True
if should_force_detailed_reply(stripped):
return False
# Very long user request usually means they expect context-aware answer.
if len(stripped) > 700:
return False
# For regular Q&A in chat keep first response concise by default.
return True
def _detect_response_style_signal(text: str) -> Optional[str]:
t = (text or "").strip().lower()
if not t:
return None
concise_markers = ["коротко", "коротка відповідь", "лаконічно", "brief", "short answer"]
detailed_markers = ["детально", "розгорнуто", "поясни детальніше", "deep dive", "step by step"]
if any(m in t for m in detailed_markers):
return "detailed"
if any(m in t for m in concise_markers):
return "concise"
return None
async def resolve_response_style_preference(
agent_id: str,
chat_id: str,
user_id: str,
text: str,
team_id: Optional[str],
) -> str:
_cleanup_user_response_style_prefs()
cache_key = f"{agent_id}:{chat_id}:{user_id}"
signal = _detect_response_style_signal(text)
now = time.time()
if signal in ("concise", "detailed"):
USER_RESPONSE_STYLE_PREFS[cache_key] = {"style": signal, "ts": now}
await memory_client.upsert_fact(
user_id=f"tg:{user_id}",
fact_key=f"communication_profile:{agent_id}",
fact_value_json={"response_style": signal, "updated_at": datetime.utcnow().isoformat()},
team_id=team_id,
)
return signal
cached = USER_RESPONSE_STYLE_PREFS.get(cache_key)
if cached:
return str(cached.get("style") or "concise")
fact = await memory_client.get_fact(
user_id=f"tg:{user_id}",
fact_key=f"communication_profile:{agent_id}",
team_id=team_id,
)
if fact:
data = fact.get("fact_value_json") if isinstance(fact, dict) else None
if isinstance(data, str):
try:
data = json.loads(data)
except Exception:
data = None
if isinstance(data, dict):
style = str(data.get("response_style") or "").lower()
if style in ("concise", "detailed"):
USER_RESPONSE_STYLE_PREFS[cache_key] = {"style": style, "ts": now}
return style
return "concise"
def _redact_private_mentions(text: str) -> str:
if not text:
return ""
sanitized = text
sanitized = re.sub(r"(?i)(email|e-mail)\s*[:\-]?\s*[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", r"\1: [redacted]", sanitized)
sanitized = re.sub(r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", "[redacted-email]", sanitized)
sanitized = re.sub(r"(?<!\d)(\+?\d[\d\s\-\(\)]{8,}\d)(?!\d)", "[redacted-phone]", sanitized)
return sanitized
def _is_private_profile_dump_request(user_text: str) -> bool:
t = (user_text or "").lower()
if not t:
return False
markers = [
"приватн", "контакт ментор", "телефон ментор", "email ментор",
"всі контакти", "скинь контакти", "private data", "mentor contacts",
]
return any(m in t for m in markers)
def _block_private_profile_dump(user_text: str) -> Optional[str]:
if not _is_private_profile_dump_request(user_text):
return None
return (
"Не можу надавати приватні дані людей (контакти, особисті профілі). "
"Можу дати лише публічну, узагальнену інформацію."
)
def _is_numeric_question(text: str) -> bool:
t = (text or "").lower()
if not t:
return False
markers = ["скільки", "сума", "витрат", "добрив", "грн", "кг", "вартість", "cost", "amount", "total", "spent"]
return any(m in t for m in markers)
def _has_numeric_answer_contract(answer_text: str) -> bool:
a = (answer_text or "").lower()
if not a:
return False
has_value = bool(re.search(r"\d", a))
has_unit = any(u in a for u in ("грн", "uah", "usd", "eur", "кг", "kg", "%"))
has_source = any(s in a for s in ("рядок", "лист", "sheet", "row", "джерело"))
return has_value and has_unit and has_source
def _answer_seems_off_intent(user_text: str, answer_text: str) -> bool:
u = (user_text or "").lower()
a = (answer_text or "").lower()
if not u or not a:
return False
if _is_numeric_question(u) and not _has_numeric_answer_contract(answer_text):
return True
if any(k in u for k in ("excel", "xlsx", "таблиц", "файл", "звіт")) and any(
k in a for k in ("вступ", "структура", "презентаці", "слайд")
):
return True
return False
def _sanitize_agent_answer(agent_id: str, user_text: str, answer_text: str) -> str:
blocked = _block_private_profile_dump(user_text)
if blocked:
return blocked
sanitized = _redact_private_mentions(answer_text or "")
return sanitized
def _strip_answer_markup_noise(answer_text: str) -> str:
if not answer_text:
return ""
cleaned = answer_text.strip()
cleaned = re.sub(r"^\s*\*{1,3}\s*коротка відповідь\s*:?\s*\*{0,3}\s*", "", cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r"^\s*\*{1,3}\s*відповідь\s*:?\s*\*{0,3}\s*", "", cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r"^\s*#+\s*", "", cleaned)
# Remove markdown emphasis noise that leaks into short answers
cleaned = cleaned.replace("**", "")
cleaned = cleaned.replace("__", "")
return cleaned.strip()
def _compress_bulleted_answer(answer_text: str, max_items: int = 3) -> str:
if not answer_text:
return ""
lines = [ln.strip() for ln in answer_text.splitlines() if ln.strip()]
bullet_lines: List[str] = []
for ln in lines:
normalized = ln.replace("**", "").replace("__", "").strip()
if re.match(r"^(\*?\s*[-*•]|\*?\s*\d+[\.\):])\s*", normalized):
item = re.sub(r"^(\*?\s*[-*•]|\*?\s*\d+[\.\):])\s*", "", normalized).strip()
item = re.sub(r"\s+", " ", item).strip(" -–—")
item = re.sub(r"\.{2,}", ".", item)
item = re.sub(r"\s+\.", ".", item)
# Keep concise mode truly short: first complete sentence from each bullet.
parts = re.split(r"(?<=[.!?…])\s+", item)
if parts:
item = parts[0].strip()
item = item.rstrip(":").strip()
if item:
bullet_lines.append(item)
if not bullet_lines:
return answer_text.strip()
picked = bullet_lines[:max_items]
joined = ". ".join(picked)
if joined and not joined.endswith((".", "!", "?")):
joined += "."
joined = re.sub(r"\s+", " ", joined).strip()
return joined or answer_text.strip()
def _limit_to_sentences(text: str, max_sentences: int = 3) -> str:
if not text:
return ""
parts = re.split(r"(?<=[.!?…])\s+", text.strip())
parts = [p.strip() for p in parts if p.strip()]
if len(parts) <= max_sentences:
return " ".join(parts).strip()
return " ".join(parts[:max_sentences]).strip()
def _agromatrix_rewrite_capability_limitations(user_text: str, answer_text: str) -> str:
if not answer_text:
return answer_text
low = answer_text.lower()
limitation_markers = (
"не можу бачити", "не можу переглядати зображення", "не маю доступу до зображень",
"працюю лише з текстом", "працюю виключно з текстом",
"cannot view images", "cannot analyze images", "as a text model",
)
if not any(m in low for m in limitation_markers):
return answer_text
ulow = (user_text or "").lower()
photo_markers = ("фото", "зображ", "image", "photo", "картин", "світлин")
if any(m in ulow for m in photo_markers):
return (
"Можу аналізувати фото. Надішли, будь ласка, зображення ще раз одним повідомленням "
"з коротким питанням, і я дам точний розбір."
)
return (
"Можу працювати природною мовою та з мультимодальністю: фото, голос і документи. "
"Сформулюй запит коротко, і я відповім по суті."
)
def postprocess_agent_answer(
agent_id: str,
user_text: str,
answer_text: str,
force_detailed: bool,
needs_complex_reasoning: bool,
) -> str:
if not answer_text:
return answer_text
if (agent_id or "").lower() != "agromatrix":
return answer_text
# Keep detailed/complex answers intact.
if force_detailed or needs_complex_reasoning:
return answer_text
user_text_len = len((user_text or "").strip())
if user_text_len > 280:
return _agromatrix_rewrite_capability_limitations(user_text, answer_text)
cleaned = _strip_answer_markup_noise(answer_text)
cleaned = _agromatrix_rewrite_capability_limitations(user_text, cleaned)
compact = _compress_bulleted_answer(cleaned, max_items=1)
short = _limit_to_sentences(compact, max_sentences=3)
return short or answer_text
COMPLEX_REASONING_KEYWORDS = [
"стратег", "roadmap", "алгоритм", "architecture", "архітектур",
"прогноз", "scenario", "модель", "аналіз", "побудуй", "plan", "дослідж",
"симуляц", "forecast", "оптиміз", "розрахуй", "calculate", "predict"
]
def requires_complex_reasoning(text: str) -> bool:
if not text:
return False
stripped = text.strip()
if len(stripped) > 400:
return True
lower = stripped.lower()
return any(keyword in lower for keyword in COMPLEX_REASONING_KEYWORDS)
LAST_RESPONSE_CACHE: Dict[Tuple[str, str], Dict[str, Any]] = {}
LAST_RESPONSE_TTL = float(os.getenv("TELEGRAM_LAST_RESPONSE_TTL", "15"))
def get_cached_response(agent_id: str, chat_id: str, text: str) -> Optional[str]:
entry = LAST_RESPONSE_CACHE.get((agent_id, chat_id))
if not entry:
return None
if entry["text"] == text and time.time() - entry["ts"] < LAST_RESPONSE_TTL:
return entry["answer"]
return None
def store_response_cache(agent_id: str, chat_id: str, text: str, answer: str) -> None:
LAST_RESPONSE_CACHE[(agent_id, chat_id)] = {
"text": text,
"answer": answer,
"ts": time.time(),
}
def _resolve_stt_upload_url() -> str:
"""
Повертає фінальний endpoint для STT.
Swapper service використовує POST /stt з multipart file upload.
"""
upload_override = os.getenv("STT_SERVICE_UPLOAD_URL")
if upload_override:
return upload_override.rstrip("/")
base_url = os.getenv("STT_SERVICE_URL", "http://swapper-service:8890").rstrip("/")
# Swapper endpoint is /stt (not /api/stt/upload)
if base_url.endswith("/stt"):
return base_url
return f"{base_url}/stt"
# ========================================
# Helper Functions
# ========================================
async def get_telegram_file_path(file_id: str, bot_token: Optional[str] = None) -> Optional[str]:
"""
Отримати шлях до файлу з Telegram API.
Args:
file_id: ID файлу з Telegram
bot_token: Telegram bot token (якщо None, використовується TELEGRAM_BOT_TOKEN)
Returns:
Шлях до файлу або None
"""
try:
token = bot_token or os.getenv("TELEGRAM_BOT_TOKEN")
if not token:
logger.error("TELEGRAM_BOT_TOKEN not set")
return None
url = f"https://api.telegram.org/bot{token}/getFile"
params = {"file_id": file_id}
async with httpx.AsyncClient() as client:
response = await client.get(url, params=params, timeout=10.0)
response.raise_for_status()
data = response.json()
if data.get("ok"):
return data.get("result", {}).get("file_path")
return None
except Exception as e:
logger.error(f"Failed to get Telegram file path: {e}")
return None
def format_qa_response(qa_list: list) -> str:
"""Форматувати список питань-відповідей для Telegram"""
if not qa_list:
return "Немає питань-відповідей."
result = "📋 **Питання та відповіді:**\n\n"
for i, qa in enumerate(qa_list, 1):
question = qa.get("question", "") if isinstance(qa, dict) else getattr(qa, "question", "")
answer = qa.get("answer", "") if isinstance(qa, dict) else getattr(qa, "answer", "")
result += f"**{i}. {question}**\n{answer}\n\n"
return result.strip()
def format_markdown_response(markdown: str) -> str:
"""Форматувати markdown відповідь для Telegram"""
if len(markdown) > TELEGRAM_SAFE_LENGTH:
return markdown[:TELEGRAM_SAFE_LENGTH] + "\n\n_... (відповідь обрізано)_"
return markdown
def format_chunks_response(chunks: list) -> str:
"""Форматувати список чанків для Telegram"""
if not chunks:
return "Немає фрагментів."
result = f"📄 **Знайдено {len(chunks)} фрагментів:**\n\n"
for i, chunk in enumerate(chunks[:5], 1): # Показуємо тільки перші 5
text = chunk.get("text", "") if isinstance(chunk, dict) else str(chunk)
if len(text) > 200:
text = text[:200] + "..."
result += f"**{i}.** {text}\n\n"
if len(chunks) > 5:
result += f"_... та ще {len(chunks) - 5} фрагментів_"
return result.strip()
# ========================================
# Universal Message Processing Functions
# ========================================
async def process_photo(
agent_config: AgentConfig,
update: TelegramUpdate,
chat_id: str,
user_id: str,
username: str,
dao_id: str,
photo: Dict[str, Any],
caption_override: Optional[str] = None,
bypass_media_gate: bool = False,
) -> Dict[str, Any]:
"""
Універсальна функція для обробки фото для будь-якого агента.
Args:
agent_config: Конфігурація агента
update: Telegram update об'єкт
chat_id: ID чату
user_id: ID користувача
username: Ім'я користувача
dao_id: ID DAO
photo: Об'єкт фото з Telegram
Returns:
Dict з результатом обробки
"""
# Telegram sends multiple sizes, get the largest one (last in array)
photo_obj = photo[-1] if isinstance(photo, list) else photo
file_id = photo_obj.get("file_id") if isinstance(photo_obj, dict) else None
# file_unique_id стабільний між розмірами — використовуємо як lock key
file_unique_id: str | None = (photo_obj.get("file_unique_id") if isinstance(photo_obj, dict) else None) or None
if not file_id:
return {"ok": False, "error": "No file_id in photo"}
logger.info(
"%s: Photo from %s (tg:%s), file_id: %s file_unique_id: %s",
agent_config.name, username, user_id, file_id, file_unique_id or "n/a",
)
_set_recent_photo_context(agent_config.agent_id, chat_id, user_id, file_id)
if agent_config.agent_id == "agromatrix":
await _set_agromatrix_last_photo_ref(
chat_id=chat_id,
user_id=user_id,
file_id=file_id,
dao_id=dao_id,
)
# Get caption for media question check
caption = caption_override if caption_override is not None else ((update.message or {}).get("caption") or "")
chat = (update.message or {}).get("chat", {})
chat_type = chat.get("type", "private")
is_private_chat = chat_type == "private"
is_training = str(chat_id) in TRAINING_GROUP_IDS
# BEHAVIOR POLICY v1: Media-no-comment
# Check if photo has a question/request in caption
if not bypass_media_gate and not is_private_chat and not is_training:
has_question = detect_media_question(caption)
if not has_question:
logger.info(f"🔇 MEDIA-NO-COMMENT: Photo without question. Agent {agent_config.agent_id} NOT responding.")
# Save to memory for context, but don't respond
await memory_client.save_chat_turn(
agent_id=agent_config.agent_id,
team_id=dao_id,
user_id=f"tg:{user_id}",
message=f"[Photo: {file_id}] {caption}",
response="",
channel_id=chat_id,
scope="short_term",
save_agent_response=False,
agent_metadata={
"media_no_comment": True,
"file_id": file_id,
"caption": caption,
},
username=username,
)
return {"ok": True, "skipped": True, "reason": "media_no_question"}
# ── VISION CONSISTENCY GUARD: Rule 1 ─────────────────────────────────────
# Те саме фото (file_unique_id або file_id) вже аналізувалось →
# повертаємо збережений результат без запиту до Router.
# reeval_request → clear_lock → продовжуємо до Router.
_vg_caption_text = caption.strip() if caption else ""
if agent_config.agent_id == "agromatrix" and _vg_should_skip(
agent_config.agent_id, chat_id, file_id, _vg_caption_text,
file_unique_id=file_unique_id,
):
_vg_lock = _vg_get_lock(agent_config.agent_id, chat_id)
_vg_reply = _vg_build_locked_reply(_vg_lock, _vg_caption_text)
logger.info(
"vision_skip_reanalysis agent=%s chat_id=%s photo_key=%s label=%s",
agent_config.agent_id, chat_id,
file_unique_id or file_id, _vg_lock.get("label", "?"),
)
telegram_token = agent_config.get_telegram_token() or ""
if telegram_token:
await send_telegram_message(chat_id, _vg_reply, telegram_token)
return {"ok": True, "skipped": True, "reason": "vision_lock_same_photo"}
try:
# Get file path from Telegram
telegram_token = agent_config.get_telegram_token()
if not telegram_token:
return {"ok": False, "error": f"Telegram token not configured for {agent_config.name}"}
file_path = await get_telegram_file_path(file_id, telegram_token)
if not file_path:
raise HTTPException(status_code=400, detail="Failed to get file from Telegram")
# Build file URL
file_url = f"https://api.telegram.org/file/bot{telegram_token}/{file_path}"
# Download and encode the image as base64 data URL for Router
async with httpx.AsyncClient(timeout=60.0) as client:
photo_resp = await client.get(file_url)
photo_resp.raise_for_status()
image_bytes = photo_resp.content
content_type = photo_resp.headers.get("Content-Type", "")
if not content_type or not content_type.startswith("image/"):
content_type = "image/jpeg"
encoded_image = base64.b64encode(image_bytes).decode("utf-8")
data_url = f"data:{content_type};base64,{encoded_image}"
logger.info(
f"{agent_config.name}: Photo downloaded ({len(image_bytes)} bytes, content_type={content_type})"
)
# Send to Router with specialist_vision_8b model (Swapper)
# IMPORTANT: Default prompt must request BRIEF description (2-3 sentences max)
prompt = caption.strip() if caption else "Коротко (2-3 речення) скажи, що на цьому зображенні та яке його значення."
if agent_config.agent_id == "agromatrix":
prior_label = await _get_agromatrix_photo_prior(file_id=file_id, dao_id=dao_id)
if prior_label:
prompt = (
f"{prompt}\n\n"
f"[Контекст навчання AgroMatrix: для цього фото раніше підтверджено мітку: '{prior_label}'. "
"Використай як пріоритетну гіпотезу, але перевір ознаки і коротко поясни.]"
)
router_request = {
"message": f"{prompt}\n\n[Зображення передано окремо у context.images]",
"mode": "chat",
"agent": agent_config.agent_id,
"payload": {
"provider": "llm_specialist_vision_8b",
"task_type": "vision_photo_analysis",
},
"metadata": {
"source": "telegram",
"dao_id": dao_id,
"user_id": f"tg:{user_id}",
"session_id": f"tg:{chat_id}:{dao_id}",
"username": username,
"chat_id": chat_id,
"file_id": file_id,
"file_url": file_url,
"has_image": True,
"provider": "llm_specialist_vision_8b",
"use_llm": "specialist_vision_8b",
},
"context": {
"agent_name": agent_config.name,
"system_prompt": agent_config.system_prompt,
"images": [data_url],
},
}
# Send to Router
logger.info(f"{agent_config.name}: Sending photo to Router with vision-8b (provider override)")
response = await send_to_router(router_request)
# Extract response
if isinstance(response, dict) and response.get("ok"):
answer_text = response.get("data", {}).get("text") or response.get("response", "")
if answer_text:
# ── VISION CONSISTENCY GUARD: Hooks A+B ──────────────────────
# A: persist lock (label + confidence) keyed by file_unique_id
if agent_config.agent_id == "agromatrix":
try:
_vg_label, _vg_conf = _vg_extract_label(answer_text)
_vg_set_lock(
agent_config.agent_id, chat_id, file_id,
_vg_label, _vg_conf,
file_unique_id=file_unique_id,
)
logger.info(
"vision_lock_set agent=%s chat_id=%s photo_key=%s label=%s conf=%s",
agent_config.agent_id, chat_id,
file_unique_id or file_id, _vg_label, _vg_conf,
)
except Exception:
pass
# B: low-confidence → append clarifier if not already present
answer_text, _vg_low_added = _vg_build_low_conf(answer_text)
if _vg_low_added:
logger.info(
"vision_low_conf_clarifier_added agent=%s chat_id=%s",
agent_config.agent_id, chat_id,
)
# ─────────────────────────────────────────────────────────────
# Photo processed - send LLM response directly
await send_telegram_message(
chat_id,
answer_text, # No prefix, just the LLM response
telegram_token
)
# Save to memory for context
await memory_client.save_chat_turn(
agent_id=agent_config.agent_id,
team_id=dao_id,
user_id=f"tg:{user_id}",
message=f"[Photo: {file_id}]",
response=answer_text,
channel_id=chat_id,
scope="short_term",
save_agent_response=not is_service_response(answer_text),
agent_metadata={"context": "photo"},
username=username,
)
return {"ok": True, "agent": agent_config.agent_id, "model": "specialist_vision_8b"}
else:
await send_telegram_message(
chat_id,
"Не вдалося коректно обробити фото. Спробуйте інше фото або додайте короткий опис, що саме перевірити.",
telegram_token
)
return {"ok": True, "handled": True, "reason": "vision_empty_response"}
else:
error_msg = response.get("error", "Unknown error") if isinstance(response, dict) else "Router error"
logger.error(f"{agent_config.name}: Vision-8b error: {error_msg}")
await send_telegram_message(
chat_id,
"Вибач, сталася помилка при обробці фото.",
telegram_token
)
return {"ok": False, "error": error_msg}
except Exception as e:
logger.error(f"{agent_config.name}: Photo processing failed: {e}", exc_info=True)
telegram_token = agent_config.get_telegram_token()
await send_telegram_message(
chat_id,
"Вибач, сталася помилка при обробці фото.",
telegram_token
)
return {"ok": False, "error": "Photo processing failed"}
async def process_document(
agent_config: AgentConfig,
update: TelegramUpdate,
chat_id: str,
user_id: str,
username: str,
dao_id: str,
document: Dict[str, Any]
) -> Dict[str, Any]:
"""
Універсальна функція для обробки документів для будь-якого агента.
Args:
agent_config: Конфігурація агента
update: Telegram update об'єкт
chat_id: ID чату
user_id: ID користувача
username: Ім'я користувача
dao_id: ID DAO
document: Об'єкт документа з Telegram
Returns:
Dict з результатом обробки
"""
mime_type = document.get("mime_type", "")
mime_type_l = (mime_type or "").lower()
file_name = document.get("file_name", "")
file_id = document.get("file_id")
file_name_lower = file_name.lower()
allowed_exts = {
".pdf", ".doc", ".docx", ".rtf", ".odt",
".txt", ".md", ".markdown",
".csv", ".tsv", ".xls", ".xlsx", ".xlsm", ".ods",
".ppt", ".pptx", ".odp",
".json", ".yaml", ".yml", ".xml", ".html", ".htm",
".zip",
".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff",
}
is_allowed = any(file_name_lower.endswith(ext) for ext in allowed_exts)
if mime_type_l == "application/pdf":
is_allowed = True
if mime_type_l in {
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/rtf",
"text/rtf",
"application/vnd.oasis.opendocument.text",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel.sheet.macroenabled.12",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.presentation",
"text/plain",
"text/markdown",
"text/csv",
"text/tab-separated-values",
"application/json",
"application/yaml",
"application/x-yaml",
"text/yaml",
"application/xml",
"text/xml",
"text/html",
"application/zip",
"application/x-zip-compressed",
}:
is_allowed = True
if mime_type_l.startswith("image/"):
is_allowed = True
if is_allowed and file_id:
logger.info(f"{agent_config.name}: Document from {username} (tg:{user_id}), file_id: {file_id}, file_name: {file_name}")
try:
telegram_token = agent_config.get_telegram_token()
if not telegram_token:
return {"ok": False, "error": f"Telegram token not configured for {agent_config.name}"}
file_path = await get_telegram_file_path(file_id, telegram_token)
if not file_path:
raise HTTPException(status_code=400, detail="Failed to get file from Telegram")
file_url = f"https://api.telegram.org/file/bot{telegram_token}/{file_path}"
session_id = f"telegram:{chat_id}"
result = await parse_document(
session_id=session_id,
doc_url=file_url,
file_name=file_name,
dao_id=dao_id,
user_id=f"tg:{user_id}",
output_mode="qa_pairs",
metadata={"username": username, "chat_id": chat_id},
agent_id=agent_config.agent_id,
)
if not result.success:
await send_telegram_message(chat_id, f"Вибач, не вдалося обробити документ: {result.error}", telegram_token)
return {"ok": False, "error": result.error}
# Get document text for summary
doc_text = result.markdown or ""
if not doc_text and result.chunks_meta:
chunks = result.chunks_meta.get("chunks", [])
doc_text = "\n".join(chunks[:5]) if chunks else ""
# v3.2 Doc Bridge: зберігаємо parsed text щоб Stepan міг відповідати на питання
if doc_text and result.doc_id:
try:
from services.doc_service import save_doc_context as _save_doc_ctx
await _save_doc_ctx(
session_id=session_id,
doc_id=result.doc_id,
doc_url=file_url,
file_name=file_name,
dao_id=dao_id,
user_id=f"tg:{user_id}",
agent_id=agent_config.agent_id,
extracted_summary=doc_text[:4000],
)
logger.info(f"Doc Bridge: saved extracted_summary ({len(doc_text)} chars) for doc_id={result.doc_id}")
except Exception as _dbe:
logger.warning(f"Doc Bridge save_doc_context failed (non-blocking): {_dbe}")
# v3.3 Doc Handoff: зберігаємо chat-scoped ключ (пріоритет для Stepan)
try:
from services.doc_service import _sanitize_summary as _ss
_file_unique = document.get("file_unique_id") or result.doc_id
await save_chat_doc_context(
chat_id=chat_id,
agent_id=agent_config.agent_id,
doc_ctx={
"doc_id": result.doc_id,
"file_unique_id": _file_unique,
"file_name": file_name,
"extracted_summary": _ss(doc_text)[:4000],
"source": "telegram",
},
)
except Exception as _cdbe:
logger.warning("Doc Handoff: save_chat_doc_context failed: %s", _cdbe)
# Ask LLM to summarize the document (human-friendly)
if doc_text:
zip_hint = None
if file_name_lower.endswith(".zip"):
zip_hint = _zip_read_summary(doc_text)
summary_prompt = f"""Користувач надіслав документ "{file_name}".
Ось його зміст (перші частини):
{doc_text[:3000]}
Дай коротке резюме цього документа в 2-3 реченнях:
- Про що цей документ?
- Яка його основна мета/тема?
- Що може бути корисним?
Відповідай українською, дружньо, без технічних термінів."""
try:
summary_response = await send_to_router({
"message": summary_prompt,
"agent": agent_config.agent_id,
"context": {
"system_prompt": "Ти помічник який коротко пояснює зміст документів. Відповідай в 2-3 реченнях, дружньо і зрозуміло."
},
"metadata": {"source": "telegram", "task": "document_summary"}
})
if isinstance(summary_response, dict) and summary_response.get("ok"):
answer_text = summary_response.get("response", "") or summary_response.get("data", {}).get("text", "")
if answer_text:
answer_text = f"📄 **{file_name}**\n\n{answer_text}"
if zip_hint:
answer_text = f"{zip_hint}\n\n{answer_text}"
answer_text += "\n\nо саме тебе цікавить у цьому документі?_"
else:
answer_text = f"📄 Отримав документ **{file_name}**. Що саме хочеш дізнатися з нього?"
else:
answer_text = f"📄 Отримав документ **{file_name}**. Про що саме хочеш запитати?"
except Exception as e:
logger.warning(f"Failed to get document summary: {e}")
answer_text = f"📄 Отримав документ **{file_name}**. Що тебе цікавить?"
else:
answer_text = f"📄 Отримав документ **{file_name}**, але не вдалося прочитати текст. Можливо, це скановане зображення?"
logger.info(f"{agent_config.name}: Document processed: {file_name}, doc_id={result.doc_id}")
# === SAVE TO CHAT HISTORY (CRITICAL: so agent remembers the document) ===
user_msg = f"[Документ: {file_name}] Надіслано документ"
if update.message.get("caption"):
user_msg = f"[Документ: {file_name}] {update.message.get('caption')}"
await memory_client.save_chat_turn(
agent_id=agent_config.agent_id,
team_id=dao_id,
user_id=f"tg:{user_id}",
message=user_msg,
response=answer_text,
channel_id=chat_id,
scope="short_term",
save_agent_response=True,
agent_metadata={"context": "document", "file_name": file_name, "doc_id": result.doc_id},
username=username,
)
logger.info(f"{agent_config.name}: Document chat turn saved to memory: {file_name}")
# === END SAVE TO CHAT HISTORY ===
# === AUTO-INGEST: Store document in agent Qdrant _docs collection ===
if doc_text:
try:
import httpx as _httpx
router_url = os.getenv("ROUTER_URL", "http://router:8000")
async with _httpx.AsyncClient(timeout=60.0) as _client:
ingest_resp = await _client.post(
f"{router_url}/v1/documents/ingest",
json={
"agent_id": agent_config.agent_id,
"doc_id": result.doc_id,
"file_name": file_name,
"text": doc_text,
"dao_id": dao_id,
"user_id": f"tg:{user_id}"
}
)
ingest_data = ingest_resp.json()
if ingest_data.get("ok"):
logger.info(f"{agent_config.name}: Document ingested to Qdrant: {ingest_data.get('chunks_stored', 0)} chunks")
else:
logger.warning(f"{agent_config.name}: Document ingest failed: {ingest_data.get('error')}")
except Exception as ingest_err:
logger.warning(f"{agent_config.name}: Document auto-ingest error: {ingest_err}")
# === END AUTO-INGEST ===
await send_telegram_message(chat_id, answer_text, telegram_token)
return {"ok": True, "agent": "parser", "mode": "doc_parse", "doc_id": result.doc_id}
except Exception as e:
logger.error(f"{agent_config.name}: Document processing failed: {e}", exc_info=True)
telegram_token = agent_config.get_telegram_token()
await send_telegram_message(chat_id, "Вибач, не вдалося обробити документ. Переконайся, що файл не пошкоджений.", telegram_token)
return {"ok": False, "error": "Document processing failed"}
elif document and not is_allowed:
telegram_token = agent_config.get_telegram_token()
await send_telegram_message(
chat_id,
"Підтримуються формати: PDF/DOC/DOCX/RTF/ODT, TXT/MD/CSV/TSV, XLS/XLSX/XLSM/ODS, PPT/PPTX/ODP, JSON/YAML/XML/HTML, ZIP, зображення.",
telegram_token,
)
return {"ok": False, "error": "Unsupported document type"}
return {"ok": False, "error": "No document to process"}
async def process_voice(
agent_config: AgentConfig,
update: TelegramUpdate,
chat_id: str,
user_id: str,
username: str,
dao_id: str,
media_obj: Dict[str, Any]
) -> Dict[str, Any]:
"""
Універсальна функція для обробки голосових повідомлень для будь-якого агента.
Використовує STT Service для розпізнавання мовлення.
Args:
agent_config: Конфігурація агента
update: Telegram update об'єкт
chat_id: ID чату
user_id: ID користувача
username: Ім'я користувача
dao_id: ID DAO
media_obj: Об'єкт голосового повідомлення з Telegram
Returns:
Dict з результатом обробки та розпізнаним текстом
"""
file_id = media_obj.get("file_id") if media_obj else None
if not file_id:
return {"ok": False, "error": "No file_id in voice/audio/video_note"}
logger.info(f"{agent_config.name}: Voice message from {username} (tg:{user_id}), file_id: {file_id}")
try:
telegram_token = agent_config.get_telegram_token()
if not telegram_token:
return {"ok": False, "error": f"Telegram token not configured for {agent_config.name}"}
# Отримуємо файл з Telegram
file_path = await get_telegram_file_path(file_id, telegram_token)
if not file_path:
raise HTTPException(status_code=400, detail="Failed to get file from Telegram")
# Завантажуємо файл
file_url = f"https://api.telegram.org/file/bot{telegram_token}/{file_path}"
async with httpx.AsyncClient(timeout=30.0) as client:
file_resp = await client.get(file_url)
file_resp.raise_for_status()
audio_bytes = file_resp.content
# Відправляємо в STT-сервіс
stt_upload_url = _resolve_stt_upload_url()
mime_type = media_obj.get("mime_type") if isinstance(media_obj, dict) else None
files = {
"file": (
"voice.ogg",
audio_bytes,
mime_type or "audio/ogg",
)
}
# Swapper /stt expects: file (multipart), model (form), language (form)
form_data = {
"model": "whisper-small",
"task": "transcribe",
}
logger.info(f"{agent_config.name}: Sending voice to STT endpoint {stt_upload_url}")
async with httpx.AsyncClient(timeout=90.0) as client:
stt_resp = await client.post(stt_upload_url, files=files, data=form_data)
stt_resp.raise_for_status()
stt_data = stt_resp.json()
text = stt_data.get("text", "")
if not text:
await send_telegram_message(
chat_id,
"Вибач, не вдалося розпізнати голосове повідомлення. Спробуй надіслати текстом.",
telegram_token
)
return {"ok": False, "error": "STT returned empty text"}
logger.info(f"{agent_config.name}: STT result: {text[:100]}...")
# Повертаємо розпізнаний текст для подальшої обробки
return {"ok": True, "text": text, "agent": agent_config.agent_id, "mode": "voice_stt"}
except Exception as e:
logger.error(f"{agent_config.name}: Voice processing failed: {e}", exc_info=True)
telegram_token = agent_config.get_telegram_token()
await send_telegram_message(
chat_id,
"Вибач, не вдалося розпізнати голосове повідомлення. Спробуй надіслати текстом.",
telegram_token
)
return {"ok": False, "error": "Voice processing failed"}
# ========================================
# Universal Telegram Webhook Handler
# ========================================
# === UPDATE DEDUPLICATION ===
import time as _time
_PROCESSED_UPDATES: Dict[int, float] = {} # update_id -> timestamp
_DEDUP_MAX_SIZE = 2000
_DEDUP_TTL = 300 # 5 minutes
def _dedup_cleanup():
"""Remove old entries from dedup cache."""
now = _time.time()
expired = [uid for uid, ts in _PROCESSED_UPDATES.items() if now - ts > _DEDUP_TTL]
for uid in expired:
del _PROCESSED_UPDATES[uid]
# === END DEDUPLICATION ===
async def handle_telegram_webhook(
agent_config: AgentConfig,
update: TelegramUpdate
) -> Dict[str, Any]:
"""
Універсальна функція для обробки Telegram webhook для будь-якого агента.
Args:
agent_config: Конфігурація агента
update: Telegram update об'єкт
Returns:
Dict з результатом обробки
"""
# Allow updates without message if they contain photo/voice
# The actual message validation happens after multimodal checks
# === DEDUP CHECK ===
if update.update_id:
if update.update_id in _PROCESSED_UPDATES:
logger.info(f"🔄 Skipping duplicate update_id={update.update_id} for {agent_config.name}")
return {"status": "ok", "skipped": "duplicate_update"}
_PROCESSED_UPDATES[update.update_id] = _time.time()
if len(_PROCESSED_UPDATES) > _DEDUP_MAX_SIZE:
_dedup_cleanup()
# === END DEDUP CHECK ===
if not update.message:
if update.channel_post:
update.message = update.channel_post
else:
return {"status": "ok", "skipped": "no_message"}
# Extract message details
from_user = update.message.get("from", {})
if not from_user:
from_user = update.message.get("sender_chat", {})
chat = update.message.get("chat", {})
user_id = str(from_user.get("id", "unknown"))
chat_id = str(chat.get("id", "unknown"))
username = from_user.get("username", "")
first_name = from_user.get("first_name")
last_name = from_user.get("last_name")
is_sender_bot = bool(from_user.get("is_bot") or (username and username.lower().endswith("bot")))
# Get DAO ID for this chat
dao_id = get_dao_id(chat_id, "telegram", agent_id=agent_config.agent_id)
initial_preferred_lang = resolve_preferred_language(
chat_id=chat_id,
user_id=user_id,
text=update.message.get("text", ""),
telegram_lang_code=from_user.get("language_code"),
)
# Оновлюємо факти про користувача/агента для побудови графу пам'яті
asyncio.create_task(
memory_client.upsert_fact(
user_id=f"tg:{user_id}",
fact_key="profile",
fact_value_json={
"username": username,
"first_name": first_name,
"last_name": last_name,
"language_code": from_user.get("language_code"),
"preferred_language": initial_preferred_lang,
"is_bot": is_sender_bot,
},
team_id=dao_id,
)
)
telegram_token = agent_config.get_telegram_token()
if not telegram_token:
raise HTTPException(status_code=500, detail=f"Telegram token not configured for {agent_config.name}")
# === REPLY-TO-AGENT DETECTION ===
# If user replies to a bot message → treat as direct mention (SOWA Priority 3)
is_reply_to_agent = False
reply_to_message = update.message.get("reply_to_message")
if reply_to_message:
reply_from = reply_to_message.get("from", {})
if reply_from.get("is_bot"):
# Verify it's THIS agent's bot (bot_id = first part of token)
bot_id = telegram_token.split(":")[0] if telegram_token else None
reply_from_id = str(reply_from.get("id", ""))
if bot_id and reply_from_id == bot_id:
is_reply_to_agent = True
logger.info(
f"↩️ {agent_config.name}: Reply-to-agent detected "
f"(user {username} replied to bot msg {reply_to_message.get('message_id', '?')})"
)
# === BRAND COMMANDS (experimental, disabled by default) ===
# ~290 lines of brand/presentation command handlers.
# This code was NEVER active in production (was trapped inside wrong indent block).
# Quarantined behind feature flag. Enable with: ENABLE_BRAND_COMMANDS=true
# See: /бренд, /бренд_інтейк, /бренд_тема, /презентація, /job_статус
_brand_commands_enabled = os.environ.get("ENABLE_BRAND_COMMANDS", "").lower() in ("1", "true", "yes")
if _brand_commands_enabled:
text = update.message.get("text", "")
# Simple brand commands (Ukrainian)
if text and text.strip().startswith("/бренд"):
parts = text.strip().split(maxsplit=2)
command = parts[0].lower()
if command == "/бренд":
await send_telegram_message(
chat_id,
"🧩 **Команди бренду**\n\n"
"• `/бренд_інтейк <url|текст>` — зберегти джерело\n"
"• `/бренд_тема <brand_id> [версія]` — опублікувати базову тему\n"
"• `/бренд_останнє <brand_id>` — показати останню тему\n"
"• `/бренд_показати <brand_id> <версія>` — показати конкретну тему\n"
"• `/презентація <brand_id> <версія> <JSON SlideSpec>` — рендер презентації\n"
"• `/презентація_статус <job_id>` — статус рендера\n"
"• `/презентація_файл <artifact_id> [pptx|pdf]` — файл\n"
"• `/job_статус <job_id>` — універсальний статус",
telegram_token
)
return {"ok": True, "action": "brand_help"}
if command == "/бренд_інтейк":
if len(parts) < 2:
await send_telegram_message(
chat_id,
"❗ Вкажи URL або текст: `/бренд_інтейк <url|текст>`",
telegram_token
)
return {"ok": True, "action": "brand_intake_help"}
source_value = parts[1] if len(parts) == 2 else f"{parts[1]} {parts[2]}"
source_type = "url" if source_value.startswith("http") else "text"
intake_payload = {
"source_type": source_type,
"text": source_value if source_type == "text" else None,
"url": source_value if source_type == "url" else None,
"agent_id": agent_config.agent_id,
"workspace_id": dao_id,
"project_id": dao_id,
"tags": ["telegram"]
}
result = await _brand_intake_request(intake_payload)
attribution = result.get("attribution", {})
await send_telegram_message(
chat_id,
"✅ **Джерело збережено**\n\n"
f"ID: `{result.get('id')}`\n"
f"Статус: `{attribution.get('status')}`\n"
f"Бренд: `{attribution.get('brand_id')}`\n"
f"Впевненість: `{attribution.get('confidence')}`",
telegram_token
)
return {"ok": True, "action": "brand_intake"}
if command == "/бренд_тема":
if len(parts) < 2:
await send_telegram_message(
chat_id,
"❗ Вкажи brand_id: `/бренд_тема <brand_id> [версія]`",
telegram_token
)
return {"ok": True, "action": "brand_theme_help"}
brand_id = parts[1]
theme_version = None
if len(parts) == 3:
theme_version = parts[2]
theme = _default_theme_payload(brand_id)
published = await _brand_publish_theme(brand_id, theme, theme_version)
await send_telegram_message(
chat_id,
"✅ **Тему опубліковано**\n\n"
f"Бренд: `{published.get('brand_id')}`\n"
f"Версія: `{published.get('theme_version')}`",
telegram_token
)
return {"ok": True, "action": "brand_publish"}
if command == "/бренд_останнє":
if len(parts) < 2:
await send_telegram_message(
chat_id,
"❗ Вкажи brand_id: `/бренд_останнє <brand_id>`",
telegram_token
)
return {"ok": True, "action": "brand_latest_help"}
brand_id = parts[1]
data = await _brand_get_latest(brand_id)
await send_telegram_message(
chat_id,
"📌 **Остання тема**\n\n"
f"Бренд: `{data.get('brand_id')}`\n"
f"Версія: `{data.get('theme_version')}`",
telegram_token
)
return {"ok": True, "action": "brand_latest"}
if command == "/бренд_показати":
if len(parts) < 3:
await send_telegram_message(
chat_id,
"❗ Формат: `/бренд_показати <brand_id> <версія>`",
telegram_token
)
return {"ok": True, "action": "brand_show_help"}
brand_id = parts[1]
theme_version = parts[2]
data = await _brand_get_theme(brand_id, theme_version)
await send_telegram_message(
chat_id,
"📎 **Тема**\n\n"
f"Бренд: `{data.get('brand_id')}`\n"
f"Версія: `{data.get('theme_version')}`",
telegram_token
)
return {"ok": True, "action": "brand_show"}
# Brand hint on keyword mention (non-command)
if text and "бренд" in text.lower():
await send_telegram_message(
chat_id,
"🧩 **Команди бренду**\n\n"
"• `/бренд_інтейк <url|текст>` — зберегти джерело\n"
"• `/бренд_тема <brand_id> [версія]` — опублікувати базову тему\n"
"• `/бренд_останнє <brand_id>` — показати останню тему\n"
"• `/бренд_показати <brand_id> <версія>` — показати конкретну тему\n"
"• `/презентація <brand_id> <версія> <JSON SlideSpec>` — рендер презентації\n"
"• `/презентація_статус <job_id>` — статус рендера\n"
"• `/презентація_файл <artifact_id> [pptx|pdf]` — файл\n"
"• `/job_статус <job_id>` — універсальний статус",
telegram_token
)
return {"ok": True, "action": "brand_hint"}
# Job status command (universal)
if text and text.strip().startswith("/job_статус"):
parts = text.strip().split(maxsplit=1)
if len(parts) < 2:
await send_telegram_message(
chat_id,
"❗ Формат: `/job_статус <job_id>`",
telegram_token
)
return {"ok": True, "action": "job_status_help"}
job_id = parts[1].strip()
job = await _artifact_job_status(job_id)
message = _format_job_status_message(job, job_id)
await send_telegram_message(chat_id, message, telegram_token)
return {"ok": True, "action": "job_status"}
# Presentation status command (alias)
if text and text.strip().startswith("/презентація_статус"):
parts = text.strip().split(maxsplit=1)
if len(parts) < 2:
await send_telegram_message(
chat_id,
"❗ Формат: `/презентація_статус <job_id>` або `/job_статус <job_id>`",
telegram_token
)
return {"ok": True, "action": "presentation_status_help"}
job_id = parts[1].strip()
job = await _artifact_job_status(job_id)
message = _format_job_status_message(job, job_id)
await send_telegram_message(chat_id, message, telegram_token)
return {"ok": True, "action": "presentation_status"}
# Presentation file command
if text and text.strip().startswith("/презентація_файл"):
parts = text.strip().split(maxsplit=2)
if len(parts) < 2:
await send_telegram_message(
chat_id,
"❗ Формат: `/презентація_файл <artifact_id> [pptx|pdf]`",
telegram_token
)
return {"ok": True, "action": "presentation_file_help"}
artifact_id = parts[1].strip()
fmt = parts[2].strip().lower() if len(parts) > 2 else "pptx"
artifact = await _artifact_get(artifact_id)
acl_ref = artifact.get("acl_ref")
if not _can_access_artifact(acl_ref, dao_id, f"tg:{user_id}"):
await send_telegram_message(chat_id, "⛔ Немає доступу до цього файлу.", telegram_token)
return {"ok": True, "action": "presentation_file_denied"}
try:
download = await _artifact_download(artifact_id, fmt)
logger.info(
"artifact.downloaded artifact_id=%s user=%s format=%s",
artifact_id,
user_id,
fmt,
)
await send_telegram_message(
chat_id,
f"📎 **Файл готовий** ({fmt})\n{download.get('url')}",
telegram_token
)
return {"ok": True, "action": "presentation_file"}
except HTTPException as e:
if fmt == "pdf" and e.status_code == 404:
versions = await _artifact_versions(artifact_id)
pptx_version_id = None
for item in versions.get("items", []):
if item.get("mime") == "application/vnd.openxmlformats-officedocument.presentationml.presentation":
pptx_version_id = item.get("id")
break
if not pptx_version_id:
await send_telegram_message(
chat_id,
"❗ PPTX ще не готовий, PDF теж недоступний.",
telegram_token
)
return {"ok": True, "action": "presentation_pdf_missing"}
job = await _artifact_create_job(artifact_id, "render_pdf", pptx_version_id)
await send_telegram_message(
chat_id,
"⏳ PDF в черзі на рендер.\n"
f"Job ID: `{job.get('job_id')}`\n"
"Спробуй `/презентація_статус <job_id>` трохи пізніше.",
telegram_token
)
return {"ok": True, "action": "presentation_pdf_queued"}
raise
# Presentation render command (JSON SlideSpec)
if text and text.strip().startswith("/презентація"):
parts = text.strip().split(maxsplit=3)
if len(parts) < 4:
await send_telegram_message(
chat_id,
"❗ Формат:\n"
"`/презентація <brand_id> <версія> <JSON SlideSpec>`\n"
"або простий формат:\n"
"`/презентація <brand_id> <версія> Назва;Слайд 1;Слайд 2;Слайд 3`\n\n"
"Приклад:\n"
"`/презентація energyunion v1.0.0 {\"meta\":{\"title\":\"Pitch\",\"brand_id\":\"energyunion\",\"theme_version\":\"v1.0.0\",\"language\":\"uk\"},\"slides\":[{\"type\":\"title\",\"title\":\"Energy Union\"}]}`",
telegram_token
)
return {"ok": True, "action": "presentation_help"}
brand_id = parts[1]
theme_version = parts[2]
slidespec_raw = parts[3]
slidespec = None
if slidespec_raw.strip().startswith("{"):
try:
slidespec = json.loads(slidespec_raw)
except json.JSONDecodeError:
await send_telegram_message(
chat_id,
"Не вдалося прочитати JSON SlideSpec. Перевір формат.",
telegram_token
)
return {"ok": True, "action": "presentation_bad_json"}
else:
parts_simple = [p.strip() for p in slidespec_raw.split(";") if p.strip()]
if not parts_simple:
await send_telegram_message(
chat_id,
"❗ Порожній список слайдів. Додай хоча б назву.",
telegram_token
)
return {"ok": True, "action": "presentation_empty"}
title = parts_simple[0]
slides = [{"type": "title", "title": title}]
for item in parts_simple[1:]:
slides.append({
"type": "bullets",
"title": item,
"blocks": [{"kind": "bullets", "items": [item]}],
})
slidespec = {
"meta": {
"title": title,
"brand_id": brand_id,
"theme_version": theme_version,
"language": "uk",
},
"slides": slides,
}
render_result = await _presentation_render(slidespec, brand_id, theme_version)
await send_telegram_message(
chat_id,
"✅ **Запит на рендер прийнято**\n\n"
f"Artifact ID: `{render_result.get('artifact_id')}`\n"
f"Job ID: `{render_result.get('job_id')}`\n"
f"Status URL: `{render_result.get('status_url')}`",
telegram_token
)
return {"ok": True, "action": "presentation_render"}
# Check for /ingest command
text = update.message.get("text", "")
if text and text.strip().startswith("/ingest"):
session_id = f"telegram:{chat_id}"
# Check if there's a document in the message
document = update.message.get("document")
if document:
mime_type = document.get("mime_type", "")
file_name = document.get("file_name", "")
file_id = document.get("file_id")
if file_id:
try:
file_path = await get_telegram_file_path(file_id, telegram_token)
if file_path:
file_url = f"https://api.telegram.org/file/bot{telegram_token}/{file_path}"
artifact = None
job = None
try:
artifact = await _artifact_create({
"type": "doc",
"title": file_name,
"brand_id": dao_id,
"project_id": dao_id,
"acl_ref": f"brand:{dao_id}:public" if dao_id else "public",
"created_by": f"tg:{user_id}",
})
version = await _artifact_add_version_from_url(
artifact["artifact_id"],
{
"url": file_url,
"mime": mime_type or "application/octet-stream",
"label": "source",
"meta_json": {
"file_name": file_name,
"dao_id": dao_id,
"user_id": f"tg:{user_id}",
},
},
)
job = await _artifact_create_job(
artifact["artifact_id"],
"index_doc",
version["version_id"],
)
except Exception as e:
logger.warning(f"Artifact doc registry failed: {e}")
await send_telegram_message(
chat_id,
f"✅ **Документ прийнято**\n\n"
f"📁 DAO: {dao_id}\n"
f"🧾 Artifact ID: `{artifact.get('artifact_id') if artifact else 'n/a'}`\n"
f"🧩 Job ID: `{job.get('job_id') if job else 'n/a'}`\n\n"
f"Індексація виконується асинхронно. "
f"Перевір статус: `/job_статус <job_id>`",
telegram_token
)
return {"ok": True, "artifact_id": artifact.get("artifact_id") if artifact else None, "job_id": job.get("job_id") if job else None}
except Exception as e:
logger.error(f"{agent_config.name}: Ingest failed: {e}", exc_info=True)
await send_telegram_message(chat_id, "Вибач, не вдалося імпортувати документ.", telegram_token)
return {"ok": False, "error": "Ingest failed"}
await send_telegram_message(chat_id, "Спочатку надішли документ, а потім використай /ingest", telegram_token)
return {"ok": False, "error": "No document for ingest"}
# Check for /link command - Account Linking (Telegram ↔ Energy Union)
if text and text.strip().startswith("/link"):
parts = text.strip().split(maxsplit=1)
if len(parts) < 2:
await send_telegram_message(
chat_id,
"🔗 **Зв'язування акаунта**\n\n"
"Щоб зв'язати Telegram з акаунтом Energy Union:\n"
"1. Отримай код у кабінеті Energy Union\n"
"2. Надішли: `/link <код>`\n\n"
"Приклад: `/link ABC123XYZ`",
telegram_token
)
return {"ok": True, "action": "link_help"}
link_code = parts[1].strip()
# Call PostgreSQL function to complete linking
try:
import asyncpg
pg_conn = await asyncpg.connect(
host=os.getenv("POSTGRES_HOST", "dagi-postgres"),
port=int(os.getenv("POSTGRES_PORT", "5432")),
user=os.getenv("POSTGRES_USER", "daarion"),
password=os.getenv("POSTGRES_PASSWORD", "DaarionDB2026!"),
database=os.getenv("POSTGRES_DB", "daarion_main")
)
result = await pg_conn.fetchrow(
"SELECT * FROM complete_account_link($1, $2, $3, $4, $5)",
link_code,
int(user_id),
username,
first_name,
last_name
)
await pg_conn.close()
if result and result['success']:
await send_telegram_message(
chat_id,
"✅ **Акаунт успішно зв'язано!**\n\n"
"Тепер Helion бачить твою історію взаємодій "
"з платформою Energy Union.\n\n"
"Твої розмови в різних чатах тепер пов'язані "
"з твоїм єдиним акаунтом.",
telegram_token
)
logger.info(f"Account linked: telegram_user_id={user_id}, account_id={result['account_id']}")
return {"ok": True, "action": "account_linked", "account_id": str(result['account_id'])}
else:
error_msg = result['error_message'] if result else "Невідома помилка"
error_text = {
"Invalid or expired code": "Код недійсний або прострочений",
"Telegram account already linked": "Telegram вже зв'язано з іншим акаунтом",
"Code not found": "Код не знайдено"
}.get(error_msg, error_msg)
await send_telegram_message(
chat_id,
f"❌ **Не вдалося зв'язати акаунт**\n\n"
f"Причина: {error_text}\n\n"
"Спробуй отримати новий код у кабінеті Energy Union.",
telegram_token
)
return {"ok": False, "error": error_msg}
except Exception as e:
logger.error(f"Account linking failed: {e}", exc_info=True)
await send_telegram_message(
chat_id,
"❌ Помилка зв'язування акаунта. Спробуй пізніше.",
telegram_token
)
return {"ok": False, "error": str(e)}
# Check for /unlink command
if text and text.strip().startswith("/unlink"):
try:
import asyncpg
pg_conn = await asyncpg.connect(
host=os.getenv("POSTGRES_HOST", "dagi-postgres"),
port=int(os.getenv("POSTGRES_PORT", "5432")),
user=os.getenv("POSTGRES_USER", "daarion"),
password=os.getenv("POSTGRES_PASSWORD", "DaarionDB2026!"),
database=os.getenv("POSTGRES_DB", "daarion_main")
)
result = await pg_conn.execute(
"""
UPDATE account_links
SET status = 'revoked',
revoked_at = NOW(),
revoked_reason = 'User requested via /unlink'
WHERE telegram_user_id = $1 AND status = 'active'
""",
int(user_id)
)
await pg_conn.close()
await send_telegram_message(
chat_id,
"✅ **Зв'язок з акаунтом видалено**\n\n"
"Helion більше не бачить твою історію.\n"
"Ти можеш повторно зв'язати акаунт командою `/link`.",
telegram_token
)
return {"ok": True, "action": "account_unlinked"}
except Exception as e:
logger.error(f"Account unlinking failed: {e}", exc_info=True)
await send_telegram_message(
chat_id,
"❌ Помилка видалення зв'язку. Спробуй пізніше.",
telegram_token
)
return {"ok": False, "error": str(e)}
# Check for /status command - Show linking status
if text and text.strip().startswith("/status"):
try:
import asyncpg
pg_conn = await asyncpg.connect(
host=os.getenv("POSTGRES_HOST", "dagi-postgres"),
port=int(os.getenv("POSTGRES_PORT", "5432")),
user=os.getenv("POSTGRES_USER", "daarion"),
password=os.getenv("POSTGRES_PASSWORD", "DaarionDB2026!"),
database=os.getenv("POSTGRES_DB", "daarion_main")
)
link = await pg_conn.fetchrow(
"""
SELECT account_id, linked_at, status
FROM account_links
WHERE telegram_user_id = $1 AND status = 'active'
""",
int(user_id)
)
await pg_conn.close()
if link:
linked_date = link['linked_at'].strftime("%d.%m.%Y %H:%M")
await send_telegram_message(
chat_id,
f"✅ **Акаунт зв'язано**\n\n"
f"📅 Дата: {linked_date}\n"
f"🔗 Статус: активний\n\n"
f"Helion бачить твою історію взаємодій.",
telegram_token
)
else:
await send_telegram_message(
chat_id,
"❌ **Акаунт не зв'язано**\n\n"
"Використай `/link <код>` щоб зв'язати.\n"
"Код можна отримати в кабінеті Energy Union.",
telegram_token
)
return {"ok": True, "action": "status_checked", "linked": bool(link)}
except Exception as e:
logger.error(f"Status check failed: {e}", exc_info=True)
return {"ok": False, "error": str(e)}
# Check if it's a document
document = update.message.get("document")
if document:
result = await process_document(
agent_config, update, chat_id, user_id, username, dao_id, document
)
if result.get("ok"):
return result
# Check if it's a photo
photo = update.message.get("photo")
if photo:
result = await process_photo(
agent_config, update, chat_id, user_id, username, dao_id, photo
)
return result
# Check if it's a voice message
voice = update.message.get("voice")
audio = update.message.get("audio")
video_note = update.message.get("video_note")
text = ""
if voice or audio or video_note:
media_obj = voice or audio or video_note
result = await process_voice(
agent_config, update, chat_id, user_id, username, dao_id, media_obj
)
if result.get("ok") and result.get("text"):
# Отримали розпізнаний текст, продовжуємо обробку як текстове повідомлення
text = result.get("text")
elif result.get("ok"):
# STT успішний, але текст порожній
return result
else:
# Помилка STT
return result
# Get message text (якщо не було голосового повідомлення)
if not text:
text = update.message.get("text", "")
caption = update.message.get("caption", "")
# Friendly greeting fast-path for better UX and less mechanical replies.
if _is_simple_greeting(text):
greeting_reply = (
f"Привіт, {username or 'друже'}! Я {agent_config.name}. "
"Можу допомогти з фото рослин, Excel-звітами та короткими практичними порадами."
)
await send_telegram_message(chat_id, greeting_reply, 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=greeting_reply,
channel_id=chat_id,
scope="short_term",
save_agent_response=True,
agent_metadata={"greeting_fast_path": True},
username=username,
)
return {"ok": True, "agent": agent_config.agent_id, "mode": "greeting_fast_path"}
# AgroMatrix: capture user correction for latest photo and persist anonymized learning.
if agent_config.agent_id == "agromatrix" and text:
corrected_label = _extract_agromatrix_correction_label(text)
negative_feedback = _is_agromatrix_negative_feedback(text)
recent_file_id = _get_recent_photo_file_id(agent_config.agent_id, chat_id, user_id)
if not recent_file_id:
try:
mc = await memory_client.get_context(
user_id=f"tg:{user_id}",
agent_id=agent_config.agent_id,
team_id=dao_id,
channel_id=chat_id,
limit=80,
)
recent_file_id = _extract_recent_photo_file_id_from_memory(mc)
except Exception:
recent_file_id = None
if not recent_file_id:
recent_file_id = await _get_agromatrix_last_photo_ref(
chat_id=chat_id,
user_id=user_id,
dao_id=dao_id,
)
if corrected_label and recent_file_id:
await _save_agromatrix_photo_learning(
file_id=recent_file_id,
label=corrected_label,
source="user_correction",
chat_id=chat_id,
user_id=user_id,
dao_id=dao_id,
)
logger.info(
f"AgroMatrix learning updated: file_id={recent_file_id}, label={corrected_label}"
)
if _is_agromatrix_correction_only_message(text):
ack = (
f"Дякую, зафіксував виправлення: {corrected_label}. "
"Для цього фото надалі використовуватиму саме цю мітку."
)
await send_telegram_message(chat_id, ack, 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=ack,
channel_id=chat_id,
scope="short_term",
save_agent_response=True,
agent_metadata={"agromatrix_learning_updated": True},
username=username,
)
return {"ok": True, "agent": agent_config.agent_id, "mode": "learning_updated"}
elif corrected_label and _is_agromatrix_correction_only_message(text):
ack = (
f"Прийняв виправлення: {corrected_label}. "
"Але не бачу останнє фото в контексті, надішли фото ще раз і я зафіксую корекцію саме до нього."
)
await send_telegram_message(chat_id, ack, 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=ack,
channel_id=chat_id,
scope="short_term",
save_agent_response=True,
agent_metadata={"agromatrix_learning_no_photo_context": True},
username=username,
)
return {"ok": True, "agent": agent_config.agent_id, "mode": "learning_no_photo_context"}
# If user says answer is wrong but does not provide a replacement label,
# invalidate stale prior so it won't keep forcing repeated wrong guesses.
if negative_feedback and not corrected_label and recent_file_id:
await _invalidate_agromatrix_photo_learning(
file_id=recent_file_id,
reason="user_marked_previous_answer_wrong_without_replacement",
dao_id=dao_id,
)
logger.info(
f"AgroMatrix learning invalidated: file_id={recent_file_id}, reason=negative_feedback_no_label"
)
if _is_agromatrix_correction_only_message(text):
ack = (
"Прийняв. Попередню мітку для цього фото скасовано. "
"Напиши правильну назву культури або попроси: 'перевір фото ще раз'."
)
await send_telegram_message(chat_id, ack, 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=ack,
channel_id=chat_id,
scope="short_term",
save_agent_response=True,
agent_metadata={"agromatrix_learning_invalidated": True},
username=username,
)
return {"ok": True, "agent": agent_config.agent_id, "mode": "learning_invalidated"}
elif negative_feedback and not corrected_label and _is_agromatrix_correction_only_message(text):
ack = (
"Прийняв, що попередня відповідь була хибна. "
"Щоб закріпити правильну мітку, напиши у форматі: 'правильна відповідь: <назва культури>'."
)
await send_telegram_message(chat_id, ack, 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=ack,
channel_id=chat_id,
scope="short_term",
save_agent_response=True,
agent_metadata={"agromatrix_negative_feedback_no_photo_context": True},
username=username,
)
return {"ok": True, "agent": agent_config.agent_id, "mode": "negative_feedback_ack"}
# Photo/image intent guard:
# if text references a photo/image, try to resolve latest file_id and route to vision.
photo_intent = False
if text:
tl = text.lower()
photo_intent = _looks_like_photo_followup(text)
if not photo_intent:
# Robust fallback for common formulations like "що на цьому фото?"
photo_intent = bool(
re.search(r"(що|what|что).{0,24}(цьому|этом|this).{0,24}(фото|зображ|світлин|image|photo)", tl)
)
if photo_intent:
recent_file_id = _get_recent_photo_file_id(agent_config.agent_id, chat_id, user_id)
# Fallback: recover latest photo file_id from memory-service context (survives process restarts).
if not recent_file_id:
try:
mc = await memory_client.get_context(
user_id=f"tg:{user_id}",
agent_id=agent_config.agent_id,
team_id=dao_id,
channel_id=chat_id,
limit=80,
)
recent_file_id = _extract_recent_photo_file_id_from_memory(mc)
if recent_file_id:
_set_recent_photo_context(agent_config.agent_id, chat_id, user_id, recent_file_id)
logger.info(
f"{agent_config.name}: Recovered photo file_id from memory context for follow-up: {recent_file_id}"
)
except Exception as e:
logger.warning(f"{agent_config.name}: failed to recover photo file_id from memory: {e}")
if recent_file_id:
logger.info(
f"{agent_config.name}: Photo intent detected; using file_id={recent_file_id}"
)
followup_result = await process_photo(
agent_config=agent_config,
update=update,
chat_id=chat_id,
user_id=user_id,
username=username,
dao_id=dao_id,
photo={"file_id": recent_file_id},
caption_override=text,
bypass_media_gate=True,
)
return followup_result
# Hard guard: don't send photo-related requests to text LLM path when image context is missing.
if _needs_photo_only_response(text):
await send_telegram_message(
chat_id,
"Бачу питання про фото, але не знайшов зображення в історії сесії. Надішли фото ще раз з коротким питанням, і я одразу проаналізую.",
telegram_token,
)
return {"ok": True, "handled": True, "reason": "photo_followup_without_image_context"}
if not text and not caption:
# Check for unsupported message types and silently ignore
unsupported_types = ["sticker", "animation", "video_note", "contact", "location",
"venue", "poll", "dice", "game", "new_chat_members",
"left_chat_member", "new_chat_title", "new_chat_photo",
"delete_chat_photo", "pinned_message", "message_auto_delete_timer_changed"]
for msg_type in unsupported_types:
if update.message.get(msg_type):
logger.debug(f"Ignoring unsupported message type: {msg_type}")
return {"ok": True, "ignored": True, "reason": f"Unsupported message type: {msg_type}"}
# If no supported content found, return silently
logger.debug(f"Message without processable content from user {user_id}")
return {"ok": True, "ignored": True, "reason": "No processable content"}
# Use caption if text is empty (for photos with captions that weren't processed)
if not text and caption:
text = caption
logger.info(f"{agent_config.name} Telegram message from {username} (tg:{user_id}) in chat {chat_id}: {text[:50]}")
mentioned_bots = extract_bot_mentions(text)
needs_complex_reasoning = requires_complex_reasoning(text)
cached_answer = get_cached_response(agent_config.agent_id, chat_id, text)
if cached_answer:
await send_telegram_message(chat_id, cached_answer, 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=cached_answer,
channel_id=chat_id,
scope="short_term",
save_agent_response=not is_service_response(cached_answer),
agent_metadata={
"cached_reply": True,
"mentioned_bots": mentioned_bots,
"requires_complex_reasoning": needs_complex_reasoning,
},
username=username,
)
return {"ok": True, "agent": agent_config.agent_id, "cached": True}
# Check if there's a document context for follow-up questions
session_id = f"telegram:{chat_id}"
doc_context = await get_doc_context(session_id, agent_id=agent_config.agent_id)
# If there's a doc_id and the message looks like a question about the document
if doc_context and doc_context.doc_id:
is_question = _is_question_like(text)
if is_question:
logger.info(f"{agent_config.name}: Follow-up question detected for doc_id={doc_context.doc_id}")
# Try RAG query first
try:
rag_result = await asyncio.wait_for(
ask_about_document(
session_id=session_id,
question=text,
doc_id=doc_context.doc_id,
dao_id=dao_id or doc_context.dao_id,
user_id=f"tg:{user_id}",
agent_id=agent_config.agent_id,
),
timeout=25.0,
)
except asyncio.TimeoutError:
logger.warning(
f"{agent_config.name}: doc follow-up timeout for doc_id={doc_context.doc_id}; "
"fallback to regular chat path"
)
rag_result = None
if rag_result and rag_result.success and rag_result.answer:
# Truncate if too long for Telegram
answer = postprocess_agent_answer(
agent_id=agent_config.agent_id,
user_text=text or "",
answer_text=rag_result.answer,
force_detailed=should_force_detailed_reply(text),
needs_complex_reasoning=requires_complex_reasoning(text),
)
if len(answer) > TELEGRAM_SAFE_LENGTH:
answer = answer[:TELEGRAM_SAFE_LENGTH] + "\n\n_... (відповідь обрізано)_"
answer = _sanitize_agent_answer(agent_config.agent_id, text or "", answer)
await send_telegram_message(chat_id, answer, telegram_token)
return {"ok": True, "agent": "parser", "mode": "rag_query"}
# Source-lock: with active document context answer only from that document.
await send_telegram_message(
chat_id,
"Не знайшов точну відповідь у поточному документі. Уточни питання або надішли файл повторно.",
telegram_token,
)
return {"ok": True, "agent": "parser", "mode": "source_lock_no_answer"}
# ========================================
# BEHAVIOR POLICY v2.1: Check if should respond
# Gateway computes has_link and has_explicit_request (source of truth)
# ========================================
chat_type = chat.get("type", "private")
is_private_chat = chat_type == "private"
# Gateway: compute has_link (single source of truth)
has_link = detect_url(text) if text else False
# Gateway: detect mentioned agents
mentioned_agents = []
if text:
for aid, variants in AGENT_NAME_VARIANTS.items():
for v in variants:
if v.lower() in text.lower():
mentioned_agents.append(aid)
break
# Gateway: check if agent has been active in this chat recently (any user)
agent_active_in_chat = has_agent_chat_participation(agent_config.agent_id, chat_id)
# Gateway: compute has_explicit_request (single source of truth)
# CONTRACT: imperative OR (? AND (dm OR reply OR mention OR thread))
has_explicit_request = detect_explicit_request(
text=text,
is_dm=is_private_chat,
is_reply_to_agent=is_reply_to_agent,
mentioned_agents=mentioned_agents,
thread_has_agent_participation=agent_active_in_chat,
)
# Check if this is a prober request (chat_id=0 or user_id=0)
is_prober = is_prober_request(chat_id, user_id)
# SOWA v2.2: 3-level decision (FULL / ACK / SILENT)
sowa_decision = analyze_message(
text=text,
agent_id=agent_config.agent_id,
chat_id=chat_id,
user_id=str(user_id),
has_media=has_link,
media_caption=text if has_link else "",
is_private_chat=is_private_chat,
payload_explicit_request=has_explicit_request,
payload_has_link=has_link,
is_reply_to_agent=is_reply_to_agent,
thread_has_agent_participation=agent_active_in_chat,
)
respond_decision = sowa_decision.should_respond
respond_reason = sowa_decision.reason
# AgroMatrix usability guard:
# In dedicated chats users often ask short operational questions without explicit mention.
# Do not silence clear question turns for agromatrix.
if (
sowa_decision.action == "SILENT"
and agent_config.agent_id == "agromatrix"
and _is_question_like(text)
):
sowa_decision.action = "FULL"
sowa_decision.should_respond = True
respond_decision = True
respond_reason = "agromatrix_question_guard"
if sowa_decision.action == "SILENT":
logger.info(f"\U0001f507 SOWA: Agent {agent_config.agent_id} NOT responding. Reason: {respond_reason}")
# Save to memory for context tracking, but don't respond
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="", # No response
channel_id=chat_id,
scope="short_term",
save_agent_response=False,
agent_metadata={
"sowa_skipped": True,
"skip_reason": respond_reason,
},
username=username,
)
return {"ok": True, "skipped": True, "reason": respond_reason}
# ACK: send short presence message WITHOUT calling LLM/Router
if sowa_decision.action == "ACK":
ack_text = sowa_decision.ack_text or get_ack_text(agent_config.agent_id)
logger.info(f"\U0001f44b SOWA ACK: Agent {agent_config.agent_id} sending ACK. Reason: {respond_reason}")
# Send ACK to Telegram (no LLM call)
if not is_prober:
token = agent_config.get_telegram_token()
if token:
try:
url = f"https://api.telegram.org/bot{token}/sendMessage"
async with httpx.AsyncClient(timeout=30) as client:
ack_payload = {
"chat_id": chat_id,
"text": ack_text,
}
# Link ACK to the user's message for better UX
msg_id = update.message.get("message_id")
if msg_id:
ack_payload["reply_to_message_id"] = msg_id
ack_payload["allow_sending_without_reply"] = True
resp = await client.post(url, json=ack_payload)
if resp.status_code == 200:
logger.info(f"\U0001f44b ACK sent to chat {chat_id}: {ack_text}")
else:
logger.warning(f"ACK send failed: {resp.status_code} {resp.text[:200]}")
except Exception as e:
logger.error(f"ACK send error: {e}")
# Record ACK for cooldown and interaction tracking
record_ack(agent_config.agent_id, str(chat_id))
record_interaction(agent_config.agent_id, str(chat_id), str(user_id))
# Save to memory
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=ack_text,
channel_id=chat_id,
scope="short_term",
save_agent_response=True,
agent_metadata={
"sowa_ack": True,
"ack_reason": respond_reason,
},
username=username,
)
return {"ok": True, "ack": True, "reason": respond_reason}
# FULL: proceed with LLM/Router call
# For prober requests, skip LLM/Router entirely to save tokens
if is_prober:
logger.info(f"\U0001f9ea PROBER: Agent {agent_config.agent_id} responding to prober (no LLM call). Reason: {respond_reason}")
return {"ok": True, "agent": agent_config.agent_id, "prober": True, "response_preview": "[prober-skip-llm]"}
else:
logger.info(f"\u2705 SOWA: Agent {agent_config.agent_id} WILL respond (FULL). Reason: {respond_reason}")
# Regular chat mode
# Fetch memory context (includes local context as fallback)
# Всі агенти мають доступ до однакової історії (80 повідомлень) для контексту
context_limit = 40 if agent_config.agent_id == "agromatrix" else 80
memory_context = await memory_client.get_context(
user_id=f"tg:{user_id}",
agent_id=agent_config.agent_id,
team_id=dao_id,
channel_id=chat_id,
limit=context_limit
)
# Build message with conversation context
local_history = _truncate_context_for_prompt(
memory_context.get("local_context_text", ""),
max_chars=2200 if agent_config.agent_id == "agromatrix" else 3800,
max_lines=28 if agent_config.agent_id == "agromatrix" else 48,
)
# Check if this is a training group
is_training_group = str(chat_id) in TRAINING_GROUP_IDS
training_prefix = ""
if is_training_group:
training_prefix = "[РЕЖИМ НАВЧАННЯ - відповідай на це повідомлення, ти в навчальній групі Agent Preschool]\n\n"
logger.info(f"🎓 Training mode activated for chat {chat_id}")
unresolved_questions = _extract_unanswered_user_messages(
memory_context=memory_context,
current_user_id=f"tg:{user_id}",
max_items=3,
)
unresolved_non_current: List[str] = []
unresolved_block = ""
if unresolved_questions:
# Do not duplicate current prompt if it matches one pending message.
unresolved_non_current = [q for q in unresolved_questions if q.strip() != (text or "").strip()]
if unresolved_non_current:
unresolved_non_current = unresolved_non_current[-1:] if agent_config.agent_id == "agromatrix" else unresolved_non_current
unresolved_block = (
"[КРИТИЧНО: є невідповідані питання цього користувача. "
"Спочатку коротко відповідай на них, потім на поточне повідомлення. "
"Не змінюй тему і не ігноруй pending-питання.]\n"
"[Невідповідані питання цього користувача]\n"
+ "\n".join(f"- {q}" for q in unresolved_non_current)
+ "\n\n"
)
if local_history:
# Add conversation history to message for better context understanding
message_with_context = (
f"{training_prefix}"
f"[Контекст розмови]\n{local_history}\n\n"
f"{unresolved_block}"
f"[Поточне повідомлення від {username}]\n{text}"
)
else:
message_with_context = f"{training_prefix}{unresolved_block}{text}"
preferred_lang = await resolve_preferred_language_persistent(
chat_id=chat_id,
user_id=user_id,
text=text or "",
telegram_lang_code=from_user.get("language_code"),
team_id=dao_id,
)
preferred_lang_label = preferred_language_label(preferred_lang)
response_style_pref = await resolve_response_style_preference(
agent_id=agent_config.agent_id,
chat_id=chat_id,
user_id=str(user_id),
text=text or "",
team_id=dao_id,
)
force_detailed = should_force_detailed_reply(text) or response_style_pref == "detailed"
force_concise = (not force_detailed) and (should_force_concise_reply(text) or response_style_pref == "concise")
# Build request to Router
system_prompt = agent_config.system_prompt
logger.info(f"📝 {agent_config.name} system_prompt length: {len(system_prompt) if system_prompt else 0} chars")
if system_prompt:
logger.debug(f"System prompt preview: {system_prompt[:200]}...")
else:
logger.error(f"{agent_config.name} system_prompt is EMPTY or None!")
router_request = {
"message": message_with_context,
"mode": "chat",
"agent": agent_config.agent_id,
"metadata": {
"source": "telegram",
"dao_id": dao_id,
"user_id": f"tg:{user_id}",
"session_id": f"tg:{chat_id}:{dao_id}",
"username": username,
"chat_id": chat_id,
"raw_user_text": text,
"sender_is_bot": is_sender_bot,
"mentioned_bots": mentioned_bots,
"requires_complex_reasoning": needs_complex_reasoning,
"is_reply_to_agent": is_reply_to_agent,
"is_training_group": is_training_group,
"preferred_response_language": preferred_lang,
"preferred_response_language_label": preferred_lang_label,
"response_style_preference": response_style_pref,
"has_unresolved_questions": bool(unresolved_non_current),
"unresolved_questions_count": len(unresolved_non_current),
},
"context": {
"agent_name": agent_config.name,
"system_prompt": system_prompt,
"memory": memory_context,
"participants": {
"sender_is_bot": is_sender_bot,
"mentioned_bots": mentioned_bots,
},
},
}
if force_detailed:
router_request["metadata"]["force_detailed"] = True
if force_concise:
# IMPORTANT: preserve conversation context! Only append concise instruction
router_request["metadata"]["force_concise"] = True
router_request["message"] = (
router_request["message"]
+ "\n\n(Інструкція: спочатку дай коротку відповідь по суті (1-3 абзаци), "
"а якщо користувач попросить — розгорни детально.)"
+ f"\n(Мова відповіді: {preferred_lang_label}.)"
+ "\n(Не потрібно щоразу представлятися по імені або писати шаблонне: 'чим можу допомогти'.)"
)
# Helion policy: DeepSeek-first primary response path.
if agent_config.agent_id == "helion":
router_request["metadata"]["provider"] = "cloud_deepseek"
router_request["metadata"]["reason"] = "helion_primary_deepseek"
elif needs_complex_reasoning:
router_request["metadata"]["provider"] = "cloud_deepseek"
router_request["metadata"]["reason"] = "auto_complex"
if not force_concise:
router_request["message"] = (
router_request["message"]
+ f"\n\n(Мова відповіді: {preferred_lang_label}.)"
+ "\n(Не потрібно щоразу представлятися по імені або писати шаблонне: 'чим можу допомогти'.)"
)
if unresolved_non_current:
router_request["message"] = (
router_request["message"]
+ "\n\n(Пріоритет відповіді: 1) закрий невідповідані питання користувача; "
"2) дай відповідь на поточне повідомлення. Якщо питання пов'язані, дай одну узгоджену відповідь.)"
)
# Send to Router
logger.info(f"Sending to Router: agent={agent_config.agent_id}, dao={dao_id}, user=tg:{user_id}")
response = await send_to_router(router_request)
# Extract response
if isinstance(response, dict) and response.get("ok"):
answer_text = response.get("data", {}).get("text") or response.get("response", "")
image_base64 = response.get("image_base64") or response.get("data", {}).get("image_base64")
file_base64 = response.get("file_base64") or response.get("data", {}).get("file_base64")
file_name = response.get("file_name") or response.get("data", {}).get("file_name") or "artifact.bin"
file_mime = response.get("file_mime") or response.get("data", {}).get("file_mime") or "application/octet-stream"
# Debug logging
logger.info(f"📦 Router response: {len(answer_text)} chars, model={response.get('model')}, backend={response.get('backend')}")
logger.info(f"📝 Response preview: {answer_text[:300]}..." if len(answer_text) > 300 else f"📝 Response: {answer_text}")
if image_base64:
logger.info(f"🖼️ Received image_base64: {len(image_base64)} chars")
else:
logger.debug("⚠️ No image_base64 in response")
if file_base64:
logger.info(f"📄 Received file_base64: {len(file_base64)} chars ({file_name})")
# Check for NO_OUTPUT (LLM decided not to respond)
if is_no_output_response(answer_text):
logger.info(f"🔇 NO_OUTPUT: Agent {agent_config.agent_id} returned empty/NO_OUTPUT. Not sending to Telegram.")
# P4: Detect NO_OUTPUT contract violations (extra text after NO_OUTPUT marker)
_stripped = (answer_text or "").strip()
_has_extra = False
if _stripped and "__NO_OUTPUT__" in _stripped:
_after_marker = _stripped.split("__NO_OUTPUT__", 1)[-1].strip()
if _after_marker:
_has_extra = True
logger.warning(
f"🚨 policy_violation=no_output_extra_text "
f"agent={agent_config.agent_id} "
f"chat_id={chat_id} "
f"extra_text_len={len(_after_marker)} "
f"extra_preview={_after_marker[:80]!r}"
)
elif _stripped and _stripped.lower() not in ("", "no_output", "no output", "silent", "мовчу", "", ".", "..", "..."):
# LLM returned something that looks like NO_OUTPUT but has unexpected content
if len(_stripped) > 10:
_has_extra = True
logger.warning(
f"🚨 policy_violation=ambiguous_no_output "
f"agent={agent_config.agent_id} "
f"chat_id={chat_id} "
f"response_len={len(_stripped)} "
f"response_preview={_stripped[:80]!r}"
)
# Save to memory for context tracking
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="",
channel_id=chat_id,
scope="short_term",
save_agent_response=False,
agent_metadata={
"no_output": True,
"original_response": answer_text[:100] if answer_text else "",
"policy_violation": "no_output_extra_text" if _has_extra else None,
},
username=username,
)
return {"ok": True, "skipped": True, "reason": "no_output_from_llm"}
# Retry policy: if response drifts from current intent, do one strict reroute.
if _answer_seems_off_intent(text or "", answer_text):
try:
strict_request = copy.deepcopy(router_request)
strict_request["metadata"]["intent_retry"] = 1
strict_request["metadata"]["disable_tools"] = True
strict_request["metadata"]["max_tool_rounds"] = 1
strict_request["metadata"]["temperature"] = 0.1
strict_request["message"] = (
f"{message_with_context}\n\n"
"(Жорстка інструкція: відповідай тільки на ПОТОЧНЕ питання користувача. "
"Не змінюй тему, не генеруй презентацію/план, якщо цього не просили. "
"Для числових питань: дай value + unit + джерело (лист/рядок).)"
)
retry_response = await send_to_router(strict_request)
if isinstance(retry_response, dict) and retry_response.get("ok"):
retry_text = retry_response.get("data", {}).get("text") or retry_response.get("response", "")
if retry_text and not is_no_output_response(retry_text):
answer_text = retry_text
router_request = strict_request
logger.info("Intent retry succeeded with strict prompt")
except Exception as retry_err:
logger.warning(f"Intent retry failed: {retry_err}")
force_detailed_reply = bool(router_request.get("metadata", {}).get("force_detailed"))
answer_text = postprocess_agent_answer(
agent_id=agent_config.agent_id,
user_text=text or "",
answer_text=answer_text,
force_detailed=force_detailed_reply,
needs_complex_reasoning=needs_complex_reasoning,
)
answer_text = _sanitize_agent_answer(agent_config.agent_id, text or "", answer_text)
# Skip Telegram sending for prober requests (chat_id=0)
if is_prober:
logger.info(f"🧪 PROBER: Skipping Telegram send for prober request. Response: {answer_text[:100]}...")
return {"ok": True, "agent": agent_config.agent_id, "prober": True, "response_preview": answer_text[:100]}
# Send file artifact if generated
if file_base64:
try:
file_bytes = base64.b64decode(file_base64)
token = telegram_token or os.getenv("TELEGRAM_BOT_TOKEN")
url = f"https://api.telegram.org/bot{token}/sendDocument"
caption = answer_text[:1024] if answer_text else ""
safe_name = str(file_name).split("/")[-1].split("\\")[-1] or "artifact.bin"
async with httpx.AsyncClient() as client:
files = {"document": (safe_name, BytesIO(file_bytes), file_mime)}
data = {"chat_id": chat_id}
if caption:
data["caption"] = caption
response_doc = await client.post(url, files=files, data=data, timeout=45.0)
response_doc.raise_for_status()
logger.info(f"✅ Sent generated document to Telegram chat {chat_id}: {safe_name}")
except Exception as e:
logger.error(f"❌ Failed to send document to Telegram: {e}")
await send_telegram_message(chat_id, answer_text or "Файл згенеровано, але не вдалося надіслати документ.", telegram_token)
# Send image if generated
elif image_base64:
try:
# Decode base64 image
image_bytes = base64.b64decode(image_base64)
# Send photo to Telegram
token = telegram_token or os.getenv("TELEGRAM_BOT_TOKEN")
url = f"https://api.telegram.org/bot{token}/sendPhoto"
async with httpx.AsyncClient() as client:
files = {"photo": ("image.png", BytesIO(image_bytes), "image/png")}
# Telegram caption limit is 1024 chars.
safe_caption = (answer_text or "")[:1024]
data = {"chat_id": chat_id, "caption": safe_caption}
response_photo = await client.post(url, files=files, data=data, timeout=30.0)
response_photo.raise_for_status()
logger.info(f"✅ Sent generated image to Telegram chat {chat_id}")
except Exception as e:
logger.error(f"❌ Failed to send image to Telegram: {e}")
# Fallback to text only
await send_telegram_message(chat_id, answer_text, telegram_token)
else:
# Send text response only
await send_telegram_message(chat_id, answer_text, telegram_token)
# Record successful interaction for conversation context
record_interaction(agent_config.agent_id, chat_id, str(user_id))
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=answer_text,
channel_id=chat_id,
scope="short_term",
save_agent_response=not is_service_response(answer_text),
agent_metadata={
"mentioned_bots": mentioned_bots,
"requires_complex_reasoning": needs_complex_reasoning,
"preferred_language": preferred_lang,
},
username=username,
)
store_response_cache(agent_config.agent_id, chat_id, text, answer_text)
return {"ok": True, "agent": agent_config.agent_id}
else:
error_msg = response.get("error", "Unknown error") if isinstance(response, dict) else "Router error"
logger.error(f"Router error: {error_msg}")
await send_telegram_message(chat_id, f"Вибач, сталася помилка: {error_msg}", telegram_token)
return {"ok": False, "error": error_msg}
# ========================================
# Endpoints
# ========================================
# DAARWIZZ webhook endpoints (both paths for compatibility)
@router.get("/healthz")
async def healthz():
try:
from crews.agromatrix_crew.run import handle_message # noqa: F401
return {"ok": True, "status": "healthy"}
except Exception as e:
return {"ok": False, "status": "error", "error": str(e)}
@router.post("/telegram/webhook")
async def telegram_webhook(update: TelegramUpdate):
"""Handle Telegram webhook for DAARWIZZ agent (default path)."""
try:
return await handle_telegram_webhook(DAARWIZZ_CONFIG, update)
except Exception as e:
logger.error(f"Error handling DAARWIZZ Telegram webhook: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/daarwizz/telegram/webhook")
async def daarwizz_telegram_webhook(update: TelegramUpdate):
"""Handle Telegram webhook for DAARWIZZ agent (agent-specific path)."""
try:
return await handle_telegram_webhook(DAARWIZZ_CONFIG, update)
except Exception as e:
logger.error(f"Error handling DAARWIZZ Telegram webhook: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# Legacy code - will be removed after testing
async def _old_telegram_webhook(update: TelegramUpdate):
"""Стара версія - використовується для тестування"""
try:
if not update.message:
raise HTTPException(status_code=400, detail="No message in update")
# Extract message details
from_user = update.message.get("from", {})
chat = update.message.get("chat", {})
user_id = str(from_user.get("id", "unknown"))
chat_id = str(chat.get("id", "unknown"))
username = from_user.get("username", "")
# Get DAO ID for this chat
dao_id = get_dao_id(chat_id, "telegram", agent_id=agent_config.agent_id)
# Check for /ingest command
text = update.message.get("text", "")
if text and text.strip().startswith("/ingest"):
session_id = f"telegram:{chat_id}"
# Check if there's a document in the message
document = update.message.get("document")
if document:
mime_type = document.get("mime_type", "")
file_name = document.get("file_name", "")
file_id = document.get("file_id")
is_pdf = (
mime_type == "application/pdf" or
(mime_type.startswith("application/") and file_name.lower().endswith(".pdf"))
)
if is_pdf and file_id:
try:
telegram_token = os.getenv("TELEGRAM_BOT_TOKEN")
file_path = await get_telegram_file_path(file_id)
if file_path:
file_url = f"https://api.telegram.org/file/bot{telegram_token}/{file_path}"
result = await ingest_document(
session_id=session_id,
doc_url=file_url,
file_name=file_name,
dao_id=dao_id,
user_id=f"tg:{user_id}",
agent_id=agent_config.agent_id,
)
if result.success:
await send_telegram_message(
chat_id,
f"✅ **Документ імпортовано у RAG**\n\n"
f"📊 Фрагментів: {result.ingested_chunks}\n"
f"📁 DAO: {dao_id}\n\n"
f"Тепер ти можеш задавати питання по цьому документу!"
)
return {"ok": True, "chunks_count": result.ingested_chunks}
else:
await send_telegram_message(chat_id, f"Вибач, не вдалося імпортувати: {result.error}")
return {"ok": False, "error": result.error}
except Exception as e:
logger.error(f"Ingest failed: {e}", exc_info=True)
await send_telegram_message(chat_id, "Вибач, не вдалося імпортувати документ.")
return {"ok": False, "error": "Ingest failed"}
# Try to get last parsed doc_id from session context
result = await ingest_document(
session_id=session_id,
dao_id=dao_id,
user_id=f"tg:{user_id}",
agent_id=agent_config.agent_id,
)
if result.success:
await send_telegram_message(
chat_id,
f"✅ **Документ імпортовано у RAG**\n\n"
f"📊 Фрагментів: {result.ingested_chunks}\n"
f"📁 DAO: {dao_id}\n\n"
f"Тепер ти можеш задавати питання по цьому документу!"
)
return {"ok": True, "chunks_count": result.ingested_chunks}
else:
await send_telegram_message(chat_id, "Спочатку надішли PDF-документ, а потім використай /ingest")
return {"ok": False, "error": result.error}
# Check if it's a document (PDF)
document = update.message.get("document")
if document:
mime_type = document.get("mime_type", "")
file_name = document.get("file_name", "")
file_id = document.get("file_id")
# Check if it's a PDF
is_pdf = (
mime_type == "application/pdf" or
(mime_type.startswith("application/") and file_name.lower().endswith(".pdf"))
)
if is_pdf and file_id:
logger.info(f"PDF document from {username} (tg:{user_id}), file_id: {file_id}, file_name: {file_name}")
try:
# Get file path from Telegram
telegram_token = os.getenv("TELEGRAM_BOT_TOKEN")
file_path = await get_telegram_file_path(file_id)
if not file_path:
raise HTTPException(status_code=400, detail="Failed to get file from Telegram")
# Build file URL
file_url = f"https://api.telegram.org/file/bot{telegram_token}/{file_path}"
# Use doc_service for parsing
session_id = f"telegram:{chat_id}"
result = await parse_document(
session_id=session_id,
doc_url=file_url,
file_name=file_name,
dao_id=dao_id,
user_id=f"tg:{user_id}",
output_mode="qa_pairs",
metadata={"username": username, "chat_id": chat_id},
agent_id=agent_config.agent_id,
)
if not result.success:
await send_telegram_message(chat_id, f"Вибач, не вдалося обробити документ: {result.error}")
return {"ok": False, "error": result.error}
# Format response for Telegram
answer_text = ""
if result.qa_pairs:
# Convert QAItem to dict for formatting
qa_list = [{"question": qa.question, "answer": qa.answer} for qa in result.qa_pairs]
answer_text = format_qa_response(qa_list)
elif result.markdown:
answer_text = format_markdown_response(result.markdown)
elif result.chunks_meta and result.chunks_meta.get("chunks"):
chunks = result.chunks_meta.get("chunks", [])
answer_text = format_chunks_response(chunks)
else:
answer_text = "✅ Документ успішно оброблено, але формат відповіді не розпізнано."
# Add hint about /ingest command
if not answer_text.endswith("_"):
answer_text += "\n\n💡 _Використай /ingest для імпорту документа у RAG_"
logger.info(f"PDF parsing result: {len(answer_text)} chars, doc_id={result.doc_id}")
# Send response back to Telegram
await send_telegram_message(chat_id, answer_text)
return {"ok": True, "agent": "parser", "mode": "doc_parse", "doc_id": result.doc_id}
except Exception as e:
logger.error(f"PDF processing failed: {e}", exc_info=True)
await send_telegram_message(chat_id, "Вибач, не вдалося обробити PDF-документ. Переконайся, що файл не пошкоджений.")
return {"ok": False, "error": "PDF processing failed"}
elif document and not is_pdf:
# Non-PDF document
await send_telegram_message(chat_id, "Наразі підтримуються тільки PDF-документи. Інші формати (docx, zip, тощо) будуть додані пізніше.")
return {"ok": False, "error": "Unsupported document type"}
# Check if it's a photo
photo = update.message.get("photo")
if photo:
# Telegram sends multiple sizes, get the largest one (last in array)
photo_obj = photo[-1] if isinstance(photo, list) else photo
file_id = photo_obj.get("file_id") if isinstance(photo_obj, dict) else None
if file_id:
logger.info(f"Photo from {username} (tg:{user_id}), file_id: {file_id}")
try:
# Get file path from Telegram
telegram_token = os.getenv("TELEGRAM_BOT_TOKEN")
file_path = await get_telegram_file_path(file_id)
if not file_path:
raise HTTPException(status_code=400, detail="Failed to get file from Telegram")
# Build file URL
file_url = f"https://api.telegram.org/file/bot{telegram_token}/{file_path}"
# Send to Router with specialist_vision_8b model (Swapper)
router_request = {
"message": f"Коротко (2-3 речення) опиши значення цього зображення: {file_url}",
"mode": "chat",
"agent": "daarwizz",
"metadata": {
"source": "telegram",
"dao_id": dao_id,
"user_id": f"tg:{user_id}",
"session_id": f"tg:{chat_id}:{dao_id}",
"username": username,
"chat_id": chat_id,
"file_id": file_id,
"file_url": file_url,
"has_image": True,
},
"context": {
"agent_name": DAARWIZZ_NAME,
"system_prompt": DAARWIZZ_SYSTEM_PROMPT,
},
}
# Override LLM to use specialist_vision_8b for image understanding
router_request["metadata"]["use_llm"] = "specialist_vision_8b"
# Send to Router
logger.info(f"Sending photo to Router with vision-8b: file_url={file_url[:50]}...")
response = await send_to_router(router_request)
# Extract response
if isinstance(response, dict) and response.get("ok"):
answer_text = response.get("data", {}).get("text") or response.get("response", "")
if answer_text:
# Photo processed successfully
await send_telegram_message(
chat_id,
answer_text # No prefix, just the LLM response
)
# Save to memory for context
await memory_client.save_chat_turn(
agent_id="daarwizz",
team_id=dao_id,
user_id=f"tg:{user_id}",
message=f"[Photo: {file_id}]",
response=answer_text,
channel_id=chat_id,
scope="short_term",
save_agent_response=not is_service_response(answer_text),
agent_metadata={"context": "photo"},
username=username,
)
return {"ok": True, "agent": "daarwizz", "model": "specialist_vision_8b"}
else:
await send_telegram_message(chat_id, "Не вдалося отримати опис зображення.")
return {"ok": False, "error": "No description in response"}
else:
error_msg = response.get("error", "Unknown error") if isinstance(response, dict) else "Router error"
logger.error(f"Vision-8b error: {error_msg}")
await send_telegram_message(chat_id, f"Вибач, не вдалося обробити фото: {error_msg}")
return {"ok": False, "error": error_msg}
except Exception as e:
logger.error(f"Photo processing failed: {e}", exc_info=True)
await send_telegram_message(chat_id, "Вибач, сталася помилка при обробці фото.")
return {"ok": False, "error": "Photo processing failed"}
# Check if it's a voice message
voice = update.message.get("voice")
audio = update.message.get("audio")
video_note = update.message.get("video_note")
text = ""
if voice or audio or video_note:
# Голосове повідомлення - розпізнаємо через STT
media_obj = voice or audio or video_note
file_id = media_obj.get("file_id") if media_obj else None
if not file_id:
raise HTTPException(status_code=400, detail="No file_id in voice/audio/video_note")
logger.info(f"Voice message from {username} (tg:{user_id}), file_id: {file_id}")
try:
# Отримуємо файл з Telegram
file_path = await get_telegram_file_path(file_id)
if not file_path:
raise HTTPException(status_code=400, detail="Failed to get file from Telegram")
# Завантажуємо файл
file_url = f"https://api.telegram.org/file/bot{os.getenv('TELEGRAM_BOT_TOKEN')}/{file_path}"
async with httpx.AsyncClient(timeout=30.0) as client:
file_resp = await client.get(file_url)
file_resp.raise_for_status()
audio_bytes = file_resp.content
# Відправляємо в STT-сервіс
stt_upload_url = _resolve_stt_upload_url()
files = {"file": ("voice.ogg", audio_bytes, "audio/ogg")}
async with httpx.AsyncClient(timeout=60.0) as client:
stt_resp = await client.post(stt_upload_url, files=files)
stt_resp.raise_for_status()
stt_data = stt_resp.json()
text = stt_data.get("text", "")
logger.info(f"STT result: {text[:100]}...")
except Exception as e:
logger.error(f"STT processing failed: {e}", exc_info=True)
await send_telegram_message(chat_id, "Вибач, не вдалося розпізнати голосове повідомлення. Спробуй надіслати текстом.", os.getenv("DAARWIZZ_TELEGRAM_BOT_TOKEN"))
return {"ok": False, "error": "STT failed"}
else:
# Текстове повідомлення
text = update.message.get("text", "")
caption = update.message.get("caption", "")
if not text and not caption:
# Check for unsupported message types and silently ignore
unsupported_types = ["sticker", "animation", "video_note", "contact", "location",
"venue", "poll", "dice", "game", "new_chat_members",
"left_chat_member", "new_chat_title", "new_chat_photo",
"delete_chat_photo", "pinned_message"]
for msg_type in unsupported_types:
if update.message.get(msg_type):
logger.debug(f"DAARWIZZ: Ignoring unsupported message type: {msg_type}")
return {"ok": True, "ignored": True, "reason": f"Unsupported message type: {msg_type}"}
# If no supported content found, return silently
return {"ok": True, "ignored": True, "reason": "No processable content"}
# Use caption if text is empty
if not text and caption:
text = caption
logger.info(f"Telegram message from {username} (tg:{user_id}) in chat {chat_id}: {text[:50]}")
# Check if there's a document context for follow-up questions
session_id = f"telegram:{chat_id}"
doc_context = await get_doc_context(session_id, agent_id=agent_config.agent_id)
# If there's a doc_id and the message looks like a question about the document
if doc_context and doc_context.doc_id:
is_question = _is_question_like(text)
if is_question:
logger.info(f"Follow-up question detected for doc_id={doc_context.doc_id}")
# Try RAG query first
try:
rag_result = await asyncio.wait_for(
ask_about_document(
session_id=session_id,
question=text,
doc_id=doc_context.doc_id,
dao_id=dao_id or doc_context.dao_id,
user_id=f"tg:{user_id}",
agent_id=agent_config.agent_id,
),
timeout=25.0,
)
except asyncio.TimeoutError:
logger.warning(
f"Doc follow-up timeout for agent={agent_config.agent_id}, "
f"doc_id={doc_context.doc_id}; fallback to regular chat path"
)
rag_result = None
if rag_result and rag_result.success and rag_result.answer:
# Truncate if too long for Telegram
answer = postprocess_agent_answer(
agent_id=agent_config.agent_id,
user_text=text or "",
answer_text=rag_result.answer,
force_detailed=should_force_detailed_reply(text),
needs_complex_reasoning=requires_complex_reasoning(text),
)
if len(answer) > TELEGRAM_SAFE_LENGTH:
answer = answer[:TELEGRAM_SAFE_LENGTH] + "\n\n_... (відповідь обрізано)_"
await send_telegram_message(chat_id, answer)
return {"ok": True, "agent": "parser", "mode": "rag_query"}
# Fall through to regular chat if RAG query fails
# Regular chat mode
# Fetch memory context
memory_context = await memory_client.get_context(
user_id=f"tg:{user_id}",
agent_id="daarwizz",
team_id=dao_id,
channel_id=chat_id,
limit=80
)
# Build request to Router with DAARWIZZ context
router_request = {
"message": text,
"mode": "chat",
"agent": "daarwizz", # DAARWIZZ agent identifier
"metadata": {
"source": "telegram",
"dao_id": dao_id,
"user_id": f"tg:{user_id}",
"session_id": f"tg:{chat_id}:{dao_id}",
"username": username,
"chat_id": chat_id,
},
"context": {
"agent_name": DAARWIZZ_NAME,
"system_prompt": DAARWIZZ_SYSTEM_PROMPT,
"memory": memory_context, # Додаємо пам'ять
# RBAC context will be injected by Router
},
}
# Send to Router
logger.info(f"Sending to Router: agent=daarwizz, dao={dao_id}, user=tg:{user_id}")
response = await send_to_router(router_request)
# Extract response text
if isinstance(response, dict):
answer_text = response.get("data", {}).get("text") or response.get("response", "Вибач, я зараз не можу відповісти.")
else:
answer_text = "Вибач, сталася помилка."
logger.info(f"Router response: {answer_text[:100]}")
# Save chat turn to memory
await memory_client.save_chat_turn(
agent_id="daarwizz",
team_id=dao_id,
user_id=f"tg:{user_id}",
message=text,
response=answer_text,
channel_id=chat_id,
scope="short_term",
save_agent_response=not is_service_response(answer_text),
agent_metadata={"context": "legacy_daarwizz"},
username=username,
)
# Send response back to Telegram
await send_telegram_message(chat_id, answer_text, os.getenv("DAARWIZZ_TELEGRAM_BOT_TOKEN"))
return {"ok": True, "agent": "daarwizz"}
except Exception as e:
logger.error(f"Error handling Telegram webhook: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/discord/webhook")
async def discord_webhook(message: DiscordMessage):
"""
Handle Discord webhook.
Discord message format:
{
"content": "Hello!",
"author": {"id": "123", "username": "alice"},
"channel_id": "456",
"guild_id": "789"
}
"""
try:
if not message.content:
raise HTTPException(status_code=400, detail="No content in message")
# Extract message details
text = message.content
author = message.author or {}
channel_id = message.channel_id or "unknown"
guild_id = message.guild_id or "unknown"
user_id = author.get("id", "unknown")
username = author.get("username", "")
# Get DAO ID for this channel
dao_id = get_dao_id(channel_id, "discord")
logger.info(f"Discord message from {username} (discord:{user_id}): {text[:50]}")
# Fetch memory context
memory_context = await memory_client.get_context(
user_id=f"discord:{user_id}",
agent_id="daarwizz",
team_id=dao_id,
channel_id=channel_id,
limit=80
)
# Build request to Router with DAARWIZZ context
router_request = {
"message": text,
"mode": "chat",
"agent": "daarwizz",
"metadata": {
"source": "discord",
"dao_id": dao_id,
"user_id": f"discord:{user_id}",
"session_id": f"discord:{channel_id}:{dao_id}",
"username": username,
"channel_id": channel_id,
"guild_id": guild_id,
},
"context": {
"agent_name": DAARWIZZ_NAME,
"system_prompt": DAARWIZZ_SYSTEM_PROMPT,
"memory": memory_context, # Додаємо пам'ять
},
}
# Send to Router
response = await send_to_router(router_request)
# Extract response text
if isinstance(response, dict):
answer_text = response.get("data", {}).get("text") or response.get("response", "Sorry, I can't respond right now.")
else:
answer_text = "Sorry, an error occurred."
logger.info(f"Router response: {answer_text[:100]}")
# Save chat turn to memory
await memory_client.save_chat_turn(
agent_id="daarwizz",
team_id=dao_id,
user_id=f"discord:{user_id}",
message=text,
response=answer_text,
channel_id=channel_id,
scope="short_term",
save_agent_response=not is_service_response(answer_text),
agent_metadata={"source": "discord"},
username=username,
)
# TODO: Send response back to Discord
# await send_discord_message(channel_id, answer_text)
return {"ok": True, "agent": "daarwizz", "response": answer_text}
except Exception as e:
logger.error(f"Error handling Discord webhook: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ========================================
# Helper Functions
# ========================================
async def get_telegram_file_path(file_id: str, bot_token: str = None) -> Optional[str]:
"""Отримати шлях до файлу з Telegram API"""
telegram_token = bot_token or os.getenv("TELEGRAM_BOT_TOKEN")
if not telegram_token:
logger.error("TELEGRAM_BOT_TOKEN not set")
return None
url = f"https://api.telegram.org/bot{telegram_token}/getFile"
params = {"file_id": file_id}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, params=params)
response.raise_for_status()
data = response.json()
if data.get("ok"):
return data.get("result", {}).get("file_path")
except Exception as e:
logger.error(f"Error getting Telegram file: {e}")
return None
def format_qa_response(qa_pairs: list, max_pairs: int = 5) -> str:
"""Format Q&A pairs for Telegram with length limits"""
if not qa_pairs:
return "📋 Документ оброблено, але Q&A пари не знайдено."
qa_text = "📋 **Зміст документа:**\n\n"
displayed = 0
for i, qa in enumerate(qa_pairs[:max_pairs], 1):
question = qa.get('question', 'Питання')
answer = qa.get('answer', 'Відповідь')
# Truncate answer if too long
if len(answer) > 500:
answer = answer[:500] + "..."
pair_text = f"**{i}. {question}**\n{answer}\n\n"
# Check if adding this pair would exceed limit
if len(qa_text) + len(pair_text) > TELEGRAM_SAFE_LENGTH:
break
qa_text += pair_text
displayed += 1
if len(qa_pairs) > displayed:
remaining = len(qa_pairs) - displayed
qa_text += f"_... та ще {remaining} {'питань' if remaining > 1 else 'питання'}_"
return qa_text
def format_markdown_response(markdown: str) -> str:
"""Format markdown response - returns raw text for LLM processing"""
# Just return the text - LLM will summarize it
return markdown
def format_chunks_response(chunks: list) -> str:
"""Format chunks summary for Telegram"""
if not chunks:
return "📄 Документ розпарсено, але фрагменти не знайдено."
answer_text = f"📄 **Документ розпарсено** ({len(chunks)} фрагментів)\n\n"
answer_text += "**Перші фрагменти:**\n\n"
for i, chunk in enumerate(chunks[:3], 1):
text = chunk.get('text', '')[:200]
answer_text += f"{i}. {text}...\n\n"
if len(chunks) > 3:
answer_text += f"_... та ще {len(chunks) - 3} фрагментів_"
return answer_text
def _zip_read_summary(markdown_text: str) -> Optional[str]:
"""Extract a short summary of processed/skipped files from ZIP markdown."""
if not markdown_text:
return None
lines = [line.strip() for line in markdown_text.splitlines()]
try:
processed = []
skipped = []
idx = 0
while idx < len(lines):
line = lines[idx]
if line.lower() == "processed files:":
idx += 1
while idx < len(lines) and lines[idx].startswith("- "):
processed.append(lines[idx][2:].strip())
idx += 1
continue
if line.lower() == "skipped files:":
idx += 1
while idx < len(lines) and lines[idx].startswith("- "):
skipped.append(lines[idx][2:].strip())
idx += 1
continue
idx += 1
if not processed and not skipped:
return None
processed_text = ", ".join(processed) if processed else "нічого"
skipped_text = ", ".join(skipped) if skipped else "нічого"
return f"Прочитала з ZIP: {processed_text}; пропустила: {skipped_text}."
except Exception:
return None
def _default_theme_payload(brand_id: str) -> Dict[str, Any]:
"""Return a minimal theme.json payload for quick publish."""
return {
"theme_version": "v1.0.0",
"brand_id": brand_id,
"layout": {
"page": "LAYOUT_WIDE",
"safe_area": {"x": 0.6, "y": 0.45, "w": 12.1, "h": 6.2},
},
"palette": {
"primary": "#0B1220",
"secondary": "#1E293B",
"accent": "#22C55E",
"bg": "#FFFFFF",
"text": "#0F172A",
},
"typography": {
"font_primary": "Inter",
"font_secondary": "Inter",
"sizes": {"h1": 38, "h2": 28, "h3": 22, "body": 16, "small": 12},
"weights": {"regular": 400, "medium": 500, "bold": 700},
"line_height": {"tight": 1.05, "normal": 1.15, "relaxed": 1.25},
},
"components": {
"header": {"enabled": True, "logo_variant": "light", "show_title": False},
"footer": {"enabled": True, "show_page_number": True, "left_text": brand_id},
},
"rules": {
"max_bullets": 6,
"max_bullet_len": 110,
"min_font_body": 12,
"overflow_strategy": "appendix",
},
}
async def _brand_intake_request(payload: Dict[str, Any]) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(f"{BRAND_INTAKE_URL}/brand/intake", json=payload)
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"Brand intake error: {resp.text[:200]}")
return resp.json()
async def _brand_publish_theme(brand_id: str, theme: Dict[str, Any], theme_version: Optional[str]) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
f"{BRAND_REGISTRY_URL}/brands/{brand_id}/themes",
json={"theme": theme, "theme_version": theme_version},
)
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"Brand publish error: {resp.text[:200]}")
return resp.json()
async def _brand_get_latest(brand_id: str) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(f"{BRAND_REGISTRY_URL}/brands/{brand_id}/latest")
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"Brand latest error: {resp.text[:200]}")
return resp.json()
async def _brand_get_theme(brand_id: str, theme_version: str) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(f"{BRAND_REGISTRY_URL}/brands/{brand_id}/themes/{theme_version}")
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"Brand get error: {resp.text[:200]}")
return resp.json()
async def _presentation_render(slidespec: Dict[str, Any], brand_id: str, theme_version: str) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=30.0) as client:
# Call presentation-renderer directly
resp = await client.post(
f"{PRESENTATION_RENDERER_URL}/present/render",
json={
"brand_id": brand_id,
"theme_version": theme_version or "v1.0.0",
"slidespec": slidespec,
"output": "pptx"
},
)
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"Presentation render error: {resp.text[:200]}")
result = resp.json()
# Return consistent response format
return {
"render_id": result.get("render_id"),
"artifact_id": result.get("render_id"), # Use render_id as artifact_id for now
"job_id": result.get("render_id"),
"status": result.get("status"),
"brand_id": brand_id,
"theme_version": theme_version
}
def _sanitize_error_text(text: str) -> str:
if not text:
return ""
sanitized = text
sanitized = re.sub(r"https?://\\S+", "[url]", sanitized)
sanitized = re.sub(r"[A-Za-z0-9_-]{20,}", "[token]", sanitized)
return sanitized[:400]
def _can_access_artifact(acl_ref: Optional[str], dao_id: Optional[str], user_id: str) -> bool:
if not acl_ref:
return True
acl = acl_ref.lower()
if "public" in acl:
return True
if dao_id and str(dao_id).lower() in acl:
return True
if user_id and user_id.lower() in acl:
return True
return False
async def _artifact_get(artifact_id: str) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(f"{ARTIFACT_REGISTRY_URL}/artifacts/{artifact_id}")
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"Artifact get error: {resp.text[:200]}")
return resp.json()
async def _artifact_versions(artifact_id: str) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(f"{ARTIFACT_REGISTRY_URL}/artifacts/{artifact_id}/versions")
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"Artifact versions error: {resp.text[:200]}")
return resp.json()
async def _artifact_job_status(job_id: str) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(f"{ARTIFACT_REGISTRY_URL}/jobs/{job_id}")
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"Job status error: {resp.text[:200]}")
return resp.json()
def _format_job_status_message(job: Dict[str, Any], job_id: str) -> str:
status = job.get("status")
job_type = job.get("job_type")
artifact_id = job.get("artifact_id")
input_version_id = job.get("input_version_id")
output_version_id = job.get("output_version_id")
error_text = _sanitize_error_text(job.get("error_text", ""))
meta = job.get("meta_json") or {}
if isinstance(meta, str):
try:
meta = json.loads(meta)
except Exception:
meta = {}
meta_bits = []
for key in ["chunks_count", "parser_version", "chunker_version", "index_fingerprint", "fingerprint", "parsed_version_id", "chunks_version_id"]:
if meta.get(key):
value = meta.get(key)
if key in {"fingerprint", "index_fingerprint"}:
value = str(value)[:16] + ""
meta_bits.append(f"{key}: {value}")
message = (
"📌 **Статус job**\n\n"
f"Status: `{status}`\n"
f"Type: `{job_type}`\n"
f"Job ID: `{job_id}`\n"
f"Artifact ID: `{artifact_id}`\n"
f"Input version: `{input_version_id}`"
)
if output_version_id:
message += f"\nOutput version: `{output_version_id}`"
if meta_bits:
message += "\nMeta: " + "; ".join(meta_bits)
if status == "failed" and error_text:
message += f"\nПомилка: `{error_text}`"
return message
async def _artifact_download(artifact_id: str, fmt: str) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(f"{ARTIFACT_REGISTRY_URL}/artifacts/{artifact_id}/download", params={"format": fmt})
if resp.status_code >= 400:
raise HTTPException(status_code=resp.status_code, detail=resp.text[:200])
return resp.json()
async def _artifact_create_job(artifact_id: str, job_type: str, input_version_id: str) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
f"{ARTIFACT_REGISTRY_URL}/artifacts/{artifact_id}/jobs",
json={"job_type": job_type, "input_version_id": input_version_id},
)
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"Create job error: {resp.text[:200]}")
return resp.json()
async def _artifact_create(payload: Dict[str, Any]) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(f"{ARTIFACT_REGISTRY_URL}/artifacts", json=payload)
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"Artifact create error: {resp.text[:200]}")
return resp.json()
async def _artifact_add_version_from_url(artifact_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(f"{ARTIFACT_REGISTRY_URL}/artifacts/{artifact_id}/versions/from_url", json=payload)
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"Artifact version error: {resp.text[:200]}")
return resp.json()
async def _artifact_job_done(job_id: str, note: str) -> None:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(f"{ARTIFACT_REGISTRY_URL}/jobs/{job_id}/done", json={"note": note})
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"Job done error: {resp.text[:200]}")
async def send_telegram_message(chat_id: str, text: str, bot_token: Optional[str] = None) -> bool:
"""Send message to Telegram chat with explicit error diagnostics."""
telegram_token = bot_token or os.getenv("TELEGRAM_BOT_TOKEN")
if not telegram_token:
logger.error("TELEGRAM_BOT_TOKEN not set")
return False
# Defensive cleanup for occasional reasoning/markup leaks.
safe_text = re.sub(r'<think>.*?</think>', '', text or "", flags=re.DOTALL)
safe_text = re.sub(r'<think>.*$', '', safe_text, flags=re.DOTALL)
safe_text = safe_text.strip() or "..."
token_id = telegram_token.split(":", 1)[0] if ":" in telegram_token else "unknown"
url = f"https://api.telegram.org/bot{telegram_token}/sendMessage"
async def _send_chunk(chunk: str) -> bool:
payload = {
"chat_id": str(chat_id),
"text": chunk,
"disable_web_page_preview": True,
}
try:
async with httpx.AsyncClient() as client:
response = await client.post(url, json=payload, timeout=15.0)
if response.status_code >= 400:
err_desc = response.text[:300]
try:
body = response.json()
err_desc = body.get("description") or err_desc
except Exception:
pass
logger.error(
"Telegram sendMessage failed: bot_id=%s chat_id=%s status=%s desc=%s",
token_id,
chat_id,
response.status_code,
err_desc,
)
return False
return True
except Exception as e:
logger.error("Telegram sendMessage exception: bot_id=%s chat_id=%s error=%s", token_id, chat_id, e)
return False
all_ok = True
chunks = _chunk_text(safe_text, max_len=TELEGRAM_MAX_MESSAGE_LENGTH)
for chunk in chunks:
sent = await _send_chunk(chunk)
all_ok = all_ok and sent
if all_ok:
logger.info("Telegram message sent: bot_id=%s chat_id=%s chunks=%s", token_id, chat_id, len(chunks))
return all_ok
# ========================================
# Helion Telegram Webhook
# ========================================
@router.post("/helion/telegram/webhook")
async def helion_telegram_webhook(update: TelegramUpdate):
"""
Handle Telegram webhook for Helion agent.
"""
try:
return await handle_telegram_webhook(HELION_CONFIG, update)
except Exception as e:
logger.error(f"Error handling Helion Telegram webhook: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ========================================
# GREENFOOD Telegram Webhook
# ========================================
@router.post("/greenfood/telegram/webhook")
async def greenfood_telegram_webhook(update: TelegramUpdate):
"""
Handle Telegram webhook for GREENFOOD agent.
"""
try:
return await handle_telegram_webhook(GREENFOOD_CONFIG, update)
except Exception as e:
logger.error(f"Error handling GREENFOOD Telegram webhook: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ========================================
# NUTRA Telegram Webhook
# ========================================
@router.post("/nutra/telegram/webhook")
async def nutra_telegram_webhook(update: TelegramUpdate):
"""
Handle Telegram webhook for NUTRA agent.
"""
try:
return await handle_telegram_webhook(NUTRA_CONFIG, update)
except Exception as e:
logger.error(f"Error handling NUTRA Telegram webhook: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# Legacy handler was removed.
# Keep a tiny sentinel for imports/tests that may still reference it.
async def _old_helion_telegram_webhook(update: TelegramUpdate):
logger.warning("Deprecated handler _old_helion_telegram_webhook invoked; redirecting to unified handler")
return await handle_telegram_webhook(HELION_CONFIG, update)
@router.get("/health")
async def health():
"""Health check endpoint"""
agents_info = {}
for agent_id, config in AGENT_REGISTRY.items():
agents_info[agent_id] = {
"name": config.name,
"prompt_loaded": len(config.system_prompt) > 0,
"telegram_token_configured": config.get_telegram_token() is not None
}
return {
"status": "healthy",
"agents": agents_info,
"agents_count": len(AGENT_REGISTRY),
"timestamp": datetime.utcnow().isoformat(),
}
@router.post("/debug/agent_ping")
async def debug_agent_ping(request: dict = None):
"""
E2E probe endpoint - tests full agent pipeline.
Used by agent-e2e-prober for monitoring.
Returns success only if router responds.
"""
import time
start = time.time()
try:
# Test 1: Check router connectivity
router_url = os.getenv("ROUTER_URL", "http://router:8000")
async with httpx.AsyncClient(timeout=10.0) as client:
router_resp = await client.get(f"{router_url}/health")
router_ok = router_resp.status_code == 200
# Test 2: Check memory service connectivity
memory_url = os.getenv("MEMORY_SERVICE_URL", "http://memory-service:8000")
async with httpx.AsyncClient(timeout=10.0) as client:
memory_resp = await client.get(f"{memory_url}/health")
memory_ok = memory_resp.status_code == 200
latency = time.time() - start
return {
"success": router_ok and memory_ok,
"latency_seconds": round(latency, 3),
"checks": {
"router": router_ok,
"memory_service": memory_ok,
},
"timestamp": datetime.utcnow().isoformat(),
}
except Exception as e:
return {
"success": False,
"error": str(e)[:100],
"latency_seconds": round(time.time() - start, 3),
"timestamp": datetime.utcnow().isoformat(),
}