From 3a1ecd7adcfc5f0f40e123df0520abe312ea2868 Mon Sep 17 00:00:00 2001 From: lidf Date: Wed, 29 Apr 2026 02:24:04 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A1=A5=E5=85=A8=20vendor=20=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=EF=BC=88model=5Ftools/utils/toolsets/gateway/cron/her?= =?UTF-8?q?mes=5Ftime=EF=BC=89+=20python-dotenv=20=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vendor_hermes.sh: 补全遗漏的单文件模块和目录 - pyproject.toml: 添加 python-dotenv 隐性依赖 - 全量 import 测试通过(216 .py, 180K 行) - firecrawl/fal_client 为可选工具,缺失时优雅跳过 --- mindcli/_vendor/HERMES_COMMIT | 1 - mindcli/_vendor/VENDOR_COMMIT | 7 +- mindcli/_vendor/cron/__init__.py | 42 + mindcli/_vendor/cron/jobs.py | 762 ++ mindcli/_vendor/cron/scheduler.py | 992 ++ mindcli/_vendor/gateway/__init__.py | 35 + .../_vendor/gateway/builtin_hooks/__init__.py | 1 + .../_vendor/gateway/builtin_hooks/boot_md.py | 85 + mindcli/_vendor/gateway/channel_directory.py | 276 + mindcli/_vendor/gateway/config.py | 1160 +++ mindcli/_vendor/gateway/delivery.py | 256 + mindcli/_vendor/gateway/display_config.py | 187 + mindcli/_vendor/gateway/hooks.py | 170 + mindcli/_vendor/gateway/mirror.py | 132 + mindcli/_vendor/gateway/pairing.py | 309 + .../gateway/platforms/ADDING_A_PLATFORM.md | 313 + mindcli/_vendor/gateway/platforms/__init__.py | 19 + .../_vendor/gateway/platforms/api_server.py | 1904 ++++ mindcli/_vendor/gateway/platforms/base.py | 2071 ++++ .../_vendor/gateway/platforms/bluebubbles.py | 897 ++ .../gateway/platforms/dashscope_realtime.py | 359 + .../gateway/platforms/deepview_materials.py | 425 + .../_vendor/gateway/platforms/deepview_sse.py | 1485 +++ mindcli/_vendor/gateway/platforms/dingtalk.py | 333 + mindcli/_vendor/gateway/platforms/discord.py | 2963 ++++++ .../_vendor/gateway/platforms/doc_parser.py | 335 + mindcli/_vendor/gateway/platforms/email.py | 625 ++ mindcli/_vendor/gateway/platforms/feishu.py | 3950 ++++++++ .../_vendor/gateway/platforms/flash_asr.py | 193 + mindcli/_vendor/gateway/platforms/helpers.py | 261 + .../gateway/platforms/homeassistant.py | 449 + mindcli/_vendor/gateway/platforms/matrix.py | 2015 ++++ .../_vendor/gateway/platforms/mattermost.py | 733 ++ .../_vendor/gateway/platforms/md_converter.py | 113 + .../gateway/platforms/mindcli_bridge.py | 249 + .../_vendor/gateway/platforms/mindos_sse.py | 1589 +++ mindcli/_vendor/gateway/platforms/qqbot.py | 1960 ++++ mindcli/_vendor/gateway/platforms/signal.py | 825 ++ mindcli/_vendor/gateway/platforms/slack.py | 1670 +++ mindcli/_vendor/gateway/platforms/sms.py | 373 + mindcli/_vendor/gateway/platforms/telegram.py | 2814 +++++ .../gateway/platforms/telegram_network.py | 246 + .../gateway/platforms/voice2md_atoms.py | 136 + .../_vendor/gateway/platforms/voice_import.py | 99 + mindcli/_vendor/gateway/platforms/webhook.py | 672 ++ mindcli/_vendor/gateway/platforms/wecom.py | 1430 +++ .../gateway/platforms/wecom_callback.py | 387 + .../_vendor/gateway/platforms/wecom_crypto.py | 142 + mindcli/_vendor/gateway/platforms/weixin.py | 1829 ++++ mindcli/_vendor/gateway/platforms/whatsapp.py | 989 ++ mindcli/_vendor/gateway/restart.py | 20 + mindcli/_vendor/gateway/run.py | 9015 +++++++++++++++++ mindcli/_vendor/gateway/session.py | 1086 ++ mindcli/_vendor/gateway/session_context.py | 146 + mindcli/_vendor/gateway/status.py | 439 + mindcli/_vendor/gateway/sticker_cache.py | 111 + mindcli/_vendor/gateway/stream_consumer.py | 744 ++ mindcli/_vendor/hermes_state.py | 188 +- mindcli/_vendor/hermes_time.py | 104 + mindcli/_vendor/model_tools.py | 601 ++ mindcli/_vendor/run_agent.py | 16 +- mindcli/_vendor/tools/cli_tunnel_tool.py | 324 + mindcli/_vendor/toolset_distributions.py | 364 + mindcli/_vendor/toolsets.py | 661 ++ mindcli/_vendor/utils.py | 164 + pyproject.toml | 2 + scripts/vendor_hermes.sh | 39 +- 67 files changed, 53228 insertions(+), 64 deletions(-) delete mode 100644 mindcli/_vendor/HERMES_COMMIT create mode 100644 mindcli/_vendor/cron/__init__.py create mode 100644 mindcli/_vendor/cron/jobs.py create mode 100644 mindcli/_vendor/cron/scheduler.py create mode 100644 mindcli/_vendor/gateway/__init__.py create mode 100644 mindcli/_vendor/gateway/builtin_hooks/__init__.py create mode 100644 mindcli/_vendor/gateway/builtin_hooks/boot_md.py create mode 100644 mindcli/_vendor/gateway/channel_directory.py create mode 100644 mindcli/_vendor/gateway/config.py create mode 100644 mindcli/_vendor/gateway/delivery.py create mode 100644 mindcli/_vendor/gateway/display_config.py create mode 100644 mindcli/_vendor/gateway/hooks.py create mode 100644 mindcli/_vendor/gateway/mirror.py create mode 100644 mindcli/_vendor/gateway/pairing.py create mode 100644 mindcli/_vendor/gateway/platforms/ADDING_A_PLATFORM.md create mode 100644 mindcli/_vendor/gateway/platforms/__init__.py create mode 100644 mindcli/_vendor/gateway/platforms/api_server.py create mode 100644 mindcli/_vendor/gateway/platforms/base.py create mode 100644 mindcli/_vendor/gateway/platforms/bluebubbles.py create mode 100644 mindcli/_vendor/gateway/platforms/dashscope_realtime.py create mode 100644 mindcli/_vendor/gateway/platforms/deepview_materials.py create mode 100644 mindcli/_vendor/gateway/platforms/deepview_sse.py create mode 100644 mindcli/_vendor/gateway/platforms/dingtalk.py create mode 100644 mindcli/_vendor/gateway/platforms/discord.py create mode 100644 mindcli/_vendor/gateway/platforms/doc_parser.py create mode 100644 mindcli/_vendor/gateway/platforms/email.py create mode 100644 mindcli/_vendor/gateway/platforms/feishu.py create mode 100644 mindcli/_vendor/gateway/platforms/flash_asr.py create mode 100644 mindcli/_vendor/gateway/platforms/helpers.py create mode 100644 mindcli/_vendor/gateway/platforms/homeassistant.py create mode 100644 mindcli/_vendor/gateway/platforms/matrix.py create mode 100644 mindcli/_vendor/gateway/platforms/mattermost.py create mode 100644 mindcli/_vendor/gateway/platforms/md_converter.py create mode 100644 mindcli/_vendor/gateway/platforms/mindcli_bridge.py create mode 100644 mindcli/_vendor/gateway/platforms/mindos_sse.py create mode 100644 mindcli/_vendor/gateway/platforms/qqbot.py create mode 100644 mindcli/_vendor/gateway/platforms/signal.py create mode 100644 mindcli/_vendor/gateway/platforms/slack.py create mode 100644 mindcli/_vendor/gateway/platforms/sms.py create mode 100644 mindcli/_vendor/gateway/platforms/telegram.py create mode 100644 mindcli/_vendor/gateway/platforms/telegram_network.py create mode 100644 mindcli/_vendor/gateway/platforms/voice2md_atoms.py create mode 100644 mindcli/_vendor/gateway/platforms/voice_import.py create mode 100644 mindcli/_vendor/gateway/platforms/webhook.py create mode 100644 mindcli/_vendor/gateway/platforms/wecom.py create mode 100644 mindcli/_vendor/gateway/platforms/wecom_callback.py create mode 100644 mindcli/_vendor/gateway/platforms/wecom_crypto.py create mode 100644 mindcli/_vendor/gateway/platforms/weixin.py create mode 100644 mindcli/_vendor/gateway/platforms/whatsapp.py create mode 100644 mindcli/_vendor/gateway/restart.py create mode 100644 mindcli/_vendor/gateway/run.py create mode 100644 mindcli/_vendor/gateway/session.py create mode 100644 mindcli/_vendor/gateway/session_context.py create mode 100644 mindcli/_vendor/gateway/status.py create mode 100644 mindcli/_vendor/gateway/sticker_cache.py create mode 100644 mindcli/_vendor/gateway/stream_consumer.py create mode 100644 mindcli/_vendor/hermes_time.py create mode 100644 mindcli/_vendor/model_tools.py create mode 100644 mindcli/_vendor/tools/cli_tunnel_tool.py create mode 100644 mindcli/_vendor/toolset_distributions.py create mode 100644 mindcli/_vendor/toolsets.py create mode 100644 mindcli/_vendor/utils.py diff --git a/mindcli/_vendor/HERMES_COMMIT b/mindcli/_vendor/HERMES_COMMIT deleted file mode 100644 index 9c4019f..0000000 --- a/mindcli/_vendor/HERMES_COMMIT +++ /dev/null @@ -1 +0,0 @@ -16f9d020 diff --git a/mindcli/_vendor/VENDOR_COMMIT b/mindcli/_vendor/VENDOR_COMMIT index a5e5ecf..372506e 100644 --- a/mindcli/_vendor/VENDOR_COMMIT +++ b/mindcli/_vendor/VENDOR_COMMIT @@ -1,8 +1,5 @@ # MindOS CLI Vendor Snapshot -# 此目录包含从 Hermes 主仓打包的快照副本 -# 运行时只 import 此副本,不依赖用户环境中的 hermes - -source: mindOSv2/hermes -commit: f8a855a +source: hermes +commit: 16f9d020 snapshot_date: 2026-04-29 snapshot_by: vendor_hermes.sh diff --git a/mindcli/_vendor/cron/__init__.py b/mindcli/_vendor/cron/__init__.py new file mode 100644 index 0000000..2c44cab --- /dev/null +++ b/mindcli/_vendor/cron/__init__.py @@ -0,0 +1,42 @@ +""" +Cron job scheduling system for Hermes Agent. + +This module provides scheduled task execution, allowing the agent to: +- Run automated tasks on schedules (cron expressions, intervals, one-shot) +- Self-schedule reminders and follow-up tasks +- Execute tasks in isolated sessions (no prior context) + +Cron jobs are executed automatically by the gateway daemon: + hermes gateway install # Install as a user service + sudo hermes gateway install --system # Linux servers: boot-time system service + hermes gateway # Or run in foreground + +The gateway ticks the scheduler every 60 seconds. A file lock prevents +duplicate execution if multiple processes overlap. +""" + +from cron.jobs import ( + create_job, + get_job, + list_jobs, + remove_job, + update_job, + pause_job, + resume_job, + trigger_job, + JOBS_FILE, +) +from cron.scheduler import tick + +__all__ = [ + "create_job", + "get_job", + "list_jobs", + "remove_job", + "update_job", + "pause_job", + "resume_job", + "trigger_job", + "tick", + "JOBS_FILE", +] diff --git a/mindcli/_vendor/cron/jobs.py b/mindcli/_vendor/cron/jobs.py new file mode 100644 index 0000000..47e0b66 --- /dev/null +++ b/mindcli/_vendor/cron/jobs.py @@ -0,0 +1,762 @@ +""" +Cron job storage and management. + +Jobs are stored in ~/.hermes/cron/jobs.json +Output is saved to ~/.hermes/cron/output/{job_id}/{timestamp}.md +""" + +import copy +import json +import logging +import tempfile +import os +import re +import uuid +from datetime import datetime, timedelta +from pathlib import Path +from hermes_constants import get_hermes_home +from typing import Optional, Dict, List, Any + +logger = logging.getLogger(__name__) + +from hermes_time import now as _hermes_now + +try: + from croniter import croniter + HAS_CRONITER = True +except ImportError: + HAS_CRONITER = False + +# ============================================================================= +# Configuration +# ============================================================================= + +HERMES_DIR = get_hermes_home().resolve() +CRON_DIR = HERMES_DIR / "cron" +JOBS_FILE = CRON_DIR / "jobs.json" +OUTPUT_DIR = CRON_DIR / "output" +ONESHOT_GRACE_SECONDS = 120 + + +def _normalize_skill_list(skill: Optional[str] = None, skills: Optional[Any] = None) -> List[str]: + """Normalize legacy/single-skill and multi-skill inputs into a unique ordered list.""" + if skills is None: + raw_items = [skill] if skill else [] + elif isinstance(skills, str): + raw_items = [skills] + else: + raw_items = list(skills) + + normalized: List[str] = [] + for item in raw_items: + text = str(item or "").strip() + if text and text not in normalized: + normalized.append(text) + return normalized + + +def _apply_skill_fields(job: Dict[str, Any]) -> Dict[str, Any]: + """Return a job dict with canonical `skills` and legacy `skill` fields aligned.""" + normalized = dict(job) + skills = _normalize_skill_list(normalized.get("skill"), normalized.get("skills")) + normalized["skills"] = skills + normalized["skill"] = skills[0] if skills else None + return normalized + + +def _secure_dir(path: Path): + """Set directory to owner-only access (0700). No-op on Windows.""" + try: + os.chmod(path, 0o700) + except (OSError, NotImplementedError): + pass # Windows or other platforms where chmod is not supported + + +def _secure_file(path: Path): + """Set file to owner-only read/write (0600). No-op on Windows.""" + try: + if path.exists(): + os.chmod(path, 0o600) + except (OSError, NotImplementedError): + pass + + +def ensure_dirs(): + """Ensure cron directories exist with secure permissions.""" + CRON_DIR.mkdir(parents=True, exist_ok=True) + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + _secure_dir(CRON_DIR) + _secure_dir(OUTPUT_DIR) + + +# ============================================================================= +# Schedule Parsing +# ============================================================================= + +def parse_duration(s: str) -> int: + """ + Parse duration string into minutes. + + Examples: + "30m" → 30 + "2h" → 120 + "1d" → 1440 + """ + s = s.strip().lower() + match = re.match(r'^(\d+)\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$', s) + if not match: + raise ValueError(f"Invalid duration: '{s}'. Use format like '30m', '2h', or '1d'") + + value = int(match.group(1)) + unit = match.group(2)[0] # First char: m, h, or d + + multipliers = {'m': 1, 'h': 60, 'd': 1440} + return value * multipliers[unit] + + +def parse_schedule(schedule: str) -> Dict[str, Any]: + """ + Parse schedule string into structured format. + + Returns dict with: + - kind: "once" | "interval" | "cron" + - For "once": "run_at" (ISO timestamp) + - For "interval": "minutes" (int) + - For "cron": "expr" (cron expression) + + Examples: + "30m" → once in 30 minutes + "2h" → once in 2 hours + "every 30m" → recurring every 30 minutes + "every 2h" → recurring every 2 hours + "0 9 * * *" → cron expression + "2026-02-03T14:00" → once at timestamp + """ + schedule = schedule.strip() + original = schedule + schedule_lower = schedule.lower() + + # "every X" pattern → recurring interval + if schedule_lower.startswith("every "): + duration_str = schedule[6:].strip() + minutes = parse_duration(duration_str) + return { + "kind": "interval", + "minutes": minutes, + "display": f"every {minutes}m" + } + + # Check for cron expression (5 or 6 space-separated fields) + # Cron fields: minute hour day month weekday [year] + parts = schedule.split() + if len(parts) >= 5 and all( + re.match(r'^[\d\*\-,/]+$', p) for p in parts[:5] + ): + if not HAS_CRONITER: + raise ValueError("Cron expressions require 'croniter' package. Install with: pip install croniter") + # Validate cron expression + try: + croniter(schedule) + except Exception as e: + raise ValueError(f"Invalid cron expression '{schedule}': {e}") + return { + "kind": "cron", + "expr": schedule, + "display": schedule + } + + # ISO timestamp (contains T or looks like date) + if 'T' in schedule or re.match(r'^\d{4}-\d{2}-\d{2}', schedule): + try: + # Parse and validate + dt = datetime.fromisoformat(schedule.replace('Z', '+00:00')) + # Make naive timestamps timezone-aware at parse time so the stored + # value doesn't depend on the system timezone matching at check time. + if dt.tzinfo is None: + dt = dt.astimezone() # Interpret as local timezone + return { + "kind": "once", + "run_at": dt.isoformat(), + "display": f"once at {dt.strftime('%Y-%m-%d %H:%M')}" + } + except ValueError as e: + raise ValueError(f"Invalid timestamp '{schedule}': {e}") + + # Duration like "30m", "2h", "1d" → one-shot from now + try: + minutes = parse_duration(schedule) + run_at = _hermes_now() + timedelta(minutes=minutes) + return { + "kind": "once", + "run_at": run_at.isoformat(), + "display": f"once in {original}" + } + except ValueError: + pass + + raise ValueError( + f"Invalid schedule '{original}'. Use:\n" + f" - Duration: '30m', '2h', '1d' (one-shot)\n" + f" - Interval: 'every 30m', 'every 2h' (recurring)\n" + f" - Cron: '0 9 * * *' (cron expression)\n" + f" - Timestamp: '2026-02-03T14:00:00' (one-shot at time)" + ) + + +def _ensure_aware(dt: datetime) -> datetime: + """Return a timezone-aware datetime in Hermes configured timezone. + + Backward compatibility: + - Older stored timestamps may be naive. + - Naive values are interpreted as *system-local wall time* (the timezone + `datetime.now()` used when they were created), then converted to the + configured Hermes timezone. + + This preserves relative ordering for legacy naive timestamps across + timezone changes and avoids false not-due results. + """ + target_tz = _hermes_now().tzinfo + if dt.tzinfo is None: + local_tz = datetime.now().astimezone().tzinfo + return dt.replace(tzinfo=local_tz).astimezone(target_tz) + return dt.astimezone(target_tz) + + +def _recoverable_oneshot_run_at( + schedule: Dict[str, Any], + now: datetime, + *, + last_run_at: Optional[str] = None, +) -> Optional[str]: + """Return a one-shot run time if it is still eligible to fire. + + One-shot jobs get a small grace window so jobs created a few seconds after + their requested minute still run on the next tick. Once a one-shot has + already run, it is never eligible again. + """ + if schedule.get("kind") != "once": + return None + if last_run_at: + return None + + run_at = schedule.get("run_at") + if not run_at: + return None + + run_at_dt = _ensure_aware(datetime.fromisoformat(run_at)) + if run_at_dt >= now - timedelta(seconds=ONESHOT_GRACE_SECONDS): + return run_at + return None + + +def _compute_grace_seconds(schedule: dict) -> int: + """Compute how late a job can be and still catch up instead of fast-forwarding. + + Uses half the schedule period, clamped between 120 seconds and 2 hours. + This ensures daily jobs can catch up if missed by up to 2 hours, + while frequent jobs (every 5-10 min) still fast-forward quickly. + """ + MIN_GRACE = 120 + MAX_GRACE = 7200 # 2 hours + + kind = schedule.get("kind") + + if kind == "interval": + period_seconds = schedule.get("minutes", 1) * 60 + grace = period_seconds // 2 + return max(MIN_GRACE, min(grace, MAX_GRACE)) + + if kind == "cron" and HAS_CRONITER: + try: + now = _hermes_now() + cron = croniter(schedule["expr"], now) + first = cron.get_next(datetime) + second = cron.get_next(datetime) + period_seconds = int((second - first).total_seconds()) + grace = period_seconds // 2 + return max(MIN_GRACE, min(grace, MAX_GRACE)) + except Exception: + pass + + return MIN_GRACE + + +def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None) -> Optional[str]: + """ + Compute the next run time for a schedule. + + Returns ISO timestamp string, or None if no more runs. + """ + now = _hermes_now() + + if schedule["kind"] == "once": + return _recoverable_oneshot_run_at(schedule, now, last_run_at=last_run_at) + + elif schedule["kind"] == "interval": + minutes = schedule["minutes"] + if last_run_at: + # Next run is last_run + interval + last = _ensure_aware(datetime.fromisoformat(last_run_at)) + next_run = last + timedelta(minutes=minutes) + else: + # First run is now + interval + next_run = now + timedelta(minutes=minutes) + return next_run.isoformat() + + elif schedule["kind"] == "cron": + if not HAS_CRONITER: + return None + cron = croniter(schedule["expr"], now) + next_run = cron.get_next(datetime) + return next_run.isoformat() + + return None + + +# ============================================================================= +# Job CRUD Operations +# ============================================================================= + +def load_jobs() -> List[Dict[str, Any]]: + """Load all jobs from storage.""" + ensure_dirs() + if not JOBS_FILE.exists(): + return [] + + try: + with open(JOBS_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + return data.get("jobs", []) + except json.JSONDecodeError: + # Retry with strict=False to handle bare control chars in string values + try: + with open(JOBS_FILE, 'r', encoding='utf-8') as f: + data = json.loads(f.read(), strict=False) + jobs = data.get("jobs", []) + if jobs: + # Auto-repair: rewrite with proper escaping + save_jobs(jobs) + logger.warning("Auto-repaired jobs.json (had invalid control characters)") + return jobs + except Exception as e: + logger.error("Failed to auto-repair jobs.json: %s", e) + raise RuntimeError(f"Cron database corrupted and unrepairable: {e}") from e + except IOError as e: + logger.error("IOError reading jobs.json: %s", e) + raise RuntimeError(f"Failed to read cron database: {e}") from e + + +def save_jobs(jobs: List[Dict[str, Any]]): + """Save all jobs to storage.""" + ensure_dirs() + fd, tmp_path = tempfile.mkstemp(dir=str(JOBS_FILE.parent), suffix='.tmp', prefix='.jobs_') + try: + with os.fdopen(fd, 'w', encoding='utf-8') as f: + json.dump({"jobs": jobs, "updated_at": _hermes_now().isoformat()}, f, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, JOBS_FILE) + _secure_file(JOBS_FILE) + except BaseException: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + +def create_job( + prompt: str, + schedule: str, + name: Optional[str] = None, + repeat: Optional[int] = None, + deliver: Optional[str] = None, + origin: Optional[Dict[str, Any]] = None, + skill: Optional[str] = None, + skills: Optional[List[str]] = None, + model: Optional[str] = None, + provider: Optional[str] = None, + base_url: Optional[str] = None, + script: Optional[str] = None, +) -> Dict[str, Any]: + """ + Create a new cron job. + + Args: + prompt: The prompt to run (must be self-contained, or a task instruction when skill is set) + schedule: Schedule string (see parse_schedule) + name: Optional friendly name + repeat: How many times to run (None = forever, 1 = once) + deliver: Where to deliver output ("origin", "local", "telegram", etc.) + origin: Source info where job was created (for "origin" delivery) + skill: Optional legacy single skill name to load before running the prompt + skills: Optional ordered list of skills to load before running the prompt + model: Optional per-job model override + provider: Optional per-job provider override + base_url: Optional per-job base URL override + script: Optional path to a Python script whose stdout is injected into the + prompt each run. The script runs before the agent turn, and its output + is prepended as context. Useful for data collection / change detection. + + Returns: + The created job dict + """ + parsed_schedule = parse_schedule(schedule) + + # Normalize repeat: treat 0 or negative values as None (infinite) + if repeat is not None and repeat <= 0: + repeat = None + + # Auto-set repeat=1 for one-shot schedules if not specified + if parsed_schedule["kind"] == "once" and repeat is None: + repeat = 1 + + # Default delivery to origin if available, otherwise local + if deliver is None: + deliver = "origin" if origin else "local" + + job_id = uuid.uuid4().hex[:12] + now = _hermes_now().isoformat() + + normalized_skills = _normalize_skill_list(skill, skills) + normalized_model = str(model).strip() if isinstance(model, str) else None + normalized_provider = str(provider).strip() if isinstance(provider, str) else None + normalized_base_url = str(base_url).strip().rstrip("/") if isinstance(base_url, str) else None + normalized_model = normalized_model or None + normalized_provider = normalized_provider or None + normalized_base_url = normalized_base_url or None + normalized_script = str(script).strip() if isinstance(script, str) else None + normalized_script = normalized_script or None + + label_source = (prompt or (normalized_skills[0] if normalized_skills else None)) or "cron job" + job = { + "id": job_id, + "name": name or label_source[:50].strip(), + "prompt": prompt, + "skills": normalized_skills, + "skill": normalized_skills[0] if normalized_skills else None, + "model": normalized_model, + "provider": normalized_provider, + "base_url": normalized_base_url, + "script": normalized_script, + "schedule": parsed_schedule, + "schedule_display": parsed_schedule.get("display", schedule), + "repeat": { + "times": repeat, # None = forever + "completed": 0 + }, + "enabled": True, + "state": "scheduled", + "paused_at": None, + "paused_reason": None, + "created_at": now, + "next_run_at": compute_next_run(parsed_schedule), + "last_run_at": None, + "last_status": None, + "last_error": None, + "last_delivery_error": None, + # Delivery configuration + "deliver": deliver, + "origin": origin, # Tracks where job was created for "origin" delivery + } + + jobs = load_jobs() + jobs.append(job) + save_jobs(jobs) + + return job + + +def get_job(job_id: str) -> Optional[Dict[str, Any]]: + """Get a job by ID.""" + jobs = load_jobs() + for job in jobs: + if job["id"] == job_id: + return _apply_skill_fields(job) + return None + + +def list_jobs(include_disabled: bool = False) -> List[Dict[str, Any]]: + """List all jobs, optionally including disabled ones.""" + jobs = [_apply_skill_fields(j) for j in load_jobs()] + if not include_disabled: + jobs = [j for j in jobs if j.get("enabled", True)] + return jobs + + +def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Update a job by ID, refreshing derived schedule fields when needed.""" + jobs = load_jobs() + for i, job in enumerate(jobs): + if job["id"] != job_id: + continue + + updated = _apply_skill_fields({**job, **updates}) + schedule_changed = "schedule" in updates + + if "skills" in updates or "skill" in updates: + normalized_skills = _normalize_skill_list(updated.get("skill"), updated.get("skills")) + updated["skills"] = normalized_skills + updated["skill"] = normalized_skills[0] if normalized_skills else None + + if schedule_changed: + updated_schedule = updated["schedule"] + updated["schedule_display"] = updates.get( + "schedule_display", + updated_schedule.get("display", updated.get("schedule_display")), + ) + if updated.get("state") != "paused": + updated["next_run_at"] = compute_next_run(updated_schedule) + + if updated.get("enabled", True) and updated.get("state") != "paused" and not updated.get("next_run_at"): + updated["next_run_at"] = compute_next_run(updated["schedule"]) + + jobs[i] = updated + save_jobs(jobs) + return _apply_skill_fields(jobs[i]) + return None + + +def pause_job(job_id: str, reason: Optional[str] = None) -> Optional[Dict[str, Any]]: + """Pause a job without deleting it.""" + return update_job( + job_id, + { + "enabled": False, + "state": "paused", + "paused_at": _hermes_now().isoformat(), + "paused_reason": reason, + }, + ) + + +def resume_job(job_id: str) -> Optional[Dict[str, Any]]: + """Resume a paused job and compute the next future run from now.""" + job = get_job(job_id) + if not job: + return None + + next_run_at = compute_next_run(job["schedule"]) + return update_job( + job_id, + { + "enabled": True, + "state": "scheduled", + "paused_at": None, + "paused_reason": None, + "next_run_at": next_run_at, + }, + ) + + +def trigger_job(job_id: str) -> Optional[Dict[str, Any]]: + """Schedule a job to run on the next scheduler tick.""" + job = get_job(job_id) + if not job: + return None + return update_job( + job_id, + { + "enabled": True, + "state": "scheduled", + "paused_at": None, + "paused_reason": None, + "next_run_at": _hermes_now().isoformat(), + }, + ) + + +def remove_job(job_id: str) -> bool: + """Remove a job by ID.""" + jobs = load_jobs() + original_len = len(jobs) + jobs = [j for j in jobs if j["id"] != job_id] + if len(jobs) < original_len: + save_jobs(jobs) + return True + return False + + +def mark_job_run(job_id: str, success: bool, error: Optional[str] = None, + delivery_error: Optional[str] = None): + """ + Mark a job as having been run. + + Updates last_run_at, last_status, increments completed count, + computes next_run_at, and auto-deletes if repeat limit reached. + + ``delivery_error`` is tracked separately from the agent error — a job + can succeed (agent produced output) but fail delivery (platform down). + """ + jobs = load_jobs() + for i, job in enumerate(jobs): + if job["id"] == job_id: + now = _hermes_now().isoformat() + job["last_run_at"] = now + job["last_status"] = "ok" if success else "error" + job["last_error"] = error if not success else None + # Track delivery failures separately — cleared on successful delivery + job["last_delivery_error"] = delivery_error + + # Increment completed count + if job.get("repeat"): + job["repeat"]["completed"] = job["repeat"].get("completed", 0) + 1 + + # Check if we've hit the repeat limit + times = job["repeat"].get("times") + completed = job["repeat"]["completed"] + if times is not None and times > 0 and completed >= times: + # Remove the job (limit reached) + jobs.pop(i) + save_jobs(jobs) + return + + # Compute next run + job["next_run_at"] = compute_next_run(job["schedule"], now) + + # If no next run (one-shot completed), disable + if job["next_run_at"] is None: + job["enabled"] = False + job["state"] = "completed" + elif job.get("state") != "paused": + job["state"] = "scheduled" + + save_jobs(jobs) + return + + logger.warning("mark_job_run: job_id %s not found, skipping save", job_id) + + +def advance_next_run(job_id: str) -> bool: + """Preemptively advance next_run_at for a recurring job before execution. + + Call this BEFORE run_job() so that if the process crashes mid-execution, + the job won't re-fire on the next gateway restart. This converts the + scheduler from at-least-once to at-most-once for recurring jobs — missing + one run is far better than firing dozens of times in a crash loop. + + One-shot jobs are left unchanged so they can still retry on restart. + + Returns True if next_run_at was advanced, False otherwise. + """ + jobs = load_jobs() + for job in jobs: + if job["id"] == job_id: + kind = job.get("schedule", {}).get("kind") + if kind not in ("cron", "interval"): + return False + now = _hermes_now().isoformat() + new_next = compute_next_run(job["schedule"], now) + if new_next and new_next != job.get("next_run_at"): + job["next_run_at"] = new_next + save_jobs(jobs) + return True + return False + return False + + +def get_due_jobs() -> List[Dict[str, Any]]: + """Get all jobs that are due to run now. + + For recurring jobs (cron/interval), if the scheduled time is stale + (more than one period in the past, e.g. because the gateway was down), + the job is fast-forwarded to the next future run instead of firing + immediately. This prevents a burst of missed jobs on gateway restart. + """ + now = _hermes_now() + raw_jobs = load_jobs() + jobs = [_apply_skill_fields(j) for j in copy.deepcopy(raw_jobs)] + due = [] + needs_save = False + + for job in jobs: + if not job.get("enabled", True): + continue + + next_run = job.get("next_run_at") + if not next_run: + recovered_next = _recoverable_oneshot_run_at( + job.get("schedule", {}), + now, + last_run_at=job.get("last_run_at"), + ) + if not recovered_next: + continue + + job["next_run_at"] = recovered_next + next_run = recovered_next + logger.info( + "Job '%s' had no next_run_at; recovering one-shot run at %s", + job.get("name", job["id"]), + recovered_next, + ) + for rj in raw_jobs: + if rj["id"] == job["id"]: + rj["next_run_at"] = recovered_next + needs_save = True + break + + next_run_dt = _ensure_aware(datetime.fromisoformat(next_run)) + if next_run_dt <= now: + schedule = job.get("schedule", {}) + kind = schedule.get("kind") + + # For recurring jobs, check if the scheduled time is stale + # (gateway was down and missed the window). Fast-forward to + # the next future occurrence instead of firing a stale run. + grace = _compute_grace_seconds(schedule) + if kind in ("cron", "interval") and (now - next_run_dt).total_seconds() > grace: + # Job is past its catch-up grace window — this is a stale missed run. + # Grace scales with schedule period: daily=2h, hourly=30m, 10min=5m. + new_next = compute_next_run(schedule, now.isoformat()) + if new_next: + logger.info( + "Job '%s' missed its scheduled time (%s, grace=%ds). " + "Fast-forwarding to next run: %s", + job.get("name", job["id"]), + next_run, + grace, + new_next, + ) + # Update the job in storage + for rj in raw_jobs: + if rj["id"] == job["id"]: + rj["next_run_at"] = new_next + needs_save = True + break + continue # Skip this run + + due.append(job) + + if needs_save: + save_jobs(raw_jobs) + + return due + + +def save_job_output(job_id: str, output: str): + """Save job output to file.""" + ensure_dirs() + job_output_dir = OUTPUT_DIR / job_id + job_output_dir.mkdir(parents=True, exist_ok=True) + _secure_dir(job_output_dir) + + timestamp = _hermes_now().strftime("%Y-%m-%d_%H-%M-%S") + output_file = job_output_dir / f"{timestamp}.md" + + fd, tmp_path = tempfile.mkstemp(dir=str(job_output_dir), suffix='.tmp', prefix='.output_') + try: + with os.fdopen(fd, 'w', encoding='utf-8') as f: + f.write(output) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, output_file) + _secure_file(output_file) + except BaseException: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + return output_file diff --git a/mindcli/_vendor/cron/scheduler.py b/mindcli/_vendor/cron/scheduler.py new file mode 100644 index 0000000..83b7abb --- /dev/null +++ b/mindcli/_vendor/cron/scheduler.py @@ -0,0 +1,992 @@ +""" +Cron job scheduler - executes due jobs. + +Provides tick() which checks for due jobs and runs them. The gateway +calls this every 60 seconds from a background thread. + +Uses a file-based lock (~/.hermes/cron/.tick.lock) so only one tick +runs at a time if multiple processes overlap. +""" + +import asyncio +import concurrent.futures +import json +import logging +import os +import subprocess +import sys + +# fcntl is Unix-only; on Windows use msvcrt for file locking +try: + import fcntl +except ImportError: + fcntl = None + try: + import msvcrt + except ImportError: + msvcrt = None +from pathlib import Path +from typing import Optional + +# Add parent directory to path for imports BEFORE repo-level imports. +# Without this, standalone invocations (e.g. after `hermes update` reloads +# the module) fail with ModuleNotFoundError for hermes_time et al. +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from hermes_constants import get_hermes_home +from hermes_cli.config import load_config +from hermes_time import now as _hermes_now + +logger = logging.getLogger(__name__) + +# Valid delivery platforms — used to validate user-supplied platform names +# in cron delivery targets, preventing env var enumeration via crafted names. +_KNOWN_DELIVERY_PLATFORMS = frozenset({ + "telegram", "discord", "slack", "whatsapp", "signal", + "matrix", "mattermost", "homeassistant", "dingtalk", "feishu", + "wecom", "wecom_callback", "weixin", "sms", "email", "webhook", "bluebubbles", + "qqbot", +}) + +from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run + +# Sentinel: when a cron agent has nothing new to report, it can start its +# response with this marker to suppress delivery. Output is still saved +# locally for audit. +SILENT_MARKER = "[SILENT]" + +# Resolve Hermes home directory (respects HERMES_HOME override) +_hermes_home = get_hermes_home() + +# File-based lock prevents concurrent ticks from gateway + daemon + systemd timer +_LOCK_DIR = _hermes_home / "cron" +_LOCK_FILE = _LOCK_DIR / ".tick.lock" + + +def _resolve_origin(job: dict) -> Optional[dict]: + """Extract origin info from a job, preserving any extra routing metadata.""" + origin = job.get("origin") + if not origin: + return None + platform = origin.get("platform") + chat_id = origin.get("chat_id") + if platform and chat_id: + return origin + return None + + +def _resolve_delivery_target(job: dict) -> Optional[dict]: + """Resolve the concrete auto-delivery target for a cron job, if any.""" + deliver = job.get("deliver", "local") + origin = _resolve_origin(job) + + if deliver == "local": + return None + + if deliver == "origin": + if origin: + return { + "platform": origin["platform"], + "chat_id": str(origin["chat_id"]), + "thread_id": origin.get("thread_id"), + } + # Origin missing (e.g. job created via API/script) — try each + # platform's home channel as a fallback instead of silently dropping. + for platform_name in ("matrix", "telegram", "discord", "slack", "bluebubbles"): + chat_id = os.getenv(f"{platform_name.upper()}_HOME_CHANNEL", "") + if chat_id: + logger.info( + "Job '%s' has deliver=origin but no origin; falling back to %s home channel", + job.get("name", job.get("id", "?")), + platform_name, + ) + return { + "platform": platform_name, + "chat_id": chat_id, + "thread_id": None, + } + return None + + if ":" in deliver: + platform_name, rest = deliver.split(":", 1) + platform_key = platform_name.lower() + + from tools.send_message_tool import _parse_target_ref + + parsed_chat_id, parsed_thread_id, is_explicit = _parse_target_ref(platform_key, rest) + if is_explicit: + chat_id, thread_id = parsed_chat_id, parsed_thread_id + else: + chat_id, thread_id = rest, None + + # Resolve human-friendly labels like "Alice (dm)" to real IDs. + try: + from gateway.channel_directory import resolve_channel_name + resolved = resolve_channel_name(platform_key, chat_id) + if resolved: + parsed_chat_id, parsed_thread_id, resolved_is_explicit = _parse_target_ref(platform_key, resolved) + if resolved_is_explicit: + chat_id, thread_id = parsed_chat_id, parsed_thread_id + else: + chat_id = resolved + except Exception: + pass + + return { + "platform": platform_name, + "chat_id": chat_id, + "thread_id": thread_id, + } + + platform_name = deliver + if origin and origin.get("platform") == platform_name: + return { + "platform": platform_name, + "chat_id": str(origin["chat_id"]), + "thread_id": origin.get("thread_id"), + } + + if platform_name.lower() not in _KNOWN_DELIVERY_PLATFORMS: + return None + chat_id = os.getenv(f"{platform_name.upper()}_HOME_CHANNEL", "") + if not chat_id: + return None + + return { + "platform": platform_name, + "chat_id": chat_id, + "thread_id": None, + } + + +# Media extension sets — keep in sync with gateway/platforms/base.py:_process_message_background +_AUDIO_EXTS = frozenset({'.ogg', '.opus', '.mp3', '.wav', '.m4a'}) +_VIDEO_EXTS = frozenset({'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'}) +_IMAGE_EXTS = frozenset({'.jpg', '.jpeg', '.png', '.webp', '.gif'}) + + +def _send_media_via_adapter(adapter, chat_id: str, media_files: list, metadata: dict | None, loop, job: dict) -> None: + """Send extracted MEDIA files as native platform attachments via a live adapter. + + Routes each file to the appropriate adapter method (send_voice, send_image_file, + send_video, send_document) based on file extension — mirroring the routing logic + in ``BasePlatformAdapter._process_message_background``. + """ + from pathlib import Path + + for media_path, _is_voice in media_files: + try: + ext = Path(media_path).suffix.lower() + if ext in _AUDIO_EXTS: + coro = adapter.send_voice(chat_id=chat_id, audio_path=media_path, metadata=metadata) + elif ext in _VIDEO_EXTS: + coro = adapter.send_video(chat_id=chat_id, video_path=media_path, metadata=metadata) + elif ext in _IMAGE_EXTS: + coro = adapter.send_image_file(chat_id=chat_id, image_path=media_path, metadata=metadata) + else: + coro = adapter.send_document(chat_id=chat_id, file_path=media_path, metadata=metadata) + + future = asyncio.run_coroutine_threadsafe(coro, loop) + result = future.result(timeout=30) + if result and not getattr(result, "success", True): + logger.warning( + "Job '%s': media send failed for %s: %s", + job.get("id", "?"), media_path, getattr(result, "error", "unknown"), + ) + except Exception as e: + logger.warning("Job '%s': failed to send media %s: %s", job.get("id", "?"), media_path, e) + + +def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Optional[str]: + """ + Deliver job output to the configured target (origin chat, specific platform, etc.). + + When ``adapters`` and ``loop`` are provided (gateway is running), tries to + use the live adapter first — this supports E2EE rooms (e.g. Matrix) where + the standalone HTTP path cannot encrypt. Falls back to standalone send if + the adapter path fails or is unavailable. + + Returns None on success, or an error string on failure. + """ + target = _resolve_delivery_target(job) + if not target: + if job.get("deliver", "local") != "local": + msg = f"no delivery target resolved for deliver={job.get('deliver', 'local')}" + logger.warning("Job '%s': %s", job["id"], msg) + return msg + return None # local-only jobs don't deliver — not a failure + + platform_name = target["platform"] + chat_id = target["chat_id"] + thread_id = target.get("thread_id") + + # Diagnostic: log thread_id for topic-aware delivery debugging + origin = job.get("origin") or {} + origin_thread = origin.get("thread_id") + if origin_thread and not thread_id: + logger.warning( + "Job '%s': origin has thread_id=%s but delivery target lost it " + "(deliver=%s, target=%s)", + job["id"], origin_thread, job.get("deliver", "local"), target, + ) + elif thread_id: + logger.debug( + "Job '%s': delivering to %s:%s thread_id=%s", + job["id"], platform_name, chat_id, thread_id, + ) + + from tools.send_message_tool import _send_to_platform + from gateway.config import load_gateway_config, Platform + + platform_map = { + "telegram": Platform.TELEGRAM, + "discord": Platform.DISCORD, + "slack": Platform.SLACK, + "whatsapp": Platform.WHATSAPP, + "signal": Platform.SIGNAL, + "matrix": Platform.MATRIX, + "mattermost": Platform.MATTERMOST, + "homeassistant": Platform.HOMEASSISTANT, + "dingtalk": Platform.DINGTALK, + "feishu": Platform.FEISHU, + "wecom": Platform.WECOM, + "wecom_callback": Platform.WECOM_CALLBACK, + "weixin": Platform.WEIXIN, + "email": Platform.EMAIL, + "sms": Platform.SMS, + "bluebubbles": Platform.BLUEBUBBLES, + "qqbot": Platform.QQBOT, + } + platform = platform_map.get(platform_name.lower()) + if not platform: + msg = f"unknown platform '{platform_name}'" + logger.warning("Job '%s': %s", job["id"], msg) + return msg + + try: + config = load_gateway_config() + except Exception as e: + msg = f"failed to load gateway config: {e}" + logger.error("Job '%s': %s", job["id"], msg) + return msg + + pconfig = config.platforms.get(platform) + if not pconfig or not pconfig.enabled: + msg = f"platform '{platform_name}' not configured/enabled" + logger.warning("Job '%s': %s", job["id"], msg) + return msg + + # Optionally wrap the content with a header/footer so the user knows this + # is a cron delivery. Wrapping is on by default; set cron.wrap_response: false + # in config.yaml for clean output. + wrap_response = True + try: + user_cfg = load_config() + wrap_response = user_cfg.get("cron", {}).get("wrap_response", True) + except Exception: + pass + + if wrap_response: + task_name = job.get("name", job["id"]) + delivery_content = ( + f"Cronjob Response: {task_name}\n" + f"-------------\n\n" + f"{content}\n\n" + f"Note: The agent cannot see this message, and therefore cannot respond to it." + ) + else: + delivery_content = content + + # Extract MEDIA: tags so attachments are forwarded as files, not raw text + from gateway.platforms.base import BasePlatformAdapter + media_files, cleaned_delivery_content = BasePlatformAdapter.extract_media(delivery_content) + + # Prefer the live adapter when the gateway is running — this supports E2EE + # rooms (e.g. Matrix) where the standalone HTTP path cannot encrypt. + runtime_adapter = (adapters or {}).get(platform) + if runtime_adapter is not None and loop is not None and getattr(loop, "is_running", lambda: False)(): + send_metadata = {"thread_id": thread_id} if thread_id else None + try: + # Send cleaned text (MEDIA tags stripped) — not the raw content + text_to_send = cleaned_delivery_content.strip() + adapter_ok = True + if text_to_send: + future = asyncio.run_coroutine_threadsafe( + runtime_adapter.send(chat_id, text_to_send, metadata=send_metadata), + loop, + ) + send_result = future.result(timeout=60) + if send_result and not getattr(send_result, "success", True): + err = getattr(send_result, "error", "unknown") + logger.warning( + "Job '%s': live adapter send to %s:%s failed (%s), falling back to standalone", + job["id"], platform_name, chat_id, err, + ) + adapter_ok = False # fall through to standalone path + + # Send extracted media files as native attachments via the live adapter + if adapter_ok and media_files: + _send_media_via_adapter(runtime_adapter, chat_id, media_files, send_metadata, loop, job) + + if adapter_ok: + logger.info("Job '%s': delivered to %s:%s via live adapter", job["id"], platform_name, chat_id) + return None + except Exception as e: + logger.warning( + "Job '%s': live adapter delivery to %s:%s failed (%s), falling back to standalone", + job["id"], platform_name, chat_id, e, + ) + + # Standalone path: run the async send in a fresh event loop (safe from any thread) + coro = _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files) + try: + result = asyncio.run(coro) + except RuntimeError: + # asyncio.run() checks for a running loop before awaiting the coroutine; + # when it raises, the original coro was never started — close it to + # prevent "coroutine was never awaited" RuntimeWarning, then retry in a + # fresh thread that has no running loop. + coro.close() + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(asyncio.run, _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files)) + result = future.result(timeout=30) + except Exception as e: + msg = f"delivery to {platform_name}:{chat_id} failed: {e}" + logger.error("Job '%s': %s", job["id"], msg) + return msg + + if result and result.get("error"): + msg = f"delivery error: {result['error']}" + logger.error("Job '%s': %s", job["id"], msg) + return msg + + logger.info("Job '%s': delivered to %s:%s", job["id"], platform_name, chat_id) + return None + + +_DEFAULT_SCRIPT_TIMEOUT = 120 # seconds +# Backward-compatible module override used by tests and emergency monkeypatches. +_SCRIPT_TIMEOUT = _DEFAULT_SCRIPT_TIMEOUT + + +def _get_script_timeout() -> int: + """Resolve cron pre-run script timeout from module/env/config with a safe default.""" + if _SCRIPT_TIMEOUT != _DEFAULT_SCRIPT_TIMEOUT: + try: + timeout = int(float(_SCRIPT_TIMEOUT)) + if timeout > 0: + return timeout + except Exception: + logger.warning("Invalid patched _SCRIPT_TIMEOUT=%r; using env/config/default", _SCRIPT_TIMEOUT) + + env_value = os.getenv("HERMES_CRON_SCRIPT_TIMEOUT", "").strip() + if env_value: + try: + timeout = int(float(env_value)) + if timeout > 0: + return timeout + except Exception: + logger.warning("Invalid HERMES_CRON_SCRIPT_TIMEOUT=%r; using config/default", env_value) + + try: + cfg = load_config() or {} + cron_cfg = cfg.get("cron", {}) if isinstance(cfg, dict) else {} + configured = cron_cfg.get("script_timeout_seconds") + if configured is not None: + timeout = int(float(configured)) + if timeout > 0: + return timeout + except Exception as exc: + logger.debug("Failed to load cron script timeout from config: %s", exc) + + return _DEFAULT_SCRIPT_TIMEOUT + + +def _run_job_script(script_path: str) -> tuple[bool, str]: + """Execute a cron job's data-collection script and capture its output. + + Scripts must reside within HERMES_HOME/scripts/. Both relative and + absolute paths are resolved and validated against this directory to + prevent arbitrary script execution via path traversal or absolute + path injection. + + Args: + script_path: Path to a Python script. Relative paths are resolved + against HERMES_HOME/scripts/. Absolute and ~-prefixed paths + are also validated to ensure they stay within the scripts dir. + + Returns: + (success, output) — on failure *output* contains the error message so the + LLM can report the problem to the user. + """ + from hermes_constants import get_hermes_home + + scripts_dir = get_hermes_home() / "scripts" + scripts_dir.mkdir(parents=True, exist_ok=True) + scripts_dir_resolved = scripts_dir.resolve() + + raw = Path(script_path).expanduser() + if raw.is_absolute(): + path = raw.resolve() + else: + path = (scripts_dir / raw).resolve() + + # Guard against path traversal, absolute path injection, and symlink + # escape — scripts MUST reside within HERMES_HOME/scripts/. + try: + path.relative_to(scripts_dir_resolved) + except ValueError: + return False, ( + f"Blocked: script path resolves outside the scripts directory " + f"({scripts_dir_resolved}): {script_path!r}" + ) + + if not path.exists(): + return False, f"Script not found: {path}" + if not path.is_file(): + return False, f"Script path is not a file: {path}" + + script_timeout = _get_script_timeout() + + try: + result = subprocess.run( + [sys.executable, str(path)], + capture_output=True, + text=True, + timeout=script_timeout, + cwd=str(path.parent), + ) + stdout = (result.stdout or "").strip() + stderr = (result.stderr or "").strip() + + # Redact secrets from both stdout and stderr before any return path. + try: + from agent.redact import redact_sensitive_text + stdout = redact_sensitive_text(stdout) + stderr = redact_sensitive_text(stderr) + except Exception: + pass + + if result.returncode != 0: + parts = [f"Script exited with code {result.returncode}"] + if stderr: + parts.append(f"stderr:\n{stderr}") + if stdout: + parts.append(f"stdout:\n{stdout}") + return False, "\n".join(parts) + + return True, stdout + + except subprocess.TimeoutExpired: + return False, f"Script timed out after {script_timeout}s: {path}" + except Exception as exc: + return False, f"Script execution failed: {exc}" + + +def _build_job_prompt(job: dict) -> str: + """Build the effective prompt for a cron job, optionally loading one or more skills first.""" + prompt = job.get("prompt", "") + skills = job.get("skills") + + # Run data-collection script if configured, inject output as context. + script_path = job.get("script") + if script_path: + success, script_output = _run_job_script(script_path) + if success: + if script_output: + prompt = ( + "## Script Output\n" + "The following data was collected by a pre-run script. " + "Use it as context for your analysis.\n\n" + f"```\n{script_output}\n```\n\n" + f"{prompt}" + ) + else: + prompt = ( + "[Script ran successfully but produced no output.]\n\n" + f"{prompt}" + ) + else: + prompt = ( + "## Script Error\n" + "The data-collection script failed. Report this to the user.\n\n" + f"```\n{script_output}\n```\n\n" + f"{prompt}" + ) + + # Always prepend cron execution guidance so the agent knows how + # delivery works and can suppress delivery when appropriate. + cron_hint = ( + "[SYSTEM: You are running as a scheduled cron job. " + "DELIVERY: Your final response will be automatically delivered " + "to the user — do NOT use send_message or try to deliver " + "the output yourself. Just produce your report/output as your " + "final response and the system handles the rest. " + "SILENT: If there is genuinely nothing new to report, respond " + "with exactly \"[SILENT]\" (nothing else) to suppress delivery. " + "Never combine [SILENT] with content — either report your " + "findings normally, or say [SILENT] and nothing more.]\n\n" + ) + prompt = cron_hint + prompt + if skills is None: + legacy = job.get("skill") + skills = [legacy] if legacy else [] + + skill_names = [str(name).strip() for name in skills if str(name).strip()] + if not skill_names: + return prompt + + from tools.skills_tool import skill_view + + parts = [] + skipped: list[str] = [] + for skill_name in skill_names: + loaded = json.loads(skill_view(skill_name)) + if not loaded.get("success"): + error = loaded.get("error") or f"Failed to load skill '{skill_name}'" + logger.warning("Cron job '%s': skill not found, skipping — %s", job.get("name", job.get("id")), error) + skipped.append(skill_name) + continue + + content = str(loaded.get("content") or "").strip() + if parts: + parts.append("") + parts.extend( + [ + f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]', + "", + content, + ] + ) + + if skipped: + notice = ( + f"[SYSTEM: The following skill(s) were listed for this job but could not be found " + f"and were skipped: {', '.join(skipped)}. " + f"Start your response with a brief notice so the user is aware, e.g.: " + f"'⚠️ Skill(s) not found and skipped: {', '.join(skipped)}']" + ) + parts.insert(0, notice) + + if prompt: + parts.extend(["", f"The user has provided the following instruction alongside the skill invocation: {prompt}"]) + return "\n".join(parts) + + +def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: + """ + Execute a single cron job. + + Returns: + Tuple of (success, full_output_doc, final_response, error_message) + """ + from run_agent import AIAgent + + # Initialize SQLite session store so cron job messages are persisted + # and discoverable via session_search (same pattern as gateway/run.py). + _session_db = None + try: + from hermes_state import SessionDB + _session_db = SessionDB() + except Exception as e: + logger.debug("Job '%s': SQLite session store not available: %s", job.get("id", "?"), e) + + job_id = job["id"] + job_name = job["name"] + prompt = _build_job_prompt(job) + origin = _resolve_origin(job) + _cron_session_id = f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}" + + logger.info("Running job '%s' (ID: %s)", job_name, job_id) + logger.info("Prompt: %s", prompt[:100]) + + try: + # Inject origin context so the agent's send_message tool knows the chat. + # Must be INSIDE the try block so the finally cleanup always runs. + if origin: + os.environ["HERMES_SESSION_PLATFORM"] = origin["platform"] + os.environ["HERMES_SESSION_CHAT_ID"] = str(origin["chat_id"]) + if origin.get("chat_name"): + os.environ["HERMES_SESSION_CHAT_NAME"] = origin["chat_name"] + # Re-read .env and config.yaml fresh every run so provider/key + # changes take effect without a gateway restart. + from dotenv import load_dotenv + try: + load_dotenv(str(_hermes_home / ".env"), override=True, encoding="utf-8") + except UnicodeDecodeError: + load_dotenv(str(_hermes_home / ".env"), override=True, encoding="latin-1") + + delivery_target = _resolve_delivery_target(job) + if delivery_target: + os.environ["HERMES_CRON_AUTO_DELIVER_PLATFORM"] = delivery_target["platform"] + os.environ["HERMES_CRON_AUTO_DELIVER_CHAT_ID"] = str(delivery_target["chat_id"]) + if delivery_target.get("thread_id") is not None: + os.environ["HERMES_CRON_AUTO_DELIVER_THREAD_ID"] = str(delivery_target["thread_id"]) + + model = job.get("model") or os.getenv("HERMES_MODEL") or "" + + # Load config.yaml for model, reasoning, prefill, toolsets, provider routing + _cfg = {} + try: + import yaml + _cfg_path = str(_hermes_home / "config.yaml") + if os.path.exists(_cfg_path): + with open(_cfg_path) as _f: + _cfg = yaml.safe_load(_f) or {} + _model_cfg = _cfg.get("model", {}) + if not job.get("model"): + if isinstance(_model_cfg, str): + model = _model_cfg + elif isinstance(_model_cfg, dict): + model = _model_cfg.get("default", model) + except Exception as e: + logger.warning("Job '%s': failed to load config.yaml, using defaults: %s", job_id, e) + + # Apply IPv4 preference if configured. + try: + from hermes_constants import apply_ipv4_preference + _net_cfg = _cfg.get("network", {}) + if isinstance(_net_cfg, dict) and _net_cfg.get("force_ipv4"): + apply_ipv4_preference(force=True) + except Exception: + pass + + # Reasoning config from config.yaml + from hermes_constants import parse_reasoning_effort + effort = str(_cfg.get("agent", {}).get("reasoning_effort", "")).strip() + reasoning_config = parse_reasoning_effort(effort) + + # Prefill messages from env or config.yaml + prefill_messages = None + prefill_file = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") or _cfg.get("prefill_messages_file", "") + if prefill_file: + import json as _json + pfpath = Path(prefill_file).expanduser() + if not pfpath.is_absolute(): + pfpath = _hermes_home / pfpath + if pfpath.exists(): + try: + with open(pfpath, "r", encoding="utf-8") as _pf: + prefill_messages = _json.load(_pf) + if not isinstance(prefill_messages, list): + prefill_messages = None + except Exception as e: + logger.warning("Job '%s': failed to parse prefill messages file '%s': %s", job_id, pfpath, e) + prefill_messages = None + + # Max iterations + max_iterations = _cfg.get("agent", {}).get("max_turns") or _cfg.get("max_turns") or 90 + + # Provider routing + pr = _cfg.get("provider_routing", {}) + smart_routing = _cfg.get("smart_model_routing", {}) or {} + + from hermes_cli.runtime_provider import ( + resolve_runtime_provider, + format_runtime_provider_error, + ) + try: + runtime_kwargs = { + "requested": job.get("provider") or os.getenv("HERMES_INFERENCE_PROVIDER"), + } + if job.get("base_url"): + runtime_kwargs["explicit_base_url"] = job.get("base_url") + runtime = resolve_runtime_provider(**runtime_kwargs) + except Exception as exc: + message = format_runtime_provider_error(exc) + raise RuntimeError(message) from exc + + from agent.smart_model_routing import resolve_turn_route + turn_route = resolve_turn_route( + prompt, + smart_routing, + { + "model": model, + "api_key": runtime.get("api_key"), + "base_url": runtime.get("base_url"), + "provider": runtime.get("provider"), + "api_mode": runtime.get("api_mode"), + "command": runtime.get("command"), + "args": list(runtime.get("args") or []), + }, + ) + + fallback_model = _cfg.get("fallback_providers") or _cfg.get("fallback_model") or None + credential_pool = None + runtime_provider = str(turn_route["runtime"].get("provider") or "").strip().lower() + if runtime_provider: + try: + from agent.credential_pool import load_pool + pool = load_pool(runtime_provider) + if pool.has_credentials(): + credential_pool = pool + logger.info( + "Job '%s': loaded credential pool for provider %s with %d entries", + job_id, + runtime_provider, + len(pool.entries()), + ) + except Exception as e: + logger.debug("Job '%s': failed to load credential pool for %s: %s", job_id, runtime_provider, e) + + agent = AIAgent( + model=turn_route["model"], + api_key=turn_route["runtime"].get("api_key"), + base_url=turn_route["runtime"].get("base_url"), + provider=turn_route["runtime"].get("provider"), + api_mode=turn_route["runtime"].get("api_mode"), + acp_command=turn_route["runtime"].get("command"), + acp_args=turn_route["runtime"].get("args"), + max_iterations=max_iterations, + reasoning_config=reasoning_config, + prefill_messages=prefill_messages, + fallback_model=fallback_model, + credential_pool=credential_pool, + providers_allowed=pr.get("only"), + providers_ignored=pr.get("ignore"), + providers_order=pr.get("order"), + provider_sort=pr.get("sort"), + disabled_toolsets=["cronjob", "messaging", "clarify"], + quiet_mode=True, + skip_context_files=True, # Don't inject SOUL.md/AGENTS.md from scheduler cwd + skip_memory=True, # Cron system prompts would corrupt user representations + platform="cron", + session_id=_cron_session_id, + session_db=_session_db, + ) + + # Run the agent with an *inactivity*-based timeout: the job can run + # for hours if it's actively calling tools / receiving stream tokens, + # but a hung API call or stuck tool with no activity for the configured + # duration is caught and killed. Default 600s (10 min inactivity); + # override via HERMES_CRON_TIMEOUT env var. 0 = unlimited. + # + # Uses the agent's built-in activity tracker (updated by + # _touch_activity() on every tool call, API call, and stream delta). + _cron_timeout = float(os.getenv("HERMES_CRON_TIMEOUT", 600)) + _cron_inactivity_limit = _cron_timeout if _cron_timeout > 0 else None + _POLL_INTERVAL = 5.0 + _cron_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) + _cron_future = _cron_pool.submit(agent.run_conversation, prompt) + _inactivity_timeout = False + try: + if _cron_inactivity_limit is None: + # Unlimited — just wait for the result. + result = _cron_future.result() + else: + result = None + while True: + done, _ = concurrent.futures.wait( + {_cron_future}, timeout=_POLL_INTERVAL, + ) + if done: + result = _cron_future.result() + break + # Agent still running — check inactivity. + _idle_secs = 0.0 + if hasattr(agent, "get_activity_summary"): + try: + _act = agent.get_activity_summary() + _idle_secs = _act.get("seconds_since_activity", 0.0) + except Exception: + pass + if _idle_secs >= _cron_inactivity_limit: + _inactivity_timeout = True + break + except Exception: + _cron_pool.shutdown(wait=False, cancel_futures=True) + raise + finally: + _cron_pool.shutdown(wait=False, cancel_futures=True) + + if _inactivity_timeout: + # Build diagnostic summary from the agent's activity tracker. + _activity = {} + if hasattr(agent, "get_activity_summary"): + try: + _activity = agent.get_activity_summary() + except Exception: + pass + _last_desc = _activity.get("last_activity_desc", "unknown") + _secs_ago = _activity.get("seconds_since_activity", 0) + _cur_tool = _activity.get("current_tool") + _iter_n = _activity.get("api_call_count", 0) + _iter_max = _activity.get("max_iterations", 0) + + logger.error( + "Job '%s' idle for %.0fs (inactivity limit %.0fs) " + "| last_activity=%s | iteration=%s/%s | tool=%s", + job_name, _secs_ago, _cron_inactivity_limit, + _last_desc, _iter_n, _iter_max, + _cur_tool or "none", + ) + if hasattr(agent, "interrupt"): + agent.interrupt("Cron job timed out (inactivity)") + raise TimeoutError( + f"Cron job '{job_name}' idle for " + f"{int(_secs_ago)}s (limit {int(_cron_inactivity_limit)}s) " + f"— last activity: {_last_desc}" + ) + + final_response = result.get("final_response", "") or "" + # Use a separate variable for log display; keep final_response clean + # for delivery logic (empty response = no delivery). + logged_response = final_response if final_response else "(No response generated)" + + output = f"""# Cron Job: {job_name} + +**Job ID:** {job_id} +**Run Time:** {_hermes_now().strftime('%Y-%m-%d %H:%M:%S')} +**Schedule:** {job.get('schedule_display', 'N/A')} + +## Prompt + +{prompt} + +## Response + +{logged_response} +""" + + logger.info("Job '%s' completed successfully", job_name) + return True, output, final_response, None + + except Exception as e: + error_msg = f"{type(e).__name__}: {str(e)}" + logger.exception("Job '%s' failed: %s", job_name, error_msg) + + output = f"""# Cron Job: {job_name} (FAILED) + +**Job ID:** {job_id} +**Run Time:** {_hermes_now().strftime('%Y-%m-%d %H:%M:%S')} +**Schedule:** {job.get('schedule_display', 'N/A')} + +## Prompt + +{prompt} + +## Error + +``` +{error_msg} +``` +""" + return False, output, "", error_msg + + finally: + # Clean up injected env vars so they don't leak to other jobs + for key in ( + "HERMES_SESSION_PLATFORM", + "HERMES_SESSION_CHAT_ID", + "HERMES_SESSION_CHAT_NAME", + "HERMES_CRON_AUTO_DELIVER_PLATFORM", + "HERMES_CRON_AUTO_DELIVER_CHAT_ID", + "HERMES_CRON_AUTO_DELIVER_THREAD_ID", + ): + os.environ.pop(key, None) + if _session_db: + try: + _session_db.end_session(_cron_session_id, "cron_complete") + except (Exception, KeyboardInterrupt) as e: + logger.debug("Job '%s': failed to end session: %s", job_id, e) + try: + _session_db.close() + except (Exception, KeyboardInterrupt) as e: + logger.debug("Job '%s': failed to close SQLite session store: %s", job_id, e) + + +def tick(verbose: bool = True, adapters=None, loop=None) -> int: + """ + Check and run all due jobs. + + Uses a file lock so only one tick runs at a time, even if the gateway's + in-process ticker and a standalone daemon or manual tick overlap. + + Args: + verbose: Whether to print status messages + adapters: Optional dict mapping Platform → live adapter (from gateway) + loop: Optional asyncio event loop (from gateway) for live adapter sends + + Returns: + Number of jobs executed (0 if another tick is already running) + """ + _LOCK_DIR.mkdir(parents=True, exist_ok=True) + + # Cross-platform file locking: fcntl on Unix, msvcrt on Windows + lock_fd = None + try: + lock_fd = open(_LOCK_FILE, "w") + if fcntl: + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + elif msvcrt: + msvcrt.locking(lock_fd.fileno(), msvcrt.LK_NBLCK, 1) + except (OSError, IOError): + logger.debug("Tick skipped — another instance holds the lock") + if lock_fd is not None: + lock_fd.close() + return 0 + + try: + due_jobs = get_due_jobs() + + if verbose and not due_jobs: + logger.info("%s - No jobs due", _hermes_now().strftime('%H:%M:%S')) + return 0 + + if verbose: + logger.info("%s - %s job(s) due", _hermes_now().strftime('%H:%M:%S'), len(due_jobs)) + + executed = 0 + for job in due_jobs: + try: + # For recurring jobs (cron/interval), advance next_run_at to the + # next future occurrence BEFORE execution. This way, if the + # process crashes mid-run, the job won't re-fire on restart. + # One-shot jobs are left alone so they can retry on restart. + advance_next_run(job["id"]) + + success, output, final_response, error = run_job(job) + + output_file = save_job_output(job["id"], output) + if verbose: + logger.info("Output saved to: %s", output_file) + + # Deliver the final response to the origin/target chat. + # If the agent responded with [SILENT], skip delivery (but + # output is already saved above). Failed jobs always deliver. + deliver_content = final_response if success else f"⚠️ Cron job '{job.get('name', job['id'])}' failed:\n{error}" + should_deliver = bool(deliver_content) + if should_deliver and success and SILENT_MARKER in deliver_content.strip().upper(): + logger.info("Job '%s': agent returned %s — skipping delivery", job["id"], SILENT_MARKER) + should_deliver = False + + delivery_error = None + if should_deliver: + try: + delivery_error = _deliver_result(job, deliver_content, adapters=adapters, loop=loop) + except Exception as de: + delivery_error = str(de) + logger.error("Delivery failed for job %s: %s", job["id"], de) + + mark_job_run(job["id"], success, error, delivery_error=delivery_error) + executed += 1 + + except Exception as e: + logger.error("Error processing job %s: %s", job['id'], e) + mark_job_run(job["id"], False, str(e)) + + return executed + finally: + if fcntl: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + elif msvcrt: + try: + msvcrt.locking(lock_fd.fileno(), msvcrt.LK_UNLCK, 1) + except (OSError, IOError): + pass + lock_fd.close() + + +if __name__ == "__main__": + tick(verbose=True) diff --git a/mindcli/_vendor/gateway/__init__.py b/mindcli/_vendor/gateway/__init__.py new file mode 100644 index 0000000..8b6d988 --- /dev/null +++ b/mindcli/_vendor/gateway/__init__.py @@ -0,0 +1,35 @@ +""" +Hermes Gateway - Multi-platform messaging integration. + +This module provides a unified gateway for connecting the Hermes agent +to various messaging platforms (Telegram, Discord, WhatsApp) with: +- Session management (persistent conversations with reset policies) +- Dynamic context injection (agent knows where messages come from) +- Delivery routing (cron job outputs to appropriate channels) +- Platform-specific toolsets (different capabilities per platform) +""" + +from .config import GatewayConfig, PlatformConfig, HomeChannel, load_gateway_config +from .session import ( + SessionContext, + SessionStore, + SessionResetPolicy, + build_session_context_prompt, +) +from .delivery import DeliveryRouter, DeliveryTarget + +__all__ = [ + # Config + "GatewayConfig", + "PlatformConfig", + "HomeChannel", + "load_gateway_config", + # Session + "SessionContext", + "SessionStore", + "SessionResetPolicy", + "build_session_context_prompt", + # Delivery + "DeliveryRouter", + "DeliveryTarget", +] diff --git a/mindcli/_vendor/gateway/builtin_hooks/__init__.py b/mindcli/_vendor/gateway/builtin_hooks/__init__.py new file mode 100644 index 0000000..37da09d --- /dev/null +++ b/mindcli/_vendor/gateway/builtin_hooks/__init__.py @@ -0,0 +1 @@ +"""Built-in gateway hooks that are always registered.""" diff --git a/mindcli/_vendor/gateway/builtin_hooks/boot_md.py b/mindcli/_vendor/gateway/builtin_hooks/boot_md.py new file mode 100644 index 0000000..c2868a1 --- /dev/null +++ b/mindcli/_vendor/gateway/builtin_hooks/boot_md.py @@ -0,0 +1,85 @@ +"""Built-in boot-md hook — run ~/.hermes/BOOT.md on gateway startup. + +This hook is always registered. It silently skips if no BOOT.md exists. +To activate, create ``~/.hermes/BOOT.md`` with instructions for the +agent to execute on every gateway restart. + +Example BOOT.md:: + + # Startup Checklist + + 1. Check if any cron jobs failed overnight + 2. Send a status update to Discord #general + 3. If there are errors in /opt/app/deploy.log, summarize them + +The agent runs in a background thread so it doesn't block gateway +startup. If nothing needs attention, it replies with [SILENT] to +suppress delivery. +""" + +import logging +import threading + +logger = logging.getLogger("hooks.boot-md") + +from hermes_constants import get_hermes_home +HERMES_HOME = get_hermes_home() +BOOT_FILE = HERMES_HOME / "BOOT.md" + + +def _build_boot_prompt(content: str) -> str: + """Wrap BOOT.md content in a system-level instruction.""" + return ( + "You are running a startup boot checklist. Follow the BOOT.md " + "instructions below exactly.\n\n" + "---\n" + f"{content}\n" + "---\n\n" + "Execute each instruction. If you need to send a message to a " + "platform, use the send_message tool.\n" + "If nothing needs attention and there is nothing to report, " + "reply with ONLY: [SILENT]" + ) + + +def _run_boot_agent(content: str) -> None: + """Spawn a one-shot agent session to execute the boot instructions.""" + try: + from run_agent import AIAgent + + prompt = _build_boot_prompt(content) + agent = AIAgent( + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + max_iterations=20, + ) + result = agent.run_conversation(prompt) + response = result.get("final_response", "") + if response and "[SILENT]" not in response: + logger.info("boot-md completed: %s", response[:200]) + else: + logger.info("boot-md completed (nothing to report)") + except Exception as e: + logger.error("boot-md agent failed: %s", e) + + +async def handle(event_type: str, context: dict) -> None: + """Gateway startup handler — run BOOT.md if it exists.""" + if not BOOT_FILE.exists(): + return + + content = BOOT_FILE.read_text(encoding="utf-8").strip() + if not content: + return + + logger.info("Running BOOT.md (%d chars)", len(content)) + + # Run in a background thread so we don't block gateway startup. + thread = threading.Thread( + target=_run_boot_agent, + args=(content,), + name="boot-md", + daemon=True, + ) + thread.start() diff --git a/mindcli/_vendor/gateway/channel_directory.py b/mindcli/_vendor/gateway/channel_directory.py new file mode 100644 index 0000000..ae2beda --- /dev/null +++ b/mindcli/_vendor/gateway/channel_directory.py @@ -0,0 +1,276 @@ +""" +Channel directory -- cached map of reachable channels/contacts per platform. + +Built on gateway startup, refreshed periodically (every 5 min), and saved to +~/.hermes/channel_directory.json. The send_message tool reads this file for +action="list" and for resolving human-friendly channel names to numeric IDs. +""" + +import json +import logging +from datetime import datetime +from typing import Any, Dict, List, Optional + +from hermes_cli.config import get_hermes_home +from utils import atomic_json_write + +logger = logging.getLogger(__name__) + +DIRECTORY_PATH = get_hermes_home() / "channel_directory.json" + + +def _normalize_channel_query(value: str) -> str: + return value.lstrip("#").strip().lower() + + +def _channel_target_name(platform_name: str, channel: Dict[str, Any]) -> str: + """Return the human-facing target label shown to users for a channel entry.""" + name = channel["name"] + if platform_name == "discord" and channel.get("guild"): + return f"#{name}" + if platform_name != "discord" and channel.get("type"): + return f"{name} ({channel['type']})" + return name + + +def _session_entry_id(origin: Dict[str, Any]) -> Optional[str]: + chat_id = origin.get("chat_id") + if not chat_id: + return None + thread_id = origin.get("thread_id") + if thread_id: + return f"{chat_id}:{thread_id}" + return str(chat_id) + + +def _session_entry_name(origin: Dict[str, Any]) -> str: + base_name = origin.get("chat_name") or origin.get("user_name") or str(origin.get("chat_id")) + thread_id = origin.get("thread_id") + if not thread_id: + return base_name + + topic_label = origin.get("chat_topic") or f"topic {thread_id}" + return f"{base_name} / {topic_label}" + + +# --------------------------------------------------------------------------- +# Build / refresh +# --------------------------------------------------------------------------- + +def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: + """ + Build a channel directory from connected platform adapters and session data. + + Returns the directory dict and writes it to DIRECTORY_PATH. + """ + from gateway.config import Platform + + platforms: Dict[str, List[Dict[str, str]]] = {} + + for platform, adapter in adapters.items(): + try: + if platform == Platform.DISCORD: + platforms["discord"] = _build_discord(adapter) + elif platform == Platform.SLACK: + platforms["slack"] = _build_slack(adapter) + except Exception as e: + logger.warning("Channel directory: failed to build %s: %s", platform.value, e) + + # Platforms that don't support direct channel enumeration get session-based + # discovery automatically. Skip infrastructure entries that aren't messaging + # platforms — everything else falls through to _build_from_sessions(). + _SKIP_SESSION_DISCOVERY = frozenset({"local", "api_server", "webhook"}) + for plat in Platform: + plat_name = plat.value + if plat_name in _SKIP_SESSION_DISCOVERY or plat_name in platforms: + continue + platforms[plat_name] = _build_from_sessions(plat_name) + + directory = { + "updated_at": datetime.now().isoformat(), + "platforms": platforms, + } + + try: + atomic_json_write(DIRECTORY_PATH, directory) + except Exception as e: + logger.warning("Channel directory: failed to write: %s", e) + + return directory + + +def _build_discord(adapter) -> List[Dict[str, str]]: + """Enumerate all text channels the Discord bot can see.""" + channels = [] + client = getattr(adapter, "_client", None) + if not client: + return channels + + try: + import discord as _discord # noqa: F401 — SDK presence check + except ImportError: + return channels + + for guild in client.guilds: + for ch in guild.text_channels: + channels.append({ + "id": str(ch.id), + "name": ch.name, + "guild": guild.name, + "type": "channel", + }) + # Also include DM-capable users we've interacted with is not + # feasible via guild enumeration; those come from sessions. + + # Merge any DMs from session history + channels.extend(_build_from_sessions("discord")) + return channels + + +def _build_slack(adapter) -> List[Dict[str, str]]: + """List Slack channels the bot has joined.""" + # Slack adapter may expose a web client + client = getattr(adapter, "_app", None) or getattr(adapter, "_client", None) + if not client: + return _build_from_sessions("slack") + + try: + from tools.send_message_tool import _send_slack # noqa: F401 + # Use the Slack Web API directly if available + except Exception: + pass + + # Fallback to session data + return _build_from_sessions("slack") + + +def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]: + """Pull known channels/contacts from sessions.json origin data.""" + sessions_path = get_hermes_home() / "sessions" / "sessions.json" + if not sessions_path.exists(): + return [] + + entries = [] + try: + with open(sessions_path, encoding="utf-8") as f: + data = json.load(f) + + seen_ids = set() + for _key, session in data.items(): + origin = session.get("origin") or {} + if origin.get("platform") != platform_name: + continue + entry_id = _session_entry_id(origin) + if not entry_id or entry_id in seen_ids: + continue + seen_ids.add(entry_id) + entries.append({ + "id": entry_id, + "name": _session_entry_name(origin), + "type": session.get("chat_type", "dm"), + "thread_id": origin.get("thread_id"), + }) + except Exception as e: + logger.debug("Channel directory: failed to read sessions for %s: %s", platform_name, e) + + return entries + + +# --------------------------------------------------------------------------- +# Read / resolve +# --------------------------------------------------------------------------- + +def load_directory() -> Dict[str, Any]: + """Load the cached channel directory from disk.""" + if not DIRECTORY_PATH.exists(): + return {"updated_at": None, "platforms": {}} + try: + with open(DIRECTORY_PATH, encoding="utf-8") as f: + return json.load(f) + except Exception: + return {"updated_at": None, "platforms": {}} + + +def resolve_channel_name(platform_name: str, name: str) -> Optional[str]: + """ + Resolve a human-friendly channel name to a numeric ID. + + Matching strategy (case-insensitive, first match wins): + - Discord: "bot-home", "#bot-home", "GuildName/bot-home" + - Telegram: display name or group name + - Slack: "engineering", "#engineering" + """ + directory = load_directory() + channels = directory.get("platforms", {}).get(platform_name, []) + if not channels: + return None + + query = _normalize_channel_query(name) + + # 1. Exact name match, including the display labels shown by send_message(action="list") + for ch in channels: + if _normalize_channel_query(ch["name"]) == query: + return ch["id"] + if _normalize_channel_query(_channel_target_name(platform_name, ch)) == query: + return ch["id"] + + # 2. Guild-qualified match for Discord ("GuildName/channel") + if "/" in query: + guild_part, ch_part = query.rsplit("/", 1) + for ch in channels: + guild = ch.get("guild", "").strip().lower() + if guild == guild_part and _normalize_channel_query(ch["name"]) == ch_part: + return ch["id"] + + # 3. Partial prefix match (only if unambiguous) + matches = [ch for ch in channels if _normalize_channel_query(ch["name"]).startswith(query)] + if len(matches) == 1: + return matches[0]["id"] + + return None + + +def format_directory_for_display() -> str: + """Format the channel directory as a human-readable list for the model.""" + directory = load_directory() + platforms = directory.get("platforms", {}) + + if not any(platforms.values()): + return "No messaging platforms connected or no channels discovered yet." + + lines = ["Available messaging targets:\n"] + + for plat_name, channels in sorted(platforms.items()): + if not channels: + continue + + # Group Discord channels by guild + if plat_name == "discord": + guilds: Dict[str, List] = {} + dms: List = [] + for ch in channels: + guild = ch.get("guild") + if guild: + guilds.setdefault(guild, []).append(ch) + else: + dms.append(ch) + + for guild_name, guild_channels in sorted(guilds.items()): + lines.append(f"Discord ({guild_name}):") + for ch in sorted(guild_channels, key=lambda c: c["name"]): + lines.append(f" discord:{_channel_target_name(plat_name, ch)}") + if dms: + lines.append("Discord (DMs):") + for ch in dms: + lines.append(f" discord:{_channel_target_name(plat_name, ch)}") + lines.append("") + else: + lines.append(f"{plat_name.title()}:") + for ch in channels: + lines.append(f" {plat_name}:{_channel_target_name(plat_name, ch)}") + lines.append("") + + lines.append('Use these as the "target" parameter when sending.') + lines.append('Bare platform name (e.g. "telegram") sends to home channel.') + + return "\n".join(lines) diff --git a/mindcli/_vendor/gateway/config.py b/mindcli/_vendor/gateway/config.py new file mode 100644 index 0000000..7ce105f --- /dev/null +++ b/mindcli/_vendor/gateway/config.py @@ -0,0 +1,1160 @@ +""" +Gateway configuration management. + +Handles loading and validating configuration for: +- Connected platforms (Telegram, Discord, WhatsApp) +- Home channels for each platform +- Session reset policies +- Delivery preferences +""" + +import logging +import os +import json +from pathlib import Path +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Any +from enum import Enum + +from hermes_cli.config import get_hermes_home +from utils import is_truthy_value + +logger = logging.getLogger(__name__) + + +def _coerce_bool(value: Any, default: bool = True) -> bool: + """Coerce bool-ish config values, preserving a caller-provided default.""" + if value is None: + return default + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in ("true", "1", "yes", "on"): + return True + if lowered in ("false", "0", "no", "off"): + return False + return default + return is_truthy_value(value, default=default) + + +def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str: + """Normalize unauthorized DM behavior to a supported value.""" + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"pair", "ignore"}: + return normalized + return default + + +class Platform(Enum): + """Supported messaging platforms.""" + LOCAL = "local" + TELEGRAM = "telegram" + DISCORD = "discord" + WHATSAPP = "whatsapp" + SLACK = "slack" + SIGNAL = "signal" + MATTERMOST = "mattermost" + MATRIX = "matrix" + HOMEASSISTANT = "homeassistant" + EMAIL = "email" + SMS = "sms" + DINGTALK = "dingtalk" + API_SERVER = "api_server" + WEBHOOK = "webhook" + FEISHU = "feishu" + WECOM = "wecom" + WECOM_CALLBACK = "wecom_callback" + WEIXIN = "weixin" + BLUEBUBBLES = "bluebubbles" + QQBOT = "qqbot" + + +@dataclass +class HomeChannel: + """ + Default destination for a platform. + + When a cron job specifies deliver="telegram" without a specific chat ID, + messages are sent to this home channel. + """ + platform: Platform + chat_id: str + name: str # Human-readable name for display + + def to_dict(self) -> Dict[str, Any]: + return { + "platform": self.platform.value, + "chat_id": self.chat_id, + "name": self.name, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "HomeChannel": + return cls( + platform=Platform(data["platform"]), + chat_id=str(data["chat_id"]), + name=data.get("name", "Home"), + ) + + +@dataclass +class SessionResetPolicy: + """ + Controls when sessions reset (lose context). + + Modes: + - "daily": Reset at a specific hour each day + - "idle": Reset after N minutes of inactivity + - "both": Whichever triggers first (daily boundary OR idle timeout) + - "none": Never auto-reset (context managed only by compression) + """ + mode: str = "both" # "daily", "idle", "both", or "none" + at_hour: int = 4 # Hour for daily reset (0-23, local time) + idle_minutes: int = 1440 # Minutes of inactivity before reset (24 hours) + notify: bool = True # Send a notification to the user when auto-reset occurs + notify_exclude_platforms: tuple = ("api_server", "webhook") # Platforms that don't get reset notifications + + def to_dict(self) -> Dict[str, Any]: + return { + "mode": self.mode, + "at_hour": self.at_hour, + "idle_minutes": self.idle_minutes, + "notify": self.notify, + "notify_exclude_platforms": list(self.notify_exclude_platforms), + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy": + # Handle both missing keys and explicit null values (YAML null → None) + mode = data.get("mode") + at_hour = data.get("at_hour") + idle_minutes = data.get("idle_minutes") + notify = data.get("notify") + exclude = data.get("notify_exclude_platforms") + return cls( + mode=mode if mode is not None else "both", + at_hour=at_hour if at_hour is not None else 4, + idle_minutes=idle_minutes if idle_minutes is not None else 1440, + notify=notify if notify is not None else True, + notify_exclude_platforms=tuple(exclude) if exclude is not None else ("api_server", "webhook"), + ) + + +@dataclass +class PlatformConfig: + """Configuration for a single messaging platform.""" + enabled: bool = False + token: Optional[str] = None # Bot token (Telegram, Discord) + api_key: Optional[str] = None # API key if different from token + home_channel: Optional[HomeChannel] = None + + # Reply threading mode (Telegram/Slack) + # - "off": Never thread replies to original message + # - "first": Only first chunk threads to user's message (default) + # - "all": All chunks in multi-part replies thread to user's message + reply_to_mode: str = "first" + + # Platform-specific settings + extra: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + result = { + "enabled": self.enabled, + "extra": self.extra, + "reply_to_mode": self.reply_to_mode, + } + if self.token: + result["token"] = self.token + if self.api_key: + result["api_key"] = self.api_key + if self.home_channel: + result["home_channel"] = self.home_channel.to_dict() + return result + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": + home_channel = None + if "home_channel" in data: + home_channel = HomeChannel.from_dict(data["home_channel"]) + + return cls( + enabled=data.get("enabled", False), + token=data.get("token"), + api_key=data.get("api_key"), + home_channel=home_channel, + reply_to_mode=data.get("reply_to_mode", "first"), + extra=data.get("extra", {}), + ) + + +@dataclass +class StreamingConfig: + """Configuration for real-time token streaming to messaging platforms.""" + enabled: bool = False + transport: str = "edit" # "edit" (progressive editMessageText) or "off" + edit_interval: float = 1.0 # Seconds between message edits (Telegram rate-limits at ~1/s) + buffer_threshold: int = 40 # Chars before forcing an edit + cursor: str = " ▉" # Cursor shown during streaming + + def to_dict(self) -> Dict[str, Any]: + return { + "enabled": self.enabled, + "transport": self.transport, + "edit_interval": self.edit_interval, + "buffer_threshold": self.buffer_threshold, + "cursor": self.cursor, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "StreamingConfig": + if not data: + return cls() + return cls( + enabled=data.get("enabled", False), + transport=data.get("transport", "edit"), + edit_interval=float(data.get("edit_interval", 1.0)), + buffer_threshold=int(data.get("buffer_threshold", 40)), + cursor=data.get("cursor", " ▉"), + ) + + +@dataclass +class GatewayConfig: + """ + Main gateway configuration. + + Manages all platform connections, session policies, and delivery settings. + """ + # Platform configurations + platforms: Dict[Platform, PlatformConfig] = field(default_factory=dict) + + # Session reset policies by type + default_reset_policy: SessionResetPolicy = field(default_factory=SessionResetPolicy) + reset_by_type: Dict[str, SessionResetPolicy] = field(default_factory=dict) + reset_by_platform: Dict[Platform, SessionResetPolicy] = field(default_factory=dict) + + # Reset trigger commands + reset_triggers: List[str] = field(default_factory=lambda: ["/new", "/reset"]) + + # User-defined quick commands (slash commands that bypass the agent loop) + quick_commands: Dict[str, Any] = field(default_factory=dict) + + # Storage paths + sessions_dir: Path = field(default_factory=lambda: get_hermes_home() / "sessions") + + # Delivery settings + always_log_local: bool = True # Always save cron outputs to local files + + # STT settings + stt_enabled: bool = True # Whether to auto-transcribe inbound voice messages + + # Session isolation in shared chats + group_sessions_per_user: bool = True # Isolate group/channel sessions per participant when user IDs are available + thread_sessions_per_user: bool = False # When False (default), threads are shared across all participants + + # Unauthorized DM policy + unauthorized_dm_behavior: str = "pair" # "pair" or "ignore" + + # Streaming configuration + streaming: StreamingConfig = field(default_factory=StreamingConfig) + + def get_connected_platforms(self) -> List[Platform]: + """Return list of platforms that are enabled and configured.""" + connected = [] + for platform, config in self.platforms.items(): + if not config.enabled: + continue + # Weixin requires both a token and an account_id + if platform == Platform.WEIXIN: + if config.extra.get("account_id") and (config.token or config.extra.get("token")): + connected.append(platform) + continue + # Platforms that use token/api_key auth + if config.token or config.api_key: + connected.append(platform) + # WhatsApp uses enabled flag only (bridge handles auth) + elif platform == Platform.WHATSAPP: + connected.append(platform) + # Signal uses extra dict for config (http_url + account) + elif platform == Platform.SIGNAL and config.extra.get("http_url"): + connected.append(platform) + # Email uses extra dict for config (address + imap_host + smtp_host) + elif platform == Platform.EMAIL and config.extra.get("address"): + connected.append(platform) + # SMS uses api_key (Twilio auth token) — SID checked via env + elif platform == Platform.SMS and os.getenv("TWILIO_ACCOUNT_SID"): + connected.append(platform) + # API Server uses enabled flag only (no token needed) + elif platform == Platform.API_SERVER: + connected.append(platform) + # Webhook uses enabled flag only (secrets are per-route) + elif platform == Platform.WEBHOOK: + connected.append(platform) + # Feishu uses extra dict for app credentials + elif platform == Platform.FEISHU and config.extra.get("app_id"): + connected.append(platform) + # WeCom bot mode uses extra dict for bot credentials + elif platform == Platform.WECOM and config.extra.get("bot_id"): + connected.append(platform) + # WeCom callback mode uses corp_id or apps list + elif platform == Platform.WECOM_CALLBACK and ( + config.extra.get("corp_id") or config.extra.get("apps") + ): + connected.append(platform) + # BlueBubbles uses extra dict for local server config + elif platform == Platform.BLUEBUBBLES and config.extra.get("server_url") and config.extra.get("password"): + connected.append(platform) + # QQBot uses extra dict for app credentials + elif platform == Platform.QQBOT and config.extra.get("app_id") and config.extra.get("client_secret"): + connected.append(platform) + return connected + + def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]: + """Get the home channel for a platform.""" + config = self.platforms.get(platform) + if config: + return config.home_channel + return None + + def get_reset_policy( + self, + platform: Optional[Platform] = None, + session_type: Optional[str] = None + ) -> SessionResetPolicy: + """ + Get the appropriate reset policy for a session. + + Priority: platform override > type override > default + """ + # Platform-specific override takes precedence + if platform and platform in self.reset_by_platform: + return self.reset_by_platform[platform] + + # Type-specific override (dm, group, thread) + if session_type and session_type in self.reset_by_type: + return self.reset_by_type[session_type] + + return self.default_reset_policy + + def to_dict(self) -> Dict[str, Any]: + return { + "platforms": { + p.value: c.to_dict() for p, c in self.platforms.items() + }, + "default_reset_policy": self.default_reset_policy.to_dict(), + "reset_by_type": { + k: v.to_dict() for k, v in self.reset_by_type.items() + }, + "reset_by_platform": { + p.value: v.to_dict() for p, v in self.reset_by_platform.items() + }, + "reset_triggers": self.reset_triggers, + "quick_commands": self.quick_commands, + "sessions_dir": str(self.sessions_dir), + "always_log_local": self.always_log_local, + "stt_enabled": self.stt_enabled, + "group_sessions_per_user": self.group_sessions_per_user, + "thread_sessions_per_user": self.thread_sessions_per_user, + "unauthorized_dm_behavior": self.unauthorized_dm_behavior, + "streaming": self.streaming.to_dict(), + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": + platforms = {} + for platform_name, platform_data in data.get("platforms", {}).items(): + try: + platform = Platform(platform_name) + platforms[platform] = PlatformConfig.from_dict(platform_data) + except ValueError: + pass # Skip unknown platforms + + reset_by_type = {} + for type_name, policy_data in data.get("reset_by_type", {}).items(): + reset_by_type[type_name] = SessionResetPolicy.from_dict(policy_data) + + reset_by_platform = {} + for platform_name, policy_data in data.get("reset_by_platform", {}).items(): + try: + platform = Platform(platform_name) + reset_by_platform[platform] = SessionResetPolicy.from_dict(policy_data) + except ValueError: + pass + + default_policy = SessionResetPolicy() + if "default_reset_policy" in data: + default_policy = SessionResetPolicy.from_dict(data["default_reset_policy"]) + + sessions_dir = get_hermes_home() / "sessions" + if "sessions_dir" in data: + sessions_dir = Path(data["sessions_dir"]) + + quick_commands = data.get("quick_commands", {}) + if not isinstance(quick_commands, dict): + quick_commands = {} + + stt_enabled = data.get("stt_enabled") + if stt_enabled is None: + stt_enabled = data.get("stt", {}).get("enabled") if isinstance(data.get("stt"), dict) else None + + group_sessions_per_user = data.get("group_sessions_per_user") + thread_sessions_per_user = data.get("thread_sessions_per_user") + unauthorized_dm_behavior = _normalize_unauthorized_dm_behavior( + data.get("unauthorized_dm_behavior"), + "pair", + ) + + return cls( + platforms=platforms, + default_reset_policy=default_policy, + reset_by_type=reset_by_type, + reset_by_platform=reset_by_platform, + reset_triggers=data.get("reset_triggers", ["/new", "/reset"]), + quick_commands=quick_commands, + sessions_dir=sessions_dir, + always_log_local=data.get("always_log_local", True), + stt_enabled=_coerce_bool(stt_enabled, True), + group_sessions_per_user=_coerce_bool(group_sessions_per_user, True), + thread_sessions_per_user=_coerce_bool(thread_sessions_per_user, False), + unauthorized_dm_behavior=unauthorized_dm_behavior, + streaming=StreamingConfig.from_dict(data.get("streaming", {})), + ) + + def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str: + """Return the effective unauthorized-DM behavior for a platform.""" + if platform: + platform_cfg = self.platforms.get(platform) + if platform_cfg and "unauthorized_dm_behavior" in platform_cfg.extra: + return _normalize_unauthorized_dm_behavior( + platform_cfg.extra.get("unauthorized_dm_behavior"), + self.unauthorized_dm_behavior, + ) + return self.unauthorized_dm_behavior + + +def load_gateway_config() -> GatewayConfig: + """ + Load gateway configuration from multiple sources. + + Priority (highest to lowest): + 1. Environment variables + 2. ~/.hermes/config.yaml (primary user-facing config) + 3. ~/.hermes/gateway.json (legacy — provides defaults under config.yaml) + 4. Built-in defaults + """ + _home = get_hermes_home() + gw_data: dict = {} + + # Legacy fallback: gateway.json provides the base layer. + # config.yaml keys always win when both specify the same setting. + gateway_json_path = _home / "gateway.json" + if gateway_json_path.exists(): + try: + with open(gateway_json_path, "r", encoding="utf-8") as f: + gw_data = json.load(f) or {} + logger.info( + "Loaded legacy %s — consider moving settings to config.yaml", + gateway_json_path, + ) + except Exception as e: + logger.warning("Failed to load %s: %s", gateway_json_path, e) + + # Primary source: config.yaml + try: + import yaml + config_yaml_path = _home / "config.yaml" + if config_yaml_path.exists(): + with open(config_yaml_path, encoding="utf-8") as f: + yaml_cfg = yaml.safe_load(f) or {} + + # Map config.yaml keys → GatewayConfig.from_dict() schema. + # Each key overwrites whatever gateway.json may have set. + sr = yaml_cfg.get("session_reset") + if sr and isinstance(sr, dict): + gw_data["default_reset_policy"] = sr + + qc = yaml_cfg.get("quick_commands") + if qc is not None: + if isinstance(qc, dict): + gw_data["quick_commands"] = qc + else: + logger.warning( + "Ignoring invalid quick_commands in config.yaml " + "(expected mapping, got %s)", + type(qc).__name__, + ) + + stt_cfg = yaml_cfg.get("stt") + if isinstance(stt_cfg, dict): + gw_data["stt"] = stt_cfg + + if "group_sessions_per_user" in yaml_cfg: + gw_data["group_sessions_per_user"] = yaml_cfg["group_sessions_per_user"] + + if "thread_sessions_per_user" in yaml_cfg: + gw_data["thread_sessions_per_user"] = yaml_cfg["thread_sessions_per_user"] + + streaming_cfg = yaml_cfg.get("streaming") + if isinstance(streaming_cfg, dict): + gw_data["streaming"] = streaming_cfg + + if "reset_triggers" in yaml_cfg: + gw_data["reset_triggers"] = yaml_cfg["reset_triggers"] + + if "always_log_local" in yaml_cfg: + gw_data["always_log_local"] = yaml_cfg["always_log_local"] + + if "unauthorized_dm_behavior" in yaml_cfg: + gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior( + yaml_cfg.get("unauthorized_dm_behavior"), + "pair", + ) + + # Merge platforms section from config.yaml into gw_data so that + # nested keys like platforms.webhook.extra.routes are loaded. + yaml_platforms = yaml_cfg.get("platforms") + platforms_data = gw_data.setdefault("platforms", {}) + if not isinstance(platforms_data, dict): + platforms_data = {} + gw_data["platforms"] = platforms_data + if isinstance(yaml_platforms, dict): + for plat_name, plat_block in yaml_platforms.items(): + if not isinstance(plat_block, dict): + continue + existing = platforms_data.get(plat_name, {}) + if not isinstance(existing, dict): + existing = {} + # Deep-merge extra dicts so gateway.json defaults survive + merged_extra = {**existing.get("extra", {}), **plat_block.get("extra", {})} + merged = {**existing, **plat_block} + if merged_extra: + merged["extra"] = merged_extra + platforms_data[plat_name] = merged + gw_data["platforms"] = platforms_data + for plat in Platform: + if plat == Platform.LOCAL: + continue + platform_cfg = yaml_cfg.get(plat.value) + if not isinstance(platform_cfg, dict): + continue + # Collect bridgeable keys from this platform section + bridged = {} + if "unauthorized_dm_behavior" in platform_cfg: + bridged["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior( + platform_cfg.get("unauthorized_dm_behavior"), + gw_data.get("unauthorized_dm_behavior", "pair"), + ) + if "reply_prefix" in platform_cfg: + bridged["reply_prefix"] = platform_cfg["reply_prefix"] + if "require_mention" in platform_cfg: + bridged["require_mention"] = platform_cfg["require_mention"] + if "free_response_channels" in platform_cfg: + bridged["free_response_channels"] = platform_cfg["free_response_channels"] + if "mention_patterns" in platform_cfg: + bridged["mention_patterns"] = platform_cfg["mention_patterns"] + if plat == Platform.DISCORD and "channel_skill_bindings" in platform_cfg: + bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"] + if not bridged: + continue + plat_data = platforms_data.setdefault(plat.value, {}) + if not isinstance(plat_data, dict): + plat_data = {} + platforms_data[plat.value] = plat_data + extra = plat_data.setdefault("extra", {}) + if not isinstance(extra, dict): + extra = {} + plat_data["extra"] = extra + extra.update(bridged) + + # Slack settings → env vars (env vars take precedence) + slack_cfg = yaml_cfg.get("slack", {}) + if isinstance(slack_cfg, dict): + if "require_mention" in slack_cfg and not os.getenv("SLACK_REQUIRE_MENTION"): + os.environ["SLACK_REQUIRE_MENTION"] = str(slack_cfg["require_mention"]).lower() + if "allow_bots" in slack_cfg and not os.getenv("SLACK_ALLOW_BOTS"): + os.environ["SLACK_ALLOW_BOTS"] = str(slack_cfg["allow_bots"]).lower() + frc = slack_cfg.get("free_response_channels") + if frc is not None and not os.getenv("SLACK_FREE_RESPONSE_CHANNELS"): + if isinstance(frc, list): + frc = ",".join(str(v) for v in frc) + os.environ["SLACK_FREE_RESPONSE_CHANNELS"] = str(frc) + + # Discord settings → env vars (env vars take precedence) + discord_cfg = yaml_cfg.get("discord", {}) + if isinstance(discord_cfg, dict): + if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"): + os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower() + frc = discord_cfg.get("free_response_channels") + if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"): + if isinstance(frc, list): + frc = ",".join(str(v) for v in frc) + os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc) + if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"): + os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower() + if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"): + os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower() + # ignored_channels: channels where bot never responds (even when mentioned) + ic = discord_cfg.get("ignored_channels") + if ic is not None and not os.getenv("DISCORD_IGNORED_CHANNELS"): + if isinstance(ic, list): + ic = ",".join(str(v) for v in ic) + os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic) + # allowed_channels: if set, bot ONLY responds in these channels (whitelist) + ac = discord_cfg.get("allowed_channels") + if ac is not None and not os.getenv("DISCORD_ALLOWED_CHANNELS"): + if isinstance(ac, list): + ac = ",".join(str(v) for v in ac) + os.environ["DISCORD_ALLOWED_CHANNELS"] = str(ac) + # no_thread_channels: channels where bot responds directly without creating thread + ntc = discord_cfg.get("no_thread_channels") + if ntc is not None and not os.getenv("DISCORD_NO_THREAD_CHANNELS"): + if isinstance(ntc, list): + ntc = ",".join(str(v) for v in ntc) + os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc) + + # Telegram settings → env vars (env vars take precedence) + telegram_cfg = yaml_cfg.get("telegram", {}) + if isinstance(telegram_cfg, dict): + if "require_mention" in telegram_cfg and not os.getenv("TELEGRAM_REQUIRE_MENTION"): + os.environ["TELEGRAM_REQUIRE_MENTION"] = str(telegram_cfg["require_mention"]).lower() + if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"): + import json as _json + os.environ["TELEGRAM_MENTION_PATTERNS"] = _json.dumps(telegram_cfg["mention_patterns"]) + frc = telegram_cfg.get("free_response_chats") + if frc is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"): + if isinstance(frc, list): + frc = ",".join(str(v) for v in frc) + os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc) + ignored_threads = telegram_cfg.get("ignored_threads") + if ignored_threads is not None and not os.getenv("TELEGRAM_IGNORED_THREADS"): + if isinstance(ignored_threads, list): + ignored_threads = ",".join(str(v) for v in ignored_threads) + os.environ["TELEGRAM_IGNORED_THREADS"] = str(ignored_threads) + if "reactions" in telegram_cfg and not os.getenv("TELEGRAM_REACTIONS"): + os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower() + + whatsapp_cfg = yaml_cfg.get("whatsapp", {}) + if isinstance(whatsapp_cfg, dict): + if "require_mention" in whatsapp_cfg and not os.getenv("WHATSAPP_REQUIRE_MENTION"): + os.environ["WHATSAPP_REQUIRE_MENTION"] = str(whatsapp_cfg["require_mention"]).lower() + if "mention_patterns" in whatsapp_cfg and not os.getenv("WHATSAPP_MENTION_PATTERNS"): + os.environ["WHATSAPP_MENTION_PATTERNS"] = json.dumps(whatsapp_cfg["mention_patterns"]) + frc = whatsapp_cfg.get("free_response_chats") + if frc is not None and not os.getenv("WHATSAPP_FREE_RESPONSE_CHATS"): + if isinstance(frc, list): + frc = ",".join(str(v) for v in frc) + os.environ["WHATSAPP_FREE_RESPONSE_CHATS"] = str(frc) + + # Matrix settings → env vars (env vars take precedence) + matrix_cfg = yaml_cfg.get("matrix", {}) + if isinstance(matrix_cfg, dict): + if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"): + os.environ["MATRIX_REQUIRE_MENTION"] = str(matrix_cfg["require_mention"]).lower() + frc = matrix_cfg.get("free_response_rooms") + if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"): + if isinstance(frc, list): + frc = ",".join(str(v) for v in frc) + os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc) + if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"): + os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower() + if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"): + os.environ["MATRIX_DM_MENTION_THREADS"] = str(matrix_cfg["dm_mention_threads"]).lower() + + except Exception as e: + logger.warning( + "Failed to process config.yaml — falling back to .env / gateway.json values. " + "Check %s for syntax errors. Error: %s", + _home / "config.yaml", + e, + ) + + config = GatewayConfig.from_dict(gw_data) + + # Override with environment variables + _apply_env_overrides(config) + + # --- Validate loaded values --- + _validate_gateway_config(config) + + return config + + +def _validate_gateway_config(config: "GatewayConfig") -> None: + """Validate and sanitize a loaded GatewayConfig in place. + + Called by ``load_gateway_config()`` after all config sources are merged. + Extracted as a separate function for testability. + """ + policy = config.default_reset_policy + + if not (0 <= policy.at_hour <= 23): + logger.warning( + "Invalid at_hour=%s (must be 0-23). Using default 4.", policy.at_hour + ) + policy.at_hour = 4 + + if policy.idle_minutes is None or policy.idle_minutes <= 0: + logger.warning( + "Invalid idle_minutes=%s (must be positive). Using default 1440.", + policy.idle_minutes, + ) + policy.idle_minutes = 1440 + + # Warn about empty bot tokens — platforms that loaded an empty string + # won't connect and the cause can be confusing without a log line. + _token_env_names = { + Platform.TELEGRAM: "TELEGRAM_BOT_TOKEN", + Platform.DISCORD: "DISCORD_BOT_TOKEN", + Platform.SLACK: "SLACK_BOT_TOKEN", + Platform.MATTERMOST: "MATTERMOST_TOKEN", + Platform.MATRIX: "MATRIX_ACCESS_TOKEN", + Platform.WEIXIN: "WEIXIN_TOKEN", + } + for platform, pconfig in config.platforms.items(): + if not pconfig.enabled: + continue + env_name = _token_env_names.get(platform) + if env_name and pconfig.token is not None and not pconfig.token.strip(): + logger.warning( + "%s is enabled but %s is empty. " + "The adapter will likely fail to connect.", + platform.value, env_name, + ) + + # Reject known-weak placeholder tokens. + # Ported from openclaw/openclaw#64586: users who copy .env.example + # without changing placeholder values get a clear startup error instead + # of a confusing "auth failed" from the platform API. + try: + from hermes_cli.auth import has_usable_secret + except ImportError: + has_usable_secret = None # type: ignore[assignment] + + if has_usable_secret is not None: + for platform, pconfig in config.platforms.items(): + if not pconfig.enabled: + continue + env_name = _token_env_names.get(platform) + if not env_name: + continue + token = pconfig.token + if token and token.strip() and not has_usable_secret(token, min_length=4): + logger.error( + "%s is enabled but %s is set to a placeholder value ('%s'). " + "Set a real bot token before starting the gateway. " + "The adapter will NOT be started.", + platform.value, env_name, token.strip()[:6] + "...", + ) + pconfig.enabled = False + + +def _apply_env_overrides(config: GatewayConfig) -> None: + """Apply environment variable overrides to config.""" + + # Telegram + telegram_token = os.getenv("TELEGRAM_BOT_TOKEN") + if telegram_token: + if Platform.TELEGRAM not in config.platforms: + config.platforms[Platform.TELEGRAM] = PlatformConfig() + config.platforms[Platform.TELEGRAM].enabled = True + config.platforms[Platform.TELEGRAM].token = telegram_token + + # Reply threading mode for Telegram (off/first/all) + telegram_reply_mode = os.getenv("TELEGRAM_REPLY_TO_MODE", "").lower() + if telegram_reply_mode in ("off", "first", "all"): + if Platform.TELEGRAM not in config.platforms: + config.platforms[Platform.TELEGRAM] = PlatformConfig() + config.platforms[Platform.TELEGRAM].reply_to_mode = telegram_reply_mode + + telegram_fallback_ips = os.getenv("TELEGRAM_FALLBACK_IPS", "") + if telegram_fallback_ips: + if Platform.TELEGRAM not in config.platforms: + config.platforms[Platform.TELEGRAM] = PlatformConfig() + config.platforms[Platform.TELEGRAM].extra["fallback_ips"] = [ + ip.strip() for ip in telegram_fallback_ips.split(",") if ip.strip() + ] + + telegram_home = os.getenv("TELEGRAM_HOME_CHANNEL") + if telegram_home and Platform.TELEGRAM in config.platforms: + config.platforms[Platform.TELEGRAM].home_channel = HomeChannel( + platform=Platform.TELEGRAM, + chat_id=telegram_home, + name=os.getenv("TELEGRAM_HOME_CHANNEL_NAME", "Home"), + ) + + # Discord + discord_token = os.getenv("DISCORD_BOT_TOKEN") + if discord_token: + if Platform.DISCORD not in config.platforms: + config.platforms[Platform.DISCORD] = PlatformConfig() + config.platforms[Platform.DISCORD].enabled = True + config.platforms[Platform.DISCORD].token = discord_token + + discord_home = os.getenv("DISCORD_HOME_CHANNEL") + if discord_home and Platform.DISCORD in config.platforms: + config.platforms[Platform.DISCORD].home_channel = HomeChannel( + platform=Platform.DISCORD, + chat_id=discord_home, + name=os.getenv("DISCORD_HOME_CHANNEL_NAME", "Home"), + ) + + # Reply threading mode for Discord (off/first/all) + discord_reply_mode = os.getenv("DISCORD_REPLY_TO_MODE", "").lower() + if discord_reply_mode in ("off", "first", "all"): + if Platform.DISCORD not in config.platforms: + config.platforms[Platform.DISCORD] = PlatformConfig() + config.platforms[Platform.DISCORD].reply_to_mode = discord_reply_mode + + # WhatsApp (typically uses different auth mechanism) + whatsapp_enabled = os.getenv("WHATSAPP_ENABLED", "").lower() in ("true", "1", "yes") + if whatsapp_enabled: + if Platform.WHATSAPP not in config.platforms: + config.platforms[Platform.WHATSAPP] = PlatformConfig() + config.platforms[Platform.WHATSAPP].enabled = True + + # Slack + slack_token = os.getenv("SLACK_BOT_TOKEN") + if slack_token: + if Platform.SLACK not in config.platforms: + config.platforms[Platform.SLACK] = PlatformConfig() + config.platforms[Platform.SLACK].enabled = True + config.platforms[Platform.SLACK].token = slack_token + slack_home = os.getenv("SLACK_HOME_CHANNEL") + if slack_home and Platform.SLACK in config.platforms: + config.platforms[Platform.SLACK].home_channel = HomeChannel( + platform=Platform.SLACK, + chat_id=slack_home, + name=os.getenv("SLACK_HOME_CHANNEL_NAME", ""), + ) + + # Signal + signal_url = os.getenv("SIGNAL_HTTP_URL") + signal_account = os.getenv("SIGNAL_ACCOUNT") + if signal_url and signal_account: + if Platform.SIGNAL not in config.platforms: + config.platforms[Platform.SIGNAL] = PlatformConfig() + config.platforms[Platform.SIGNAL].enabled = True + config.platforms[Platform.SIGNAL].extra.update({ + "http_url": signal_url, + "account": signal_account, + "ignore_stories": os.getenv("SIGNAL_IGNORE_STORIES", "true").lower() in ("true", "1", "yes"), + }) + signal_home = os.getenv("SIGNAL_HOME_CHANNEL") + if signal_home and Platform.SIGNAL in config.platforms: + config.platforms[Platform.SIGNAL].home_channel = HomeChannel( + platform=Platform.SIGNAL, + chat_id=signal_home, + name=os.getenv("SIGNAL_HOME_CHANNEL_NAME", "Home"), + ) + + # Mattermost + mattermost_token = os.getenv("MATTERMOST_TOKEN") + if mattermost_token: + mattermost_url = os.getenv("MATTERMOST_URL", "") + if not mattermost_url: + logger.warning("MATTERMOST_TOKEN set but MATTERMOST_URL is missing") + if Platform.MATTERMOST not in config.platforms: + config.platforms[Platform.MATTERMOST] = PlatformConfig() + config.platforms[Platform.MATTERMOST].enabled = True + config.platforms[Platform.MATTERMOST].token = mattermost_token + config.platforms[Platform.MATTERMOST].extra["url"] = mattermost_url + mattermost_home = os.getenv("MATTERMOST_HOME_CHANNEL") + if mattermost_home and Platform.MATTERMOST in config.platforms: + config.platforms[Platform.MATTERMOST].home_channel = HomeChannel( + platform=Platform.MATTERMOST, + chat_id=mattermost_home, + name=os.getenv("MATTERMOST_HOME_CHANNEL_NAME", "Home"), + ) + + # Matrix + matrix_token = os.getenv("MATRIX_ACCESS_TOKEN") + matrix_homeserver = os.getenv("MATRIX_HOMESERVER", "") + if matrix_token or os.getenv("MATRIX_PASSWORD"): + if not matrix_homeserver: + logger.warning("MATRIX_ACCESS_TOKEN/MATRIX_PASSWORD set but MATRIX_HOMESERVER is missing") + if Platform.MATRIX not in config.platforms: + config.platforms[Platform.MATRIX] = PlatformConfig() + config.platforms[Platform.MATRIX].enabled = True + if matrix_token: + config.platforms[Platform.MATRIX].token = matrix_token + config.platforms[Platform.MATRIX].extra["homeserver"] = matrix_homeserver + matrix_user = os.getenv("MATRIX_USER_ID", "") + if matrix_user: + config.platforms[Platform.MATRIX].extra["user_id"] = matrix_user + matrix_password = os.getenv("MATRIX_PASSWORD", "") + if matrix_password: + config.platforms[Platform.MATRIX].extra["password"] = matrix_password + matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes") + config.platforms[Platform.MATRIX].extra["encryption"] = matrix_e2ee + matrix_device_id = os.getenv("MATRIX_DEVICE_ID", "") + if matrix_device_id: + config.platforms[Platform.MATRIX].extra["device_id"] = matrix_device_id + matrix_home = os.getenv("MATRIX_HOME_ROOM") + if matrix_home and Platform.MATRIX in config.platforms: + config.platforms[Platform.MATRIX].home_channel = HomeChannel( + platform=Platform.MATRIX, + chat_id=matrix_home, + name=os.getenv("MATRIX_HOME_ROOM_NAME", "Home"), + ) + + # Home Assistant + hass_token = os.getenv("HASS_TOKEN") + if hass_token: + if Platform.HOMEASSISTANT not in config.platforms: + config.platforms[Platform.HOMEASSISTANT] = PlatformConfig() + config.platforms[Platform.HOMEASSISTANT].enabled = True + config.platforms[Platform.HOMEASSISTANT].token = hass_token + hass_url = os.getenv("HASS_URL") + if hass_url: + config.platforms[Platform.HOMEASSISTANT].extra["url"] = hass_url + + # Email + email_addr = os.getenv("EMAIL_ADDRESS") + email_pwd = os.getenv("EMAIL_PASSWORD") + email_imap = os.getenv("EMAIL_IMAP_HOST") + email_smtp = os.getenv("EMAIL_SMTP_HOST") + if all([email_addr, email_pwd, email_imap, email_smtp]): + if Platform.EMAIL not in config.platforms: + config.platforms[Platform.EMAIL] = PlatformConfig() + config.platforms[Platform.EMAIL].enabled = True + config.platforms[Platform.EMAIL].extra.update({ + "address": email_addr, + "imap_host": email_imap, + "smtp_host": email_smtp, + }) + email_home = os.getenv("EMAIL_HOME_ADDRESS") + if email_home and Platform.EMAIL in config.platforms: + config.platforms[Platform.EMAIL].home_channel = HomeChannel( + platform=Platform.EMAIL, + chat_id=email_home, + name=os.getenv("EMAIL_HOME_ADDRESS_NAME", "Home"), + ) + + # SMS (Twilio) + twilio_sid = os.getenv("TWILIO_ACCOUNT_SID") + if twilio_sid: + if Platform.SMS not in config.platforms: + config.platforms[Platform.SMS] = PlatformConfig() + config.platforms[Platform.SMS].enabled = True + config.platforms[Platform.SMS].api_key = os.getenv("TWILIO_AUTH_TOKEN", "") + sms_home = os.getenv("SMS_HOME_CHANNEL") + if sms_home and Platform.SMS in config.platforms: + config.platforms[Platform.SMS].home_channel = HomeChannel( + platform=Platform.SMS, + chat_id=sms_home, + name=os.getenv("SMS_HOME_CHANNEL_NAME", "Home"), + ) + + # API Server + api_server_enabled = os.getenv("API_SERVER_ENABLED", "").lower() in ("true", "1", "yes") + api_server_key = os.getenv("API_SERVER_KEY", "") + api_server_cors_origins = os.getenv("API_SERVER_CORS_ORIGINS", "") + api_server_port = os.getenv("API_SERVER_PORT") + api_server_host = os.getenv("API_SERVER_HOST") + if api_server_enabled or api_server_key: + if Platform.API_SERVER not in config.platforms: + config.platforms[Platform.API_SERVER] = PlatformConfig() + config.platforms[Platform.API_SERVER].enabled = True + if api_server_key: + config.platforms[Platform.API_SERVER].extra["key"] = api_server_key + if api_server_cors_origins: + origins = [origin.strip() for origin in api_server_cors_origins.split(",") if origin.strip()] + if origins: + config.platforms[Platform.API_SERVER].extra["cors_origins"] = origins + if api_server_port: + try: + config.platforms[Platform.API_SERVER].extra["port"] = int(api_server_port) + except ValueError: + pass + if api_server_host: + config.platforms[Platform.API_SERVER].extra["host"] = api_server_host + api_server_model_name = os.getenv("API_SERVER_MODEL_NAME", "") + if api_server_model_name: + config.platforms[Platform.API_SERVER].extra["model_name"] = api_server_model_name + + # Webhook platform + webhook_enabled = os.getenv("WEBHOOK_ENABLED", "").lower() in ("true", "1", "yes") + webhook_port = os.getenv("WEBHOOK_PORT") + webhook_secret = os.getenv("WEBHOOK_SECRET", "") + if webhook_enabled: + if Platform.WEBHOOK not in config.platforms: + config.platforms[Platform.WEBHOOK] = PlatformConfig() + config.platforms[Platform.WEBHOOK].enabled = True + if webhook_port: + try: + config.platforms[Platform.WEBHOOK].extra["port"] = int(webhook_port) + except ValueError: + pass + if webhook_secret: + config.platforms[Platform.WEBHOOK].extra["secret"] = webhook_secret + + # Feishu / Lark + feishu_app_id = os.getenv("FEISHU_APP_ID") + feishu_app_secret = os.getenv("FEISHU_APP_SECRET") + if feishu_app_id and feishu_app_secret: + if Platform.FEISHU not in config.platforms: + config.platforms[Platform.FEISHU] = PlatformConfig() + config.platforms[Platform.FEISHU].enabled = True + config.platforms[Platform.FEISHU].extra.update({ + "app_id": feishu_app_id, + "app_secret": feishu_app_secret, + "domain": os.getenv("FEISHU_DOMAIN", "feishu"), + "connection_mode": os.getenv("FEISHU_CONNECTION_MODE", "websocket"), + }) + feishu_encrypt_key = os.getenv("FEISHU_ENCRYPT_KEY", "") + if feishu_encrypt_key: + config.platforms[Platform.FEISHU].extra["encrypt_key"] = feishu_encrypt_key + feishu_verification_token = os.getenv("FEISHU_VERIFICATION_TOKEN", "") + if feishu_verification_token: + config.platforms[Platform.FEISHU].extra["verification_token"] = feishu_verification_token + feishu_home = os.getenv("FEISHU_HOME_CHANNEL") + if feishu_home: + config.platforms[Platform.FEISHU].home_channel = HomeChannel( + platform=Platform.FEISHU, + chat_id=feishu_home, + name=os.getenv("FEISHU_HOME_CHANNEL_NAME", "Home"), + ) + + # WeCom (Enterprise WeChat) + wecom_bot_id = os.getenv("WECOM_BOT_ID") + wecom_secret = os.getenv("WECOM_SECRET") + if wecom_bot_id and wecom_secret: + if Platform.WECOM not in config.platforms: + config.platforms[Platform.WECOM] = PlatformConfig() + config.platforms[Platform.WECOM].enabled = True + config.platforms[Platform.WECOM].extra.update({ + "bot_id": wecom_bot_id, + "secret": wecom_secret, + }) + wecom_ws_url = os.getenv("WECOM_WEBSOCKET_URL", "") + if wecom_ws_url: + config.platforms[Platform.WECOM].extra["websocket_url"] = wecom_ws_url + wecom_home = os.getenv("WECOM_HOME_CHANNEL") + if wecom_home: + config.platforms[Platform.WECOM].home_channel = HomeChannel( + platform=Platform.WECOM, + chat_id=wecom_home, + name=os.getenv("WECOM_HOME_CHANNEL_NAME", "Home"), + ) + + # WeCom callback mode (self-built apps) + wecom_callback_corp_id = os.getenv("WECOM_CALLBACK_CORP_ID") + wecom_callback_corp_secret = os.getenv("WECOM_CALLBACK_CORP_SECRET") + if wecom_callback_corp_id and wecom_callback_corp_secret: + if Platform.WECOM_CALLBACK not in config.platforms: + config.platforms[Platform.WECOM_CALLBACK] = PlatformConfig() + config.platforms[Platform.WECOM_CALLBACK].enabled = True + config.platforms[Platform.WECOM_CALLBACK].extra.update({ + "corp_id": wecom_callback_corp_id, + "corp_secret": wecom_callback_corp_secret, + "agent_id": os.getenv("WECOM_CALLBACK_AGENT_ID", ""), + "token": os.getenv("WECOM_CALLBACK_TOKEN", ""), + "encoding_aes_key": os.getenv("WECOM_CALLBACK_ENCODING_AES_KEY", ""), + "host": os.getenv("WECOM_CALLBACK_HOST", "0.0.0.0"), + "port": int(os.getenv("WECOM_CALLBACK_PORT", "8645")), + }) + + # Weixin (personal WeChat via iLink Bot API) + weixin_token = os.getenv("WEIXIN_TOKEN") + weixin_account_id = os.getenv("WEIXIN_ACCOUNT_ID") + if weixin_token or weixin_account_id: + if Platform.WEIXIN not in config.platforms: + config.platforms[Platform.WEIXIN] = PlatformConfig() + config.platforms[Platform.WEIXIN].enabled = True + if weixin_token: + config.platforms[Platform.WEIXIN].token = weixin_token + extra = config.platforms[Platform.WEIXIN].extra + if weixin_account_id: + extra["account_id"] = weixin_account_id + weixin_base_url = os.getenv("WEIXIN_BASE_URL", "").strip() + if weixin_base_url: + extra["base_url"] = weixin_base_url.rstrip("/") + weixin_cdn_base_url = os.getenv("WEIXIN_CDN_BASE_URL", "").strip() + if weixin_cdn_base_url: + extra["cdn_base_url"] = weixin_cdn_base_url.rstrip("/") + weixin_dm_policy = os.getenv("WEIXIN_DM_POLICY", "").strip().lower() + if weixin_dm_policy: + extra["dm_policy"] = weixin_dm_policy + weixin_group_policy = os.getenv("WEIXIN_GROUP_POLICY", "").strip().lower() + if weixin_group_policy: + extra["group_policy"] = weixin_group_policy + weixin_allowed_users = os.getenv("WEIXIN_ALLOWED_USERS", "").strip() + if weixin_allowed_users: + extra["allow_from"] = weixin_allowed_users + weixin_group_allowed_users = os.getenv("WEIXIN_GROUP_ALLOWED_USERS", "").strip() + if weixin_group_allowed_users: + extra["group_allow_from"] = weixin_group_allowed_users + weixin_split_multiline = os.getenv("WEIXIN_SPLIT_MULTILINE_MESSAGES", "").strip() + if weixin_split_multiline: + extra["split_multiline_messages"] = weixin_split_multiline + weixin_home = os.getenv("WEIXIN_HOME_CHANNEL", "").strip() + if weixin_home: + config.platforms[Platform.WEIXIN].home_channel = HomeChannel( + platform=Platform.WEIXIN, + chat_id=weixin_home, + name=os.getenv("WEIXIN_HOME_CHANNEL_NAME", "Home"), + ) + + # BlueBubbles (iMessage) + bluebubbles_server_url = os.getenv("BLUEBUBBLES_SERVER_URL") + bluebubbles_password = os.getenv("BLUEBUBBLES_PASSWORD") + if bluebubbles_server_url and bluebubbles_password: + if Platform.BLUEBUBBLES not in config.platforms: + config.platforms[Platform.BLUEBUBBLES] = PlatformConfig() + config.platforms[Platform.BLUEBUBBLES].enabled = True + config.platforms[Platform.BLUEBUBBLES].extra.update({ + "server_url": bluebubbles_server_url.rstrip("/"), + "password": bluebubbles_password, + "webhook_host": os.getenv("BLUEBUBBLES_WEBHOOK_HOST", "127.0.0.1"), + "webhook_port": int(os.getenv("BLUEBUBBLES_WEBHOOK_PORT", "8645")), + "webhook_path": os.getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"), + "send_read_receipts": os.getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true").lower() in ("true", "1", "yes"), + }) + bluebubbles_home = os.getenv("BLUEBUBBLES_HOME_CHANNEL") + if bluebubbles_home and Platform.BLUEBUBBLES in config.platforms: + config.platforms[Platform.BLUEBUBBLES].home_channel = HomeChannel( + platform=Platform.BLUEBUBBLES, + chat_id=bluebubbles_home, + name=os.getenv("BLUEBUBBLES_HOME_CHANNEL_NAME", "Home"), + ) + + # QQ (Official Bot API v2) + qq_app_id = os.getenv("QQ_APP_ID") + qq_client_secret = os.getenv("QQ_CLIENT_SECRET") + if qq_app_id or qq_client_secret: + if Platform.QQBOT not in config.platforms: + config.platforms[Platform.QQBOT] = PlatformConfig() + config.platforms[Platform.QQBOT].enabled = True + extra = config.platforms[Platform.QQBOT].extra + if qq_app_id: + extra["app_id"] = qq_app_id + if qq_client_secret: + extra["client_secret"] = qq_client_secret + qq_allowed_users = os.getenv("QQ_ALLOWED_USERS", "").strip() + if qq_allowed_users: + extra["allow_from"] = qq_allowed_users + qq_group_allowed = os.getenv("QQ_GROUP_ALLOWED_USERS", "").strip() + if qq_group_allowed: + extra["group_allow_from"] = qq_group_allowed + qq_home = os.getenv("QQ_HOME_CHANNEL", "").strip() + if qq_home: + config.platforms[Platform.QQBOT].home_channel = HomeChannel( + platform=Platform.QQBOT, + chat_id=qq_home, + name=os.getenv("QQ_HOME_CHANNEL_NAME", "Home"), + ) + + # Session settings + idle_minutes = os.getenv("SESSION_IDLE_MINUTES") + if idle_minutes: + try: + config.default_reset_policy.idle_minutes = int(idle_minutes) + except ValueError: + pass + + reset_hour = os.getenv("SESSION_RESET_HOUR") + if reset_hour: + try: + config.default_reset_policy.at_hour = int(reset_hour) + except ValueError: + pass diff --git a/mindcli/_vendor/gateway/delivery.py b/mindcli/_vendor/gateway/delivery.py new file mode 100644 index 0000000..bc901c2 --- /dev/null +++ b/mindcli/_vendor/gateway/delivery.py @@ -0,0 +1,256 @@ +""" +Delivery routing for cron job outputs and agent responses. + +Routes messages to the appropriate destination based on: +- Explicit targets (e.g., "telegram:123456789") +- Platform home channels (e.g., "telegram" → home channel) +- Origin (back to where the job was created) +- Local (always saved to files) +""" + +import logging +from pathlib import Path +from datetime import datetime +from dataclasses import dataclass +from typing import Dict, List, Optional, Any + +from hermes_cli.config import get_hermes_home + +logger = logging.getLogger(__name__) + +MAX_PLATFORM_OUTPUT = 4000 +TRUNCATED_VISIBLE = 3800 + +from .config import Platform, GatewayConfig +from .session import SessionSource + + +@dataclass +class DeliveryTarget: + """ + A single delivery target. + + Represents where a message should be sent: + - "origin" → back to source + - "local" → save to local files + - "telegram" → Telegram home channel + - "telegram:123456" → specific Telegram chat + """ + platform: Platform + chat_id: Optional[str] = None # None means use home channel + thread_id: Optional[str] = None + is_origin: bool = False + is_explicit: bool = False # True if chat_id was explicitly specified + + @classmethod + def parse(cls, target: str, origin: Optional[SessionSource] = None) -> "DeliveryTarget": + """ + Parse a delivery target string. + + Formats: + - "origin" → back to source + - "local" → local files only + - "telegram" → Telegram home channel + - "telegram:123456" → specific Telegram chat + """ + target = target.strip().lower() + + if target == "origin": + if origin: + return cls( + platform=origin.platform, + chat_id=origin.chat_id, + thread_id=origin.thread_id, + is_origin=True, + ) + else: + # Fallback to local if no origin + return cls(platform=Platform.LOCAL, is_origin=True) + + if target == "local": + return cls(platform=Platform.LOCAL) + + # Check for platform:chat_id or platform:chat_id:thread_id format + if ":" in target: + parts = target.split(":", 2) + platform_str = parts[0] + chat_id = parts[1] if len(parts) > 1 else None + thread_id = parts[2] if len(parts) > 2 else None + try: + platform = Platform(platform_str) + return cls(platform=platform, chat_id=chat_id, thread_id=thread_id, is_explicit=True) + except ValueError: + # Unknown platform, treat as local + return cls(platform=Platform.LOCAL) + + # Just a platform name (use home channel) + try: + platform = Platform(target) + return cls(platform=platform) + except ValueError: + # Unknown platform, treat as local + return cls(platform=Platform.LOCAL) + + def to_string(self) -> str: + """Convert back to string format.""" + if self.is_origin: + return "origin" + if self.platform == Platform.LOCAL: + return "local" + if self.chat_id and self.thread_id: + return f"{self.platform.value}:{self.chat_id}:{self.thread_id}" + if self.chat_id: + return f"{self.platform.value}:{self.chat_id}" + return self.platform.value + + +class DeliveryRouter: + """ + Routes messages to appropriate destinations. + + Handles the logic of resolving delivery targets and dispatching + messages to the right platform adapters. + """ + + def __init__(self, config: GatewayConfig, adapters: Dict[Platform, Any] = None): + """ + Initialize the delivery router. + + Args: + config: Gateway configuration + adapters: Dict mapping platforms to their adapter instances + """ + self.config = config + self.adapters = adapters or {} + self.output_dir = get_hermes_home() / "cron" / "output" + + async def deliver( + self, + content: str, + targets: List[DeliveryTarget], + job_id: Optional[str] = None, + job_name: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Deliver content to all specified targets. + + Args: + content: The message/output to deliver + targets: List of delivery targets + job_id: Optional job ID (for cron jobs) + job_name: Optional job name + metadata: Additional metadata to include + + Returns: + Dict with delivery results per target + """ + results = {} + + for target in targets: + try: + if target.platform == Platform.LOCAL: + result = self._deliver_local(content, job_id, job_name, metadata) + else: + result = await self._deliver_to_platform(target, content, metadata) + + results[target.to_string()] = { + "success": True, + "result": result + } + except Exception as e: + results[target.to_string()] = { + "success": False, + "error": str(e) + } + + return results + + def _deliver_local( + self, + content: str, + job_id: Optional[str], + job_name: Optional[str], + metadata: Optional[Dict[str, Any]] + ) -> Dict[str, Any]: + """Save content to local files.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + if job_id: + output_path = self.output_dir / job_id / f"{timestamp}.md" + else: + output_path = self.output_dir / "misc" / f"{timestamp}.md" + + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Build the output document + lines = [] + if job_name: + lines.append(f"# {job_name}") + else: + lines.append("# Delivery Output") + + lines.append("") + lines.append(f"**Timestamp:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + if job_id: + lines.append(f"**Job ID:** {job_id}") + + if metadata: + for key, value in metadata.items(): + lines.append(f"**{key}:** {value}") + + lines.append("") + lines.append("---") + lines.append("") + lines.append(content) + + output_path.write_text("\n".join(lines)) + + return { + "path": str(output_path), + "timestamp": timestamp + } + + def _save_full_output(self, content: str, job_id: str) -> Path: + """Save full cron output to disk and return the file path.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + out_dir = get_hermes_home() / "cron" / "output" + out_dir.mkdir(parents=True, exist_ok=True) + path = out_dir / f"{job_id}_{timestamp}.txt" + path.write_text(content) + return path + + async def _deliver_to_platform( + self, + target: DeliveryTarget, + content: str, + metadata: Optional[Dict[str, Any]] + ) -> Dict[str, Any]: + """Deliver content to a messaging platform.""" + adapter = self.adapters.get(target.platform) + + if not adapter: + raise ValueError(f"No adapter configured for {target.platform.value}") + + if not target.chat_id: + raise ValueError(f"No chat ID for {target.platform.value} delivery") + + # Guard: truncate oversized cron output to stay within platform limits + if len(content) > MAX_PLATFORM_OUTPUT: + job_id = (metadata or {}).get("job_id", "unknown") + saved_path = self._save_full_output(content, job_id) + logger.info("Cron output truncated (%d chars) — full output: %s", len(content), saved_path) + content = ( + content[:TRUNCATED_VISIBLE] + + f"\n\n... [truncated, full output saved to {saved_path}]" + ) + + send_metadata = dict(metadata or {}) + if target.thread_id and "thread_id" not in send_metadata: + send_metadata["thread_id"] = target.thread_id + return await adapter.send(target.chat_id, content, metadata=send_metadata or None) + + + + diff --git a/mindcli/_vendor/gateway/display_config.py b/mindcli/_vendor/gateway/display_config.py new file mode 100644 index 0000000..c1dcf2a --- /dev/null +++ b/mindcli/_vendor/gateway/display_config.py @@ -0,0 +1,187 @@ +"""Per-platform display/verbosity configuration resolver. + +Provides ``resolve_display_setting()`` — the single entry-point for reading +display settings with platform-specific overrides and sensible defaults. + +Resolution order (first non-None wins): + 1. ``display.platforms..`` — explicit per-platform user override + 2. ``display.`` — global user setting + 3. ``_PLATFORM_DEFAULTS[][]`` — built-in sensible default + 4. ``_GLOBAL_DEFAULTS[]`` — built-in global default + +Backward compatibility: ``display.tool_progress_overrides`` is still read as a +fallback for ``tool_progress`` when no ``display.platforms`` entry exists. A +config migration (version bump) automatically moves the old format into the new +``display.platforms`` structure. +""" + +from __future__ import annotations + +from typing import Any + +# --------------------------------------------------------------------------- +# Overrideable display settings and their global defaults +# --------------------------------------------------------------------------- +# These are the settings that can be configured per-platform. +# Other display settings (compact, personality, skin, etc.) are CLI-only +# and don't participate in per-platform resolution. + +_GLOBAL_DEFAULTS: dict[str, Any] = { + "tool_progress": "all", + "show_reasoning": False, + "tool_preview_length": 0, + "streaming": None, # None = follow top-level streaming config +} + +# --------------------------------------------------------------------------- +# Sensible per-platform defaults — tiered by platform capability +# --------------------------------------------------------------------------- +# Tier 1 (high): Supports message editing, typically personal/team use +# Tier 2 (medium): Supports editing but often workspace/customer-facing +# Tier 3 (low): No edit support — each progress msg is permanent +# Tier 4 (minimal): Batch/non-interactive delivery + +_TIER_HIGH = { + "tool_progress": "all", + "show_reasoning": False, + "tool_preview_length": 40, + "streaming": None, # follow global +} + +_TIER_MEDIUM = { + "tool_progress": "new", + "show_reasoning": False, + "tool_preview_length": 40, + "streaming": None, +} + +_TIER_LOW = { + "tool_progress": "off", + "show_reasoning": False, + "tool_preview_length": 40, + "streaming": False, +} + +_TIER_MINIMAL = { + "tool_progress": "off", + "show_reasoning": False, + "tool_preview_length": 0, + "streaming": False, +} + +_PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = { + # Tier 1 — full edit support, personal/team use + "telegram": _TIER_HIGH, + "discord": _TIER_HIGH, + + # Tier 2 — edit support, often customer/workspace channels + "slack": _TIER_MEDIUM, + "mattermost": _TIER_MEDIUM, + "matrix": _TIER_MEDIUM, + "feishu": _TIER_MEDIUM, + + # Tier 3 — no edit support, progress messages are permanent + "signal": _TIER_LOW, + "whatsapp": _TIER_MEDIUM, # Baileys bridge supports /edit + "bluebubbles": _TIER_LOW, + "weixin": _TIER_LOW, + "wecom": _TIER_LOW, + "wecom_callback": _TIER_LOW, + "dingtalk": _TIER_LOW, + + # Tier 4 — batch or non-interactive delivery + "email": _TIER_MINIMAL, + "sms": _TIER_MINIMAL, + "webhook": _TIER_MINIMAL, + "homeassistant": _TIER_MINIMAL, + "api_server": {**_TIER_HIGH, "tool_preview_length": 0}, +} + +# Canonical set of per-platform overrideable keys (for validation). +OVERRIDEABLE_KEYS = frozenset(_GLOBAL_DEFAULTS.keys()) + + +def resolve_display_setting( + user_config: dict, + platform_key: str, + setting: str, + fallback: Any = None, +) -> Any: + """Resolve a display setting with per-platform override support. + + Parameters + ---------- + user_config : dict + The full parsed config.yaml dict. + platform_key : str + Platform config key (e.g. ``"telegram"``, ``"slack"``). Use + ``_platform_config_key(source.platform)`` from gateway/run.py. + setting : str + Display setting name (e.g. ``"tool_progress"``, ``"show_reasoning"``). + fallback : Any + Fallback value when the setting isn't found anywhere. + + Returns + ------- + The resolved value, or *fallback* if nothing is configured. + """ + display_cfg = user_config.get("display") or {} + + # 1. Explicit per-platform override (display.platforms..) + platforms = display_cfg.get("platforms") or {} + plat_overrides = platforms.get(platform_key) + if isinstance(plat_overrides, dict): + val = plat_overrides.get(setting) + if val is not None: + return _normalise(setting, val) + + # 1b. Backward compat: display.tool_progress_overrides. + if setting == "tool_progress": + legacy = display_cfg.get("tool_progress_overrides") + if isinstance(legacy, dict): + val = legacy.get(platform_key) + if val is not None: + return _normalise(setting, val) + + # 2. Global user setting (display.) + val = display_cfg.get(setting) + if val is not None: + return _normalise(setting, val) + + # 3. Built-in platform default + plat_defaults = _PLATFORM_DEFAULTS.get(platform_key) + if plat_defaults: + val = plat_defaults.get(setting) + if val is not None: + return val + + # 4. Built-in global default + val = _GLOBAL_DEFAULTS.get(setting) + if val is not None: + return val + + return fallback + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _normalise(setting: str, value: Any) -> Any: + """Normalise YAML quirks (bare ``off`` → False in YAML 1.1).""" + if setting == "tool_progress": + if value is False: + return "off" + if value is True: + return "all" + return str(value).lower() + if setting in ("show_reasoning", "streaming"): + if isinstance(value, str): + return value.lower() in ("true", "1", "yes", "on") + return bool(value) + if setting == "tool_preview_length": + try: + return int(value) + except (TypeError, ValueError): + return 0 + return value diff --git a/mindcli/_vendor/gateway/hooks.py b/mindcli/_vendor/gateway/hooks.py new file mode 100644 index 0000000..c50394b --- /dev/null +++ b/mindcli/_vendor/gateway/hooks.py @@ -0,0 +1,170 @@ +""" +Event Hook System + +A lightweight event-driven system that fires handlers at key lifecycle points. +Hooks are discovered from ~/.hermes/hooks/ directories, each containing: + - HOOK.yaml (metadata: name, description, events list) + - handler.py (Python handler with async def handle(event_type, context)) + +Events: + - gateway:startup -- Gateway process starts + - session:start -- New session created (first message of a new session) + - session:end -- Session ends (user ran /new or /reset) + - session:reset -- Session reset completed (new session entry created) + - agent:start -- Agent begins processing a message + - agent:step -- Each turn in the tool-calling loop + - agent:end -- Agent finishes processing + - command:* -- Any slash command executed (wildcard match) + +Errors in hooks are caught and logged but never block the main pipeline. +""" + +import asyncio +import importlib.util +from typing import Any, Callable, Dict, List, Optional + +import yaml + +from hermes_cli.config import get_hermes_home + + +HOOKS_DIR = get_hermes_home() / "hooks" + + +class HookRegistry: + """ + Discovers, loads, and fires event hooks. + + Usage: + registry = HookRegistry() + registry.discover_and_load() + await registry.emit("agent:start", {"platform": "telegram", ...}) + """ + + def __init__(self): + # event_type -> [handler_fn, ...] + self._handlers: Dict[str, List[Callable]] = {} + self._loaded_hooks: List[dict] = [] # metadata for listing + + @property + def loaded_hooks(self) -> List[dict]: + """Return metadata about all loaded hooks.""" + return list(self._loaded_hooks) + + def _register_builtin_hooks(self) -> None: + """Register built-in hooks that are always active.""" + try: + from gateway.builtin_hooks.boot_md import handle as boot_md_handle + + self._handlers.setdefault("gateway:startup", []).append(boot_md_handle) + self._loaded_hooks.append({ + "name": "boot-md", + "description": "Run ~/.hermes/BOOT.md on gateway startup", + "events": ["gateway:startup"], + "path": "(builtin)", + }) + except Exception as e: + print(f"[hooks] Could not load built-in boot-md hook: {e}", flush=True) + + def discover_and_load(self) -> None: + """ + Scan the hooks directory for hook directories and load their handlers. + + Also registers built-in hooks that are always active. + + Each hook directory must contain: + - HOOK.yaml with at least 'name' and 'events' keys + - handler.py with a top-level 'handle' function (sync or async) + """ + self._register_builtin_hooks() + + if not HOOKS_DIR.exists(): + return + + for hook_dir in sorted(HOOKS_DIR.iterdir()): + if not hook_dir.is_dir(): + continue + + manifest_path = hook_dir / "HOOK.yaml" + handler_path = hook_dir / "handler.py" + + if not manifest_path.exists() or not handler_path.exists(): + continue + + try: + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + if not manifest or not isinstance(manifest, dict): + print(f"[hooks] Skipping {hook_dir.name}: invalid HOOK.yaml", flush=True) + continue + + hook_name = manifest.get("name", hook_dir.name) + events = manifest.get("events", []) + if not events: + print(f"[hooks] Skipping {hook_name}: no events declared", flush=True) + continue + + # Dynamically load the handler module + spec = importlib.util.spec_from_file_location( + f"hermes_hook_{hook_name}", handler_path + ) + if spec is None or spec.loader is None: + print(f"[hooks] Skipping {hook_name}: could not load handler.py", flush=True) + continue + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + handle_fn = getattr(module, "handle", None) + if handle_fn is None: + print(f"[hooks] Skipping {hook_name}: no 'handle' function found", flush=True) + continue + + # Register the handler for each declared event + for event in events: + self._handlers.setdefault(event, []).append(handle_fn) + + self._loaded_hooks.append({ + "name": hook_name, + "description": manifest.get("description", ""), + "events": events, + "path": str(hook_dir), + }) + + print(f"[hooks] Loaded hook '{hook_name}' for events: {events}", flush=True) + + except Exception as e: + print(f"[hooks] Error loading hook {hook_dir.name}: {e}", flush=True) + + async def emit(self, event_type: str, context: Optional[Dict[str, Any]] = None) -> None: + """ + Fire all handlers registered for an event. + + Supports wildcard matching: handlers registered for "command:*" will + fire for any "command:..." event. Handlers registered for a base type + like "agent" won't fire for "agent:start" -- only exact matches and + explicit wildcards. + + Args: + event_type: The event identifier (e.g. "agent:start"). + context: Optional dict with event-specific data. + """ + if context is None: + context = {} + + # Collect handlers: exact match + wildcard match + handlers = list(self._handlers.get(event_type, [])) + + # Check for wildcard patterns (e.g., "command:*" matches "command:reset") + if ":" in event_type: + base = event_type.split(":")[0] + wildcard_key = f"{base}:*" + handlers.extend(self._handlers.get(wildcard_key, [])) + + for fn in handlers: + try: + result = fn(event_type, context) + # Support both sync and async handlers + if asyncio.iscoroutine(result): + await result + except Exception as e: + print(f"[hooks] Error in handler for '{event_type}': {e}", flush=True) diff --git a/mindcli/_vendor/gateway/mirror.py b/mindcli/_vendor/gateway/mirror.py new file mode 100644 index 0000000..0312424 --- /dev/null +++ b/mindcli/_vendor/gateway/mirror.py @@ -0,0 +1,132 @@ +""" +Session mirroring for cross-platform message delivery. + +When a message is sent to a platform (via send_message or cron delivery), +this module appends a "delivery-mirror" record to the target session's +transcript so the receiving-side agent has context about what was sent. + +Standalone -- works from CLI, cron, and gateway contexts without needing +the full SessionStore machinery. +""" + +import json +import logging +from datetime import datetime +from typing import Optional + +from hermes_cli.config import get_hermes_home + +logger = logging.getLogger(__name__) + +_SESSIONS_DIR = get_hermes_home() / "sessions" +_SESSIONS_INDEX = _SESSIONS_DIR / "sessions.json" + + +def mirror_to_session( + platform: str, + chat_id: str, + message_text: str, + source_label: str = "cli", + thread_id: Optional[str] = None, +) -> bool: + """ + Append a delivery-mirror message to the target session's transcript. + + Finds the gateway session that matches the given platform + chat_id, + then writes a mirror entry to both the JSONL transcript and SQLite DB. + + Returns True if mirrored successfully, False if no matching session or error. + All errors are caught -- this is never fatal. + """ + try: + session_id = _find_session_id(platform, str(chat_id), thread_id=thread_id) + if not session_id: + logger.debug("Mirror: no session found for %s:%s:%s", platform, chat_id, thread_id) + return False + + mirror_msg = { + "role": "assistant", + "content": message_text, + "timestamp": datetime.now().isoformat(), + "mirror": True, + "mirror_source": source_label, + } + + _append_to_jsonl(session_id, mirror_msg) + _append_to_sqlite(session_id, mirror_msg) + + logger.debug("Mirror: wrote to session %s (from %s)", session_id, source_label) + return True + + except Exception as e: + logger.debug("Mirror failed for %s:%s:%s: %s", platform, chat_id, thread_id, e) + return False + + +def _find_session_id(platform: str, chat_id: str, thread_id: Optional[str] = None) -> Optional[str]: + """ + Find the active session_id for a platform + chat_id pair. + + Scans sessions.json entries and matches where origin.chat_id == chat_id + on the right platform. DM session keys don't embed the chat_id + (e.g. "agent:main:telegram:dm"), so we check the origin dict. + """ + if not _SESSIONS_INDEX.exists(): + return None + + try: + with open(_SESSIONS_INDEX, encoding="utf-8") as f: + data = json.load(f) + except Exception: + return None + + platform_lower = platform.lower() + best_match = None + best_updated = "" + + for _key, entry in data.items(): + origin = entry.get("origin") or {} + entry_platform = (origin.get("platform") or entry.get("platform", "")).lower() + + if entry_platform != platform_lower: + continue + + origin_chat_id = str(origin.get("chat_id", "")) + if origin_chat_id == str(chat_id): + origin_thread_id = origin.get("thread_id") + if thread_id is not None and str(origin_thread_id or "") != str(thread_id): + continue + updated = entry.get("updated_at", "") + if updated > best_updated: + best_updated = updated + best_match = entry.get("session_id") + + return best_match + + +def _append_to_jsonl(session_id: str, message: dict) -> None: + """Append a message to the JSONL transcript file.""" + transcript_path = _SESSIONS_DIR / f"{session_id}.jsonl" + try: + with open(transcript_path, "a", encoding="utf-8") as f: + f.write(json.dumps(message, ensure_ascii=False) + "\n") + except Exception as e: + logger.debug("Mirror JSONL write failed: %s", e) + + +def _append_to_sqlite(session_id: str, message: dict) -> None: + """Append a message to the SQLite session database.""" + db = None + try: + from hermes_state import SessionDB + db = SessionDB() + db.append_message( + session_id=session_id, + role=message.get("role", "assistant"), + content=message.get("content"), + ) + except Exception as e: + logger.debug("Mirror SQLite write failed: %s", e) + finally: + if db is not None: + db.close() diff --git a/mindcli/_vendor/gateway/pairing.py b/mindcli/_vendor/gateway/pairing.py new file mode 100644 index 0000000..09b61fe --- /dev/null +++ b/mindcli/_vendor/gateway/pairing.py @@ -0,0 +1,309 @@ +""" +DM Pairing System + +Code-based approval flow for authorizing new users on messaging platforms. +Instead of static allowlists with user IDs, unknown users receive a one-time +pairing code that the bot owner approves via the CLI. + +Security features (based on OWASP + NIST SP 800-63-4 guidance): + - 8-char codes from 32-char unambiguous alphabet (no 0/O/1/I) + - Cryptographic randomness via secrets.choice() + - 1-hour code expiry + - Max 3 pending codes per platform + - Rate limiting: 1 request per user per 10 minutes + - Lockout after 5 failed approval attempts (1 hour) + - File permissions: chmod 0600 on all data files + - Codes are never logged to stdout + +Storage: ~/.hermes/pairing/ +""" + +import json +import os +import secrets +import tempfile +import threading +import time +from pathlib import Path +from typing import Optional + +from hermes_constants import get_hermes_dir + + +# Unambiguous alphabet -- excludes 0/O, 1/I to prevent confusion +ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" +CODE_LENGTH = 8 + +# Timing constants +CODE_TTL_SECONDS = 3600 # Codes expire after 1 hour +RATE_LIMIT_SECONDS = 600 # 1 request per user per 10 minutes +LOCKOUT_SECONDS = 3600 # Lockout duration after too many failures + +# Limits +MAX_PENDING_PER_PLATFORM = 3 # Max pending codes per platform +MAX_FAILED_ATTEMPTS = 5 # Failed approvals before lockout + +PAIRING_DIR = get_hermes_dir("platforms/pairing", "pairing") + + +def _secure_write(path: Path, data: str) -> None: + """Write data to file with restrictive permissions (owner read/write only). + + Uses a temp-file + atomic rename so readers always see either the old + complete file or the new one — never a partial write. + """ + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, str(path)) + try: + os.chmod(path, 0o600) + except OSError: + pass # Windows doesn't support chmod the same way + except BaseException: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + +class PairingStore: + """ + Manages pairing codes and approved user lists. + + Data files per platform: + - {platform}-pending.json : pending pairing requests + - {platform}-approved.json : approved (paired) users + - _rate_limits.json : rate limit tracking + """ + + def __init__(self): + PAIRING_DIR.mkdir(parents=True, exist_ok=True) + # Protects all read-modify-write cycles. The gateway runs multiple + # platform adapters concurrently in threads sharing one PairingStore. + self._lock = threading.RLock() + + def _pending_path(self, platform: str) -> Path: + return PAIRING_DIR / f"{platform}-pending.json" + + def _approved_path(self, platform: str) -> Path: + return PAIRING_DIR / f"{platform}-approved.json" + + def _rate_limit_path(self) -> Path: + return PAIRING_DIR / "_rate_limits.json" + + def _load_json(self, path: Path) -> dict: + if path.exists(): + try: + return json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {} + return {} + + def _save_json(self, path: Path, data: dict) -> None: + _secure_write(path, json.dumps(data, indent=2, ensure_ascii=False)) + + # ----- Approved users ----- + + def is_approved(self, platform: str, user_id: str) -> bool: + """Check if a user is approved (paired) on a platform.""" + approved = self._load_json(self._approved_path(platform)) + return user_id in approved + + def list_approved(self, platform: str = None) -> list: + """List approved users, optionally filtered by platform.""" + results = [] + platforms = [platform] if platform else self._all_platforms("approved") + for p in platforms: + approved = self._load_json(self._approved_path(p)) + for uid, info in approved.items(): + results.append({"platform": p, "user_id": uid, **info}) + return results + + def _approve_user(self, platform: str, user_id: str, user_name: str = "") -> None: + """Add a user to the approved list. Must be called under self._lock.""" + approved = self._load_json(self._approved_path(platform)) + approved[user_id] = { + "user_name": user_name, + "approved_at": time.time(), + } + self._save_json(self._approved_path(platform), approved) + + def revoke(self, platform: str, user_id: str) -> bool: + """Remove a user from the approved list. Returns True if found.""" + path = self._approved_path(platform) + with self._lock: + approved = self._load_json(path) + if user_id in approved: + del approved[user_id] + self._save_json(path, approved) + return True + return False + + # ----- Pending codes ----- + + def generate_code( + self, platform: str, user_id: str, user_name: str = "" + ) -> Optional[str]: + """ + Generate a pairing code for a new user. + + Returns the code string, or None if: + - User is rate-limited (too recent request) + - Max pending codes reached for this platform + - User/platform is in lockout due to failed attempts + """ + with self._lock: + self._cleanup_expired(platform) + + # Check lockout + if self._is_locked_out(platform): + return None + + # Check rate limit for this specific user + if self._is_rate_limited(platform, user_id): + return None + + # Check max pending + pending = self._load_json(self._pending_path(platform)) + if len(pending) >= MAX_PENDING_PER_PLATFORM: + return None + + # Generate cryptographically random code + code = "".join(secrets.choice(ALPHABET) for _ in range(CODE_LENGTH)) + + # Store pending request + pending[code] = { + "user_id": user_id, + "user_name": user_name, + "created_at": time.time(), + } + self._save_json(self._pending_path(platform), pending) + + # Record rate limit + self._record_rate_limit(platform, user_id) + + return code + + def approve_code(self, platform: str, code: str) -> Optional[dict]: + """ + Approve a pairing code. Adds the user to the approved list. + + Returns {user_id, user_name} on success, None if code is invalid/expired. + """ + with self._lock: + self._cleanup_expired(platform) + code = code.upper().strip() + + pending = self._load_json(self._pending_path(platform)) + if code not in pending: + self._record_failed_attempt(platform) + return None + + entry = pending.pop(code) + self._save_json(self._pending_path(platform), pending) + + # Add to approved list + self._approve_user(platform, entry["user_id"], entry.get("user_name", "")) + + return { + "user_id": entry["user_id"], + "user_name": entry.get("user_name", ""), + } + + def list_pending(self, platform: str = None) -> list: + """List pending pairing requests, optionally filtered by platform.""" + results = [] + platforms = [platform] if platform else self._all_platforms("pending") + for p in platforms: + self._cleanup_expired(p) + pending = self._load_json(self._pending_path(p)) + for code, info in pending.items(): + age_min = int((time.time() - info["created_at"]) / 60) + results.append({ + "platform": p, + "code": code, + "user_id": info["user_id"], + "user_name": info.get("user_name", ""), + "age_minutes": age_min, + }) + return results + + def clear_pending(self, platform: str = None) -> int: + """Clear all pending requests. Returns count removed.""" + with self._lock: + count = 0 + platforms = [platform] if platform else self._all_platforms("pending") + for p in platforms: + pending = self._load_json(self._pending_path(p)) + count += len(pending) + self._save_json(self._pending_path(p), {}) + return count + + # ----- Rate limiting and lockout ----- + + def _is_rate_limited(self, platform: str, user_id: str) -> bool: + """Check if a user has requested a code too recently.""" + limits = self._load_json(self._rate_limit_path()) + key = f"{platform}:{user_id}" + last_request = limits.get(key, 0) + return (time.time() - last_request) < RATE_LIMIT_SECONDS + + def _record_rate_limit(self, platform: str, user_id: str) -> None: + """Record the time of a pairing request for rate limiting.""" + limits = self._load_json(self._rate_limit_path()) + key = f"{platform}:{user_id}" + limits[key] = time.time() + self._save_json(self._rate_limit_path(), limits) + + def _is_locked_out(self, platform: str) -> bool: + """Check if a platform is in lockout due to failed approval attempts.""" + limits = self._load_json(self._rate_limit_path()) + lockout_key = f"_lockout:{platform}" + lockout_until = limits.get(lockout_key, 0) + return time.time() < lockout_until + + def _record_failed_attempt(self, platform: str) -> None: + """Record a failed approval attempt. Triggers lockout after MAX_FAILED_ATTEMPTS.""" + limits = self._load_json(self._rate_limit_path()) + fail_key = f"_failures:{platform}" + fails = limits.get(fail_key, 0) + 1 + limits[fail_key] = fails + if fails >= MAX_FAILED_ATTEMPTS: + lockout_key = f"_lockout:{platform}" + limits[lockout_key] = time.time() + LOCKOUT_SECONDS + limits[fail_key] = 0 # Reset counter + print(f"[pairing] Platform {platform} locked out for {LOCKOUT_SECONDS}s " + f"after {MAX_FAILED_ATTEMPTS} failed attempts", flush=True) + self._save_json(self._rate_limit_path(), limits) + + # ----- Cleanup ----- + + def _cleanup_expired(self, platform: str) -> None: + """Remove expired pending codes.""" + path = self._pending_path(platform) + pending = self._load_json(path) + now = time.time() + expired = [ + code for code, info in pending.items() + if (now - info["created_at"]) > CODE_TTL_SECONDS + ] + if expired: + for code in expired: + del pending[code] + self._save_json(path, pending) + + def _all_platforms(self, suffix: str) -> list: + """List all platforms that have data files of a given suffix.""" + platforms = [] + for f in PAIRING_DIR.iterdir(): + if f.name.endswith(f"-{suffix}.json"): + platform = f.name.replace(f"-{suffix}.json", "") + if not platform.startswith("_"): + platforms.append(platform) + return platforms diff --git a/mindcli/_vendor/gateway/platforms/ADDING_A_PLATFORM.md b/mindcli/_vendor/gateway/platforms/ADDING_A_PLATFORM.md new file mode 100644 index 0000000..f773f8c --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/ADDING_A_PLATFORM.md @@ -0,0 +1,313 @@ +# Adding a New Messaging Platform + +Checklist for integrating a new messaging platform into the Hermes gateway. +Use this as a reference when building a new adapter — every item here is a +real integration point that exists in the codebase. Missing any of them will +cause broken functionality, missing features, or inconsistent behavior. + +--- + +## 1. Core Adapter (`gateway/platforms/.py`) + +The adapter is a subclass of `BasePlatformAdapter` from `gateway/platforms/base.py`. + +### Required methods + +| Method | Purpose | +|--------|---------| +| `__init__(self, config)` | Parse config, init state. Call `super().__init__(config, Platform.YOUR_PLATFORM)` | +| `connect() -> bool` | Connect to the platform, start listeners. Return True on success | +| `disconnect()` | Stop listeners, close connections, cancel tasks | +| `send(chat_id, text, ...) -> SendResult` | Send a text message | +| `send_typing(chat_id)` | Send typing indicator | +| `send_image(chat_id, image_url, caption) -> SendResult` | Send an image | +| `get_chat_info(chat_id) -> dict` | Return `{name, type, chat_id}` for a chat | + +### Optional methods (have default stubs in base) + +| Method | Purpose | +|--------|---------| +| `send_document(chat_id, path, caption)` | Send a file attachment | +| `send_voice(chat_id, path)` | Send a voice message | +| `send_video(chat_id, path, caption)` | Send a video | +| `send_animation(chat_id, path, caption)` | Send a GIF/animation | +| `send_image_file(chat_id, path, caption)` | Send image from local file | + +### Required function + +```python +def check__requirements() -> bool: + """Check if this platform's dependencies are available.""" +``` + +### Key patterns to follow + +- Use `self.build_source(...)` to construct `SessionSource` objects +- Call `self.handle_message(event)` to dispatch inbound messages to the gateway +- Use `MessageEvent`, `MessageType`, `SendResult` from base +- Use `cache_image_from_bytes`, `cache_audio_from_bytes`, `cache_document_from_bytes` for attachments +- Filter self-messages (prevent reply loops) +- Filter sync/echo messages if the platform has them +- Redact sensitive identifiers (phone numbers, tokens) in all log output +- Implement reconnection with exponential backoff + jitter for streaming connections +- Set `MAX_MESSAGE_LENGTH` if the platform has message size limits + +--- + +## 2. Platform Enum (`gateway/config.py`) + +Add the platform to the `Platform` enum: + +```python +class Platform(Enum): + ... + YOUR_PLATFORM = "your_platform" +``` + +Add env var loading in `_apply_env_overrides()`: + +```python +# Your Platform +your_token = os.getenv("YOUR_PLATFORM_TOKEN") +if your_token: + if Platform.YOUR_PLATFORM not in config.platforms: + config.platforms[Platform.YOUR_PLATFORM] = PlatformConfig() + config.platforms[Platform.YOUR_PLATFORM].enabled = True + config.platforms[Platform.YOUR_PLATFORM].token = your_token +``` + +Update `get_connected_platforms()` if your platform doesn't use token/api_key +(e.g., WhatsApp uses `enabled` flag, Signal uses `extra` dict). + +--- + +## 3. Adapter Factory (`gateway/run.py`) + +Add to `_create_adapter()`: + +```python +elif platform == Platform.YOUR_PLATFORM: + from gateway.platforms.your_platform import YourAdapter, check_your_requirements + if not check_your_requirements(): + logger.warning("Your Platform: dependencies not met") + return None + return YourAdapter(config) +``` + +--- + +## 4. Authorization Maps (`gateway/run.py`) + +Add to BOTH dicts in `_is_user_authorized()`: + +```python +platform_env_map = { + ... + Platform.YOUR_PLATFORM: "YOUR_PLATFORM_ALLOWED_USERS", +} +platform_allow_all_map = { + ... + Platform.YOUR_PLATFORM: "YOUR_PLATFORM_ALLOW_ALL_USERS", +} +``` + +--- + +## 5. Session Source (`gateway/session.py`) + +If your platform needs extra identity fields (e.g., Signal's UUID alongside +phone number), add them to the `SessionSource` dataclass with `Optional` defaults, +and update `to_dict()`, `from_dict()`, and `build_source()` in base.py. + +--- + +## 6. System Prompt Hints (`agent/prompt_builder.py`) + +Add a `PLATFORM_HINTS` entry so the agent knows what platform it's on: + +```python +PLATFORM_HINTS = { + ... + "your_platform": ( + "You are on Your Platform. " + "Describe formatting capabilities, media support, etc." + ), +} +``` + +Without this, the agent won't know it's on your platform and may use +inappropriate formatting (e.g., markdown on platforms that don't render it). + +--- + +## 7. Toolset (`toolsets.py`) + +Add a named toolset for your platform: + +```python +"hermes-your-platform": { + "description": "Your Platform bot toolset", + "tools": _HERMES_CORE_TOOLS, + "includes": [] +}, +``` + +And add it to the `hermes-gateway` composite: + +```python +"hermes-gateway": { + "includes": [..., "hermes-your-platform"] +} +``` + +--- + +## 8. Cron Delivery (`cron/scheduler.py`) + +Add to `platform_map` in `_deliver_result()`: + +```python +platform_map = { + ... + "your_platform": Platform.YOUR_PLATFORM, +} +``` + +Without this, `cronjob(action="create", deliver="your_platform", ...)` silently fails. + +--- + +## 9. Send Message Tool (`tools/send_message_tool.py`) + +Add to `platform_map` in `send_message_tool()`: + +```python +platform_map = { + ... + "your_platform": Platform.YOUR_PLATFORM, +} +``` + +Add routing in `_send_to_platform()`: + +```python +elif platform == Platform.YOUR_PLATFORM: + return await _send_your_platform(pconfig, chat_id, message) +``` + +Implement `_send_your_platform()` — a standalone async function that sends +a single message without requiring the full adapter (for use by cron jobs +and the send_message tool outside the gateway process). + +Update the tool schema `target` description to include your platform example. + +--- + +## 10. Cronjob Tool Schema (`tools/cronjob_tools.py`) + +Update the `deliver` parameter description and docstring to mention your +platform as a delivery option. + +--- + +## 11. Channel Directory (`gateway/channel_directory.py`) + +If your platform can't enumerate chats (most can't), add it to the +session-based discovery list: + +```python +for plat_name in ("telegram", "whatsapp", "signal", "your_platform"): +``` + +--- + +## 12. Status Display (`hermes_cli/status.py`) + +Add to the `platforms` dict in the Messaging Platforms section: + +```python +platforms = { + ... + "Your Platform": ("YOUR_PLATFORM_TOKEN", "YOUR_PLATFORM_HOME_CHANNEL"), +} +``` + +--- + +## 13. Gateway Setup Wizard (`hermes_cli/gateway.py`) + +Add to the `_PLATFORMS` list: + +```python +{ + "key": "your_platform", + "label": "Your Platform", + "emoji": "📱", + "token_var": "YOUR_PLATFORM_TOKEN", + "setup_instructions": [...], + "vars": [...], +} +``` + +If your platform needs custom setup logic (connectivity testing, QR codes, +policy choices), add a `_setup_your_platform()` function and route to it +in the platform selection switch. + +Update `_platform_status()` if your platform's "configured" check differs +from the standard `bool(get_env_value(token_var))`. + +--- + +## 14. Phone/ID Redaction (`agent/redact.py`) + +If your platform uses sensitive identifiers (phone numbers, etc.), add a +regex pattern and redaction function to `agent/redact.py`. This ensures +identifiers are masked in ALL log output, not just your adapter's logs. + +--- + +## 15. Documentation + +| File | What to update | +|------|---------------| +| `README.md` | Platform list in feature table + documentation table | +| `AGENTS.md` | Gateway description + env var config section | +| `website/docs/user-guide/messaging/.md` | **NEW** — Full setup guide (see existing platform docs for template) | +| `website/docs/user-guide/messaging/index.md` | Architecture diagram, toolset table, security examples, Next Steps links | +| `website/docs/reference/environment-variables.md` | All env vars for the platform | + +--- + +## 16. Tests (`tests/gateway/test_.py`) + +Recommended test coverage: + +- Platform enum exists with correct value +- Config loading from env vars via `_apply_env_overrides` +- Adapter init (config parsing, allowlist handling, default values) +- Helper functions (redaction, parsing, file type detection) +- Session source round-trip (to_dict → from_dict) +- Authorization integration (platform in allowlist maps) +- Send message tool routing (platform in platform_map) + +Optional but valuable: +- Async tests for message handling flow (mock the platform API) +- SSE/WebSocket reconnection logic +- Attachment processing +- Group message filtering + +--- + +## Quick Verification + +After implementing everything, verify with: + +```bash +# All tests pass +python -m pytest tests/ -q + +# Grep for your platform name to find any missed integration points +grep -r "telegram\|discord\|whatsapp\|slack" gateway/ tools/ agent/ cron/ hermes_cli/ toolsets.py \ + --include="*.py" -l | sort -u +# Check each file in the output — if it mentions other platforms but not yours, you missed it +``` diff --git a/mindcli/_vendor/gateway/platforms/__init__.py b/mindcli/_vendor/gateway/platforms/__init__.py new file mode 100644 index 0000000..4eb26ed --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/__init__.py @@ -0,0 +1,19 @@ +""" +Platform adapters for messaging integrations. + +Each adapter handles: +- Receiving messages from a platform +- Sending messages/responses back +- Platform-specific authentication +- Message formatting and media handling +""" + +from .base import BasePlatformAdapter, MessageEvent, SendResult +from .qqbot import QQAdapter + +__all__ = [ + "BasePlatformAdapter", + "MessageEvent", + "SendResult", + "QQAdapter", +] diff --git a/mindcli/_vendor/gateway/platforms/api_server.py b/mindcli/_vendor/gateway/platforms/api_server.py new file mode 100644 index 0000000..9a49904 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/api_server.py @@ -0,0 +1,1904 @@ +""" +OpenAI-compatible API server platform adapter. + +Exposes an HTTP server with endpoints: +- POST /v1/chat/completions — OpenAI Chat Completions format (stateless; opt-in session continuity via X-Hermes-Session-Id header) +- POST /v1/responses — OpenAI Responses API format (stateful via previous_response_id) +- GET /v1/responses/{response_id} — Retrieve a stored response +- DELETE /v1/responses/{response_id} — Delete a stored response +- GET /v1/models — lists hermes-agent as an available model +- POST /v1/runs — start a run, returns run_id immediately (202) +- GET /v1/runs/{run_id}/events — SSE stream of structured lifecycle events +- GET /health — health check + +Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat, +AnythingLLM, NextChat, ChatBox, etc.) can connect to hermes-agent +through this adapter by pointing at http://localhost:8642/v1. + +Requires: +- aiohttp (already available in the gateway) +""" + +import asyncio +import hashlib +import hmac +import json +import logging +import os +import socket as _socket +import re +import sqlite3 +import time +import uuid +from typing import Any, Dict, List, Optional + +try: + from aiohttp import web + AIOHTTP_AVAILABLE = True +except ImportError: + AIOHTTP_AVAILABLE = False + web = None # type: ignore[assignment] + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + SendResult, + is_network_accessible, +) + +logger = logging.getLogger(__name__) + +# Default settings +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8642 +MAX_STORED_RESPONSES = 100 +MAX_REQUEST_BYTES = 1_000_000 # 1 MB default limit for POST bodies +CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS = 30.0 +MAX_NORMALIZED_TEXT_LENGTH = 65_536 # 64 KB cap for normalized content parts +MAX_CONTENT_LIST_SIZE = 1_000 # Max items when content is an array + + +def _normalize_chat_content( + content: Any, *, _max_depth: int = 10, _depth: int = 0, +) -> str: + """Normalize OpenAI chat message content into a plain text string. + + Some clients (Open WebUI, LobeChat, etc.) send content as an array of + typed parts instead of a plain string:: + + [{"type": "text", "text": "hello"}, {"type": "input_text", "text": "..."}] + + This function flattens those into a single string so the agent pipeline + (which expects strings) doesn't choke. + + Defensive limits prevent abuse: recursion depth, list size, and output + length are all bounded. + """ + if _depth > _max_depth: + return "" + if content is None: + return "" + if isinstance(content, str): + return content[:MAX_NORMALIZED_TEXT_LENGTH] if len(content) > MAX_NORMALIZED_TEXT_LENGTH else content + + if isinstance(content, list): + parts: List[str] = [] + items = content[:MAX_CONTENT_LIST_SIZE] if len(content) > MAX_CONTENT_LIST_SIZE else content + for item in items: + if isinstance(item, str): + if item: + parts.append(item[:MAX_NORMALIZED_TEXT_LENGTH]) + elif isinstance(item, dict): + item_type = str(item.get("type") or "").strip().lower() + if item_type in {"text", "input_text", "output_text"}: + text = item.get("text", "") + if text: + try: + parts.append(str(text)[:MAX_NORMALIZED_TEXT_LENGTH]) + except Exception: + pass + # Silently skip image_url / other non-text parts + elif isinstance(item, list): + nested = _normalize_chat_content(item, _max_depth=_max_depth, _depth=_depth + 1) + if nested: + parts.append(nested) + # Check accumulated size + if sum(len(p) for p in parts) >= MAX_NORMALIZED_TEXT_LENGTH: + break + result = "\n".join(parts) + return result[:MAX_NORMALIZED_TEXT_LENGTH] if len(result) > MAX_NORMALIZED_TEXT_LENGTH else result + + # Fallback for unexpected types (int, float, bool, etc.) + try: + result = str(content) + return result[:MAX_NORMALIZED_TEXT_LENGTH] if len(result) > MAX_NORMALIZED_TEXT_LENGTH else result + except Exception: + return "" + + +def check_api_server_requirements() -> bool: + """Check if API server dependencies are available.""" + return AIOHTTP_AVAILABLE + + +class ResponseStore: + """ + SQLite-backed LRU store for Responses API state. + + Each stored response includes the full internal conversation history + (with tool calls and results) so it can be reconstructed on subsequent + requests via previous_response_id. + + Persists across gateway restarts. Falls back to in-memory SQLite + if the on-disk path is unavailable. + """ + + def __init__(self, max_size: int = MAX_STORED_RESPONSES, db_path: str = None): + self._max_size = max_size + if db_path is None: + try: + from hermes_cli.config import get_hermes_home + db_path = str(get_hermes_home() / "response_store.db") + except Exception: + db_path = ":memory:" + try: + self._conn = sqlite3.connect(db_path, check_same_thread=False) + except Exception: + self._conn = sqlite3.connect(":memory:", check_same_thread=False) + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.execute( + """CREATE TABLE IF NOT EXISTS responses ( + response_id TEXT PRIMARY KEY, + data TEXT NOT NULL, + accessed_at REAL NOT NULL + )""" + ) + self._conn.execute( + """CREATE TABLE IF NOT EXISTS conversations ( + name TEXT PRIMARY KEY, + response_id TEXT NOT NULL + )""" + ) + self._conn.commit() + + def get(self, response_id: str) -> Optional[Dict[str, Any]]: + """Retrieve a stored response by ID (updates access time for LRU).""" + row = self._conn.execute( + "SELECT data FROM responses WHERE response_id = ?", (response_id,) + ).fetchone() + if row is None: + return None + import time + self._conn.execute( + "UPDATE responses SET accessed_at = ? WHERE response_id = ?", + (time.time(), response_id), + ) + self._conn.commit() + return json.loads(row[0]) + + def put(self, response_id: str, data: Dict[str, Any]) -> None: + """Store a response, evicting the oldest if at capacity.""" + import time + self._conn.execute( + "INSERT OR REPLACE INTO responses (response_id, data, accessed_at) VALUES (?, ?, ?)", + (response_id, json.dumps(data, default=str), time.time()), + ) + # Evict oldest entries beyond max_size + count = self._conn.execute("SELECT COUNT(*) FROM responses").fetchone()[0] + if count > self._max_size: + self._conn.execute( + "DELETE FROM responses WHERE response_id IN " + "(SELECT response_id FROM responses ORDER BY accessed_at ASC LIMIT ?)", + (count - self._max_size,), + ) + self._conn.commit() + + def delete(self, response_id: str) -> bool: + """Remove a response from the store. Returns True if found and deleted.""" + cursor = self._conn.execute( + "DELETE FROM responses WHERE response_id = ?", (response_id,) + ) + self._conn.commit() + return cursor.rowcount > 0 + + def get_conversation(self, name: str) -> Optional[str]: + """Get the latest response_id for a conversation name.""" + row = self._conn.execute( + "SELECT response_id FROM conversations WHERE name = ?", (name,) + ).fetchone() + return row[0] if row else None + + def set_conversation(self, name: str, response_id: str) -> None: + """Map a conversation name to its latest response_id.""" + self._conn.execute( + "INSERT OR REPLACE INTO conversations (name, response_id) VALUES (?, ?)", + (name, response_id), + ) + self._conn.commit() + + def close(self) -> None: + """Close the database connection.""" + try: + self._conn.close() + except Exception: + pass + + def __len__(self) -> int: + row = self._conn.execute("SELECT COUNT(*) FROM responses").fetchone() + return row[0] if row else 0 + + +# --------------------------------------------------------------------------- +# CORS middleware +# --------------------------------------------------------------------------- + +_CORS_HEADERS = { + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Authorization, Content-Type, Idempotency-Key", +} + + +if AIOHTTP_AVAILABLE: + @web.middleware + async def cors_middleware(request, handler): + """Add CORS headers for explicitly allowed origins; handle OPTIONS preflight.""" + adapter = request.app.get("api_server_adapter") + origin = request.headers.get("Origin", "") + cors_headers = None + if adapter is not None: + if not adapter._origin_allowed(origin): + return web.Response(status=403) + cors_headers = adapter._cors_headers_for_origin(origin) + + if request.method == "OPTIONS": + if cors_headers is None: + return web.Response(status=403) + return web.Response(status=200, headers=cors_headers) + + response = await handler(request) + if cors_headers is not None: + response.headers.update(cors_headers) + return response +else: + cors_middleware = None # type: ignore[assignment] + + +def _openai_error(message: str, err_type: str = "invalid_request_error", param: str = None, code: str = None) -> Dict[str, Any]: + """OpenAI-style error envelope.""" + return { + "error": { + "message": message, + "type": err_type, + "param": param, + "code": code, + } + } + + +if AIOHTTP_AVAILABLE: + @web.middleware + async def body_limit_middleware(request, handler): + """Reject overly large request bodies early based on Content-Length.""" + if request.method in ("POST", "PUT", "PATCH"): + cl = request.headers.get("Content-Length") + if cl is not None: + try: + if int(cl) > MAX_REQUEST_BYTES: + return web.json_response(_openai_error("Request body too large.", code="body_too_large"), status=413) + except ValueError: + return web.json_response(_openai_error("Invalid Content-Length header.", code="invalid_content_length"), status=400) + return await handler(request) +else: + body_limit_middleware = None # type: ignore[assignment] + +_SECURITY_HEADERS = { + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", +} + + +if AIOHTTP_AVAILABLE: + @web.middleware + async def security_headers_middleware(request, handler): + """Add security headers to all responses (including errors).""" + response = await handler(request) + for k, v in _SECURITY_HEADERS.items(): + response.headers.setdefault(k, v) + return response +else: + security_headers_middleware = None # type: ignore[assignment] + + +class _IdempotencyCache: + """In-memory idempotency cache with TTL and basic LRU semantics.""" + def __init__(self, max_items: int = 1000, ttl_seconds: int = 300): + from collections import OrderedDict + self._store = OrderedDict() + self._ttl = ttl_seconds + self._max = max_items + + def _purge(self): + import time as _t + now = _t.time() + expired = [k for k, v in self._store.items() if now - v["ts"] > self._ttl] + for k in expired: + self._store.pop(k, None) + while len(self._store) > self._max: + self._store.popitem(last=False) + + async def get_or_set(self, key: str, fingerprint: str, compute_coro): + self._purge() + item = self._store.get(key) + if item and item["fp"] == fingerprint: + return item["resp"] + resp = await compute_coro() + import time as _t + self._store[key] = {"resp": resp, "fp": fingerprint, "ts": _t.time()} + self._purge() + return resp + + +_idem_cache = _IdempotencyCache() + + +def _make_request_fingerprint(body: Dict[str, Any], keys: List[str]) -> str: + from hashlib import sha256 + subset = {k: body.get(k) for k in keys} + return sha256(repr(subset).encode("utf-8")).hexdigest() + + +def _derive_chat_session_id( + system_prompt: Optional[str], + first_user_message: str, +) -> str: + """Derive a stable session ID from the conversation's first user message. + + OpenAI-compatible frontends (Open WebUI, LibreChat, etc.) send the full + conversation history with every request. The system prompt and first user + message are constant across all turns of the same conversation, so hashing + them produces a deterministic session ID that lets the API server reuse + the same Hermes session (and therefore the same Docker container sandbox + directory) across turns. + """ + seed = f"{system_prompt or ''}\n{first_user_message}" + digest = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16] + return f"api-{digest}" + + +class APIServerAdapter(BasePlatformAdapter): + """ + OpenAI-compatible HTTP API server adapter. + + Runs an aiohttp web server that accepts OpenAI-format requests + and routes them through hermes-agent's AIAgent. + """ + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.API_SERVER) + extra = config.extra or {} + self._host: str = extra.get("host", os.getenv("API_SERVER_HOST", DEFAULT_HOST)) + self._port: int = int(extra.get("port", os.getenv("API_SERVER_PORT", str(DEFAULT_PORT)))) + self._api_key: str = extra.get("key", os.getenv("API_SERVER_KEY", "")) + self._cors_origins: tuple[str, ...] = self._parse_cors_origins( + extra.get("cors_origins", os.getenv("API_SERVER_CORS_ORIGINS", "")), + ) + self._model_name: str = self._resolve_model_name( + extra.get("model_name", os.getenv("API_SERVER_MODEL_NAME", "")), + ) + self._app: Optional["web.Application"] = None + self._runner: Optional["web.AppRunner"] = None + self._site: Optional["web.TCPSite"] = None + self._response_store = ResponseStore() + # Active run streams: run_id -> asyncio.Queue of SSE event dicts + self._run_streams: Dict[str, "asyncio.Queue[Optional[Dict]]"] = {} + # Creation timestamps for orphaned-run TTL sweep + self._run_streams_created: Dict[str, float] = {} + self._session_db: Optional[Any] = None # Lazy-init SessionDB for session continuity + + @staticmethod + def _parse_cors_origins(value: Any) -> tuple[str, ...]: + """Normalize configured CORS origins into a stable tuple.""" + if not value: + return () + + if isinstance(value, str): + items = value.split(",") + elif isinstance(value, (list, tuple, set)): + items = value + else: + items = [str(value)] + + return tuple(str(item).strip() for item in items if str(item).strip()) + + @staticmethod + def _resolve_model_name(explicit: str) -> str: + """Derive the advertised model name for /v1/models. + + Priority: + 1. Explicit override (config extra or API_SERVER_MODEL_NAME env var) + 2. Active profile name (so each profile advertises a distinct model) + 3. Fallback: "hermes-agent" + """ + if explicit and explicit.strip(): + return explicit.strip() + try: + from hermes_cli.profiles import get_active_profile_name + profile = get_active_profile_name() + if profile and profile not in ("default", "custom"): + return profile + except Exception: + pass + return "hermes-agent" + + def _cors_headers_for_origin(self, origin: str) -> Optional[Dict[str, str]]: + """Return CORS headers for an allowed browser origin.""" + if not origin or not self._cors_origins: + return None + + if "*" in self._cors_origins: + headers = dict(_CORS_HEADERS) + headers["Access-Control-Allow-Origin"] = "*" + headers["Access-Control-Max-Age"] = "600" + return headers + + if origin not in self._cors_origins: + return None + + headers = dict(_CORS_HEADERS) + headers["Access-Control-Allow-Origin"] = origin + headers["Vary"] = "Origin" + headers["Access-Control-Max-Age"] = "600" + return headers + + def _origin_allowed(self, origin: str) -> bool: + """Allow non-browser clients and explicitly configured browser origins.""" + if not origin: + return True + + if not self._cors_origins: + return False + + return "*" in self._cors_origins or origin in self._cors_origins + + # ------------------------------------------------------------------ + # Auth helper + # ------------------------------------------------------------------ + + def _check_auth(self, request: "web.Request") -> Optional["web.Response"]: + """ + Validate Bearer token from Authorization header. + + Returns None if auth is OK, or a 401 web.Response on failure. + If no API key is configured, all requests are allowed (only when API + server is local). + """ + if not self._api_key: + return None # No key configured — allow all (local-only use) + + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:].strip() + if hmac.compare_digest(token, self._api_key): + return None # Auth OK + + return web.json_response( + {"error": {"message": "Invalid API key", "type": "invalid_request_error", "code": "invalid_api_key"}}, + status=401, + ) + + # ------------------------------------------------------------------ + # Session DB helper + # ------------------------------------------------------------------ + + def _ensure_session_db(self): + """Lazily initialise and return the shared SessionDB instance. + + Sessions are persisted to ``state.db`` so that ``hermes sessions list`` + shows API-server conversations alongside CLI and gateway ones. + """ + if self._session_db is None: + try: + from hermes_state import SessionDB + self._session_db = SessionDB() + except Exception as e: + logger.debug("SessionDB unavailable for API server: %s", e) + return self._session_db + + # ------------------------------------------------------------------ + # Agent creation helper + # ------------------------------------------------------------------ + + def _create_agent( + self, + ephemeral_system_prompt: Optional[str] = None, + session_id: Optional[str] = None, + stream_delta_callback=None, + tool_progress_callback=None, + ) -> Any: + """ + Create an AIAgent instance using the gateway's runtime config. + + Uses _resolve_runtime_agent_kwargs() to pick up model, api_key, + base_url, etc. from config.yaml / env vars. Toolsets are resolved + from config.yaml platform_toolsets.api_server (same as all other + gateway platforms), falling back to the hermes-api-server default. + """ + from run_agent import AIAgent + from gateway.run import _resolve_runtime_agent_kwargs, _resolve_gateway_model, _load_gateway_config + from hermes_cli.tools_config import _get_platform_tools + + runtime_kwargs = _resolve_runtime_agent_kwargs() + model = _resolve_gateway_model() + + user_config = _load_gateway_config() + enabled_toolsets = sorted(_get_platform_tools(user_config, "api_server")) + + max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90")) + + # Load fallback provider chain so the API server platform has the + # same fallback behaviour as Telegram/Discord/Slack (fixes #4954). + from gateway.run import GatewayRunner + fallback_model = GatewayRunner._load_fallback_model() + + agent = AIAgent( + model=model, + **runtime_kwargs, + max_iterations=max_iterations, + quiet_mode=True, + verbose_logging=False, + ephemeral_system_prompt=ephemeral_system_prompt or None, + enabled_toolsets=enabled_toolsets, + session_id=session_id, + platform="api_server", + stream_delta_callback=stream_delta_callback, + tool_progress_callback=tool_progress_callback, + session_db=self._ensure_session_db(), + fallback_model=fallback_model, + ) + return agent + + # ------------------------------------------------------------------ + # HTTP Handlers + # ------------------------------------------------------------------ + + async def _handle_health(self, request: "web.Request") -> "web.Response": + """GET /health — simple health check.""" + return web.json_response({"status": "ok", "platform": "hermes-agent"}) + + async def _handle_models(self, request: "web.Request") -> "web.Response": + """GET /v1/models — return hermes-agent as an available model.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + + return web.json_response({ + "object": "list", + "data": [ + { + "id": self._model_name, + "object": "model", + "created": int(time.time()), + "owned_by": "hermes", + "permission": [], + "root": self._model_name, + "parent": None, + } + ], + }) + + async def _handle_chat_completions(self, request: "web.Request") -> "web.Response": + """POST /v1/chat/completions — OpenAI Chat Completions format.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + + # Parse request body + try: + body = await request.json() + except (json.JSONDecodeError, Exception): + return web.json_response(_openai_error("Invalid JSON in request body"), status=400) + + messages = body.get("messages") + if not messages or not isinstance(messages, list): + return web.json_response( + {"error": {"message": "Missing or invalid 'messages' field", "type": "invalid_request_error"}}, + status=400, + ) + + stream = body.get("stream", False) + + # Extract system message (becomes ephemeral system prompt layered ON TOP of core) + system_prompt = None + conversation_messages: List[Dict[str, str]] = [] + + for msg in messages: + role = msg.get("role", "") + content = _normalize_chat_content(msg.get("content", "")) + if role == "system": + # Accumulate system messages + if system_prompt is None: + system_prompt = content + else: + system_prompt = system_prompt + "\n" + content + elif role in ("user", "assistant"): + conversation_messages.append({"role": role, "content": content}) + + # Extract the last user message as the primary input + user_message = "" + history = [] + if conversation_messages: + user_message = conversation_messages[-1].get("content", "") + history = conversation_messages[:-1] + + if not user_message: + return web.json_response( + {"error": {"message": "No user message found in messages", "type": "invalid_request_error"}}, + status=400, + ) + + # Allow caller to continue an existing session by passing X-Hermes-Session-Id. + # When provided, history is loaded from state.db instead of from the request body. + # + # Security: session continuation exposes conversation history, so it is + # only allowed when the API key is configured and the request is + # authenticated. Without this gate, any unauthenticated client could + # read arbitrary session history by guessing/enumerating session IDs. + provided_session_id = request.headers.get("X-Hermes-Session-Id", "").strip() + if provided_session_id: + if not self._api_key: + logger.warning( + "Session continuation via X-Hermes-Session-Id rejected: " + "no API key configured. Set API_SERVER_KEY to enable " + "session continuity." + ) + return web.json_response( + _openai_error( + "Session continuation requires API key authentication. " + "Configure API_SERVER_KEY to enable this feature." + ), + status=403, + ) + # Sanitize: reject control characters that could enable header injection. + if re.search(r'[\r\n\x00]', provided_session_id): + return web.json_response( + {"error": {"message": "Invalid session ID", "type": "invalid_request_error"}}, + status=400, + ) + session_id = provided_session_id + try: + db = self._ensure_session_db() + if db is not None: + history = db.get_messages_as_conversation(session_id) + except Exception as e: + logger.warning("Failed to load session history for %s: %s", session_id, e) + history = [] + else: + # Derive a stable session ID from the conversation fingerprint so + # that consecutive messages from the same Open WebUI (or similar) + # conversation map to the same Hermes session. The first user + # message + system prompt are constant across all turns. + first_user = "" + for cm in conversation_messages: + if cm.get("role") == "user": + first_user = cm.get("content", "") + break + session_id = _derive_chat_session_id(system_prompt, first_user) + # history already set from request body above + + completion_id = f"chatcmpl-{uuid.uuid4().hex[:29]}" + model_name = body.get("model", self._model_name) + created = int(time.time()) + + if stream: + import queue as _q + _stream_q: _q.Queue = _q.Queue() + + def _on_delta(delta): + # Filter out None — the agent fires stream_delta_callback(None) + # to signal the CLI display to close its response box before + # tool execution, but the SSE writer uses None as end-of-stream + # sentinel. Forwarding it would prematurely close the HTTP + # response, causing Open WebUI (and similar frontends) to miss + # the final answer after tool calls. The SSE loop detects + # completion via agent_task.done() instead. + if delta is not None: + _stream_q.put(delta) + + def _on_tool_progress(event_type, name, preview, args, **kwargs): + """Send tool progress as a separate SSE event. + + Previously, progress markers like ``⏰ list`` were injected + directly into ``delta.content``. OpenAI-compatible frontends + (Open WebUI, LobeChat, …) store ``delta.content`` verbatim as + the assistant message and send it back on subsequent requests. + After enough turns the model learns to *emit* the markers as + plain text instead of issuing real tool calls — silently + hallucinating tool results. See #6972. + + The fix: push a tagged tuple ``("__tool_progress__", payload)`` + onto the stream queue. The SSE writer emits it as a custom + ``event: hermes.tool.progress`` line that compliant frontends + can render for UX but will *not* persist into conversation + history. Clients that don't understand the custom event type + silently ignore it per the SSE specification. + """ + if event_type != "tool.started": + return + if name.startswith("_"): + return + from agent.display import get_tool_emoji + emoji = get_tool_emoji(name) + label = preview or name + _stream_q.put(("__tool_progress__", { + "tool": name, + "emoji": emoji, + "label": label, + })) + + # Start agent in background. agent_ref is a mutable container + # so the SSE writer can interrupt the agent on client disconnect. + agent_ref = [None] + agent_task = asyncio.ensure_future(self._run_agent( + user_message=user_message, + conversation_history=history, + ephemeral_system_prompt=system_prompt, + session_id=session_id, + stream_delta_callback=_on_delta, + tool_progress_callback=_on_tool_progress, + agent_ref=agent_ref, + )) + + return await self._write_sse_chat_completion( + request, completion_id, model_name, created, _stream_q, + agent_task, agent_ref, session_id=session_id, + ) + + # Non-streaming: run the agent (with optional Idempotency-Key) + async def _compute_completion(): + return await self._run_agent( + user_message=user_message, + conversation_history=history, + ephemeral_system_prompt=system_prompt, + session_id=session_id, + ) + + idempotency_key = request.headers.get("Idempotency-Key") + if idempotency_key: + fp = _make_request_fingerprint(body, keys=["model", "messages", "tools", "tool_choice", "stream"]) + try: + result, usage = await _idem_cache.get_or_set(idempotency_key, fp, _compute_completion) + except Exception as e: + logger.error("Error running agent for chat completions: %s", e, exc_info=True) + return web.json_response( + _openai_error(f"Internal server error: {e}", err_type="server_error"), + status=500, + ) + else: + try: + result, usage = await _compute_completion() + except Exception as e: + logger.error("Error running agent for chat completions: %s", e, exc_info=True) + return web.json_response( + _openai_error(f"Internal server error: {e}", err_type="server_error"), + status=500, + ) + + final_response = result.get("final_response", "") + if not final_response: + final_response = result.get("error", "(No response generated)") + + response_data = { + "id": completion_id, + "object": "chat.completion", + "created": created, + "model": model_name, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": final_response, + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": usage.get("input_tokens", 0), + "completion_tokens": usage.get("output_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + }, + } + + return web.json_response(response_data, headers={"X-Hermes-Session-Id": session_id}) + + async def _write_sse_chat_completion( + self, request: "web.Request", completion_id: str, model: str, + created: int, stream_q, agent_task, agent_ref=None, session_id: str = None, + ) -> "web.StreamResponse": + """Write real streaming SSE from agent's stream_delta_callback queue. + + If the client disconnects mid-stream (network drop, browser tab close), + the agent is interrupted via ``agent.interrupt()`` so it stops making + LLM API calls, and the asyncio task wrapper is cancelled. + """ + import queue as _q + + sse_headers = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + } + # CORS middleware can't inject headers into StreamResponse after + # prepare() flushes them, so resolve CORS headers up front. + origin = request.headers.get("Origin", "") + cors = self._cors_headers_for_origin(origin) if origin else None + if cors: + sse_headers.update(cors) + if session_id: + sse_headers["X-Hermes-Session-Id"] = session_id + response = web.StreamResponse(status=200, headers=sse_headers) + await response.prepare(request) + + try: + last_activity = time.monotonic() + + # Role chunk + role_chunk = { + "id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}], + } + await response.write(f"data: {json.dumps(role_chunk)}\n\n".encode()) + last_activity = time.monotonic() + + # Helper — route a queue item to the correct SSE event. + async def _emit(item): + """Write a single queue item to the SSE stream. + + Plain strings are sent as normal ``delta.content`` chunks. + Tagged tuples ``("__tool_progress__", payload)`` are sent + as a custom ``event: hermes.tool.progress`` SSE event so + frontends can display them without storing the markers in + conversation history. See #6972. + """ + if isinstance(item, tuple) and len(item) == 2 and item[0] == "__tool_progress__": + event_data = json.dumps(item[1]) + await response.write( + f"event: hermes.tool.progress\ndata: {event_data}\n\n".encode() + ) + else: + content_chunk = { + "id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {"content": item}, "finish_reason": None}], + } + await response.write(f"data: {json.dumps(content_chunk)}\n\n".encode()) + return time.monotonic() + + # Stream content chunks as they arrive from the agent + loop = asyncio.get_event_loop() + while True: + try: + delta = await loop.run_in_executor(None, lambda: stream_q.get(timeout=0.5)) + except _q.Empty: + if agent_task.done(): + # Drain any remaining items + while True: + try: + delta = stream_q.get_nowait() + if delta is None: + break + last_activity = await _emit(delta) + except _q.Empty: + break + break + if time.monotonic() - last_activity >= CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS: + await response.write(b": keepalive\n\n") + last_activity = time.monotonic() + continue + + if delta is None: # End of stream sentinel + break + + last_activity = await _emit(delta) + + # Get usage from completed agent + usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0} + try: + result, agent_usage = await agent_task + usage = agent_usage or usage + except Exception: + pass + + # Finish chunk + finish_chunk = { + "id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + "usage": { + "prompt_tokens": usage.get("input_tokens", 0), + "completion_tokens": usage.get("output_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + }, + } + await response.write(f"data: {json.dumps(finish_chunk)}\n\n".encode()) + await response.write(b"data: [DONE]\n\n") + except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError, OSError): + # Client disconnected mid-stream. Interrupt the agent so it + # stops making LLM API calls at the next loop iteration, then + # cancel the asyncio task wrapper. + agent = agent_ref[0] if agent_ref else None + if agent is not None: + try: + agent.interrupt("SSE client disconnected") + except Exception: + pass + if not agent_task.done(): + agent_task.cancel() + try: + await agent_task + except (asyncio.CancelledError, Exception): + pass + logger.info("SSE client disconnected; interrupted agent task %s", completion_id) + + return response + + async def _handle_responses(self, request: "web.Request") -> "web.Response": + """POST /v1/responses — OpenAI Responses API format.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + + # Parse request body + try: + body = await request.json() + except (json.JSONDecodeError, Exception): + return web.json_response( + {"error": {"message": "Invalid JSON in request body", "type": "invalid_request_error"}}, + status=400, + ) + + raw_input = body.get("input") + if raw_input is None: + return web.json_response(_openai_error("Missing 'input' field"), status=400) + + instructions = body.get("instructions") + previous_response_id = body.get("previous_response_id") + conversation = body.get("conversation") + store = body.get("store", True) + + # conversation and previous_response_id are mutually exclusive + if conversation and previous_response_id: + return web.json_response(_openai_error("Cannot use both 'conversation' and 'previous_response_id'"), status=400) + + # Resolve conversation name to latest response_id + if conversation: + previous_response_id = self._response_store.get_conversation(conversation) + # No error if conversation doesn't exist yet — it's a new conversation + + # Normalize input to message list + input_messages: List[Dict[str, str]] = [] + if isinstance(raw_input, str): + input_messages = [{"role": "user", "content": raw_input}] + elif isinstance(raw_input, list): + for item in raw_input: + if isinstance(item, str): + input_messages.append({"role": "user", "content": item}) + elif isinstance(item, dict): + role = item.get("role", "user") + content = _normalize_chat_content(item.get("content", "")) + input_messages.append({"role": role, "content": content}) + else: + return web.json_response(_openai_error("'input' must be a string or array"), status=400) + + # Accept explicit conversation_history from the request body. + # This lets stateless clients supply their own history instead of + # relying on server-side response chaining via previous_response_id. + # Precedence: explicit conversation_history > previous_response_id. + conversation_history: List[Dict[str, str]] = [] + raw_history = body.get("conversation_history") + if raw_history: + if not isinstance(raw_history, list): + return web.json_response( + _openai_error("'conversation_history' must be an array of message objects"), + status=400, + ) + for i, entry in enumerate(raw_history): + if not isinstance(entry, dict) or "role" not in entry or "content" not in entry: + return web.json_response( + _openai_error(f"conversation_history[{i}] must have 'role' and 'content' fields"), + status=400, + ) + conversation_history.append({"role": str(entry["role"]), "content": str(entry["content"])}) + if previous_response_id: + logger.debug("Both conversation_history and previous_response_id provided; using conversation_history") + + if not conversation_history and previous_response_id: + stored = self._response_store.get(previous_response_id) + if stored is None: + return web.json_response(_openai_error(f"Previous response not found: {previous_response_id}"), status=404) + conversation_history = list(stored.get("conversation_history", [])) + # If no instructions provided, carry forward from previous + if instructions is None: + instructions = stored.get("instructions") + + # Append new input messages to history (all but the last become history) + for msg in input_messages[:-1]: + conversation_history.append(msg) + + # Last input message is the user_message + user_message = input_messages[-1].get("content", "") if input_messages else "" + if not user_message: + return web.json_response(_openai_error("No user message found in input"), status=400) + + # Truncation support + if body.get("truncation") == "auto" and len(conversation_history) > 100: + conversation_history = conversation_history[-100:] + + # Run the agent (with Idempotency-Key support) + session_id = str(uuid.uuid4()) + + async def _compute_response(): + return await self._run_agent( + user_message=user_message, + conversation_history=conversation_history, + ephemeral_system_prompt=instructions, + session_id=session_id, + ) + + idempotency_key = request.headers.get("Idempotency-Key") + if idempotency_key: + fp = _make_request_fingerprint( + body, + keys=["input", "instructions", "previous_response_id", "conversation", "model", "tools"], + ) + try: + result, usage = await _idem_cache.get_or_set(idempotency_key, fp, _compute_response) + except Exception as e: + logger.error("Error running agent for responses: %s", e, exc_info=True) + return web.json_response( + _openai_error(f"Internal server error: {e}", err_type="server_error"), + status=500, + ) + else: + try: + result, usage = await _compute_response() + except Exception as e: + logger.error("Error running agent for responses: %s", e, exc_info=True) + return web.json_response( + _openai_error(f"Internal server error: {e}", err_type="server_error"), + status=500, + ) + + final_response = result.get("final_response", "") + if not final_response: + final_response = result.get("error", "(No response generated)") + + response_id = f"resp_{uuid.uuid4().hex[:28]}" + created_at = int(time.time()) + + # Build the full conversation history for storage + # (includes tool calls from the agent run) + full_history = list(conversation_history) + full_history.append({"role": "user", "content": user_message}) + # Add agent's internal messages if available + agent_messages = result.get("messages", []) + if agent_messages: + full_history.extend(agent_messages) + else: + full_history.append({"role": "assistant", "content": final_response}) + + # Build output items (includes tool calls + final message) + output_items = self._extract_output_items(result) + + response_data = { + "id": response_id, + "object": "response", + "status": "completed", + "created_at": created_at, + "model": body.get("model", self._model_name), + "output": output_items, + "usage": { + "input_tokens": usage.get("input_tokens", 0), + "output_tokens": usage.get("output_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + }, + } + + # Store the complete response object for future chaining / GET retrieval + if store: + self._response_store.put(response_id, { + "response": response_data, + "conversation_history": full_history, + "instructions": instructions, + }) + # Update conversation mapping so the next request with the same + # conversation name automatically chains to this response + if conversation: + self._response_store.set_conversation(conversation, response_id) + + return web.json_response(response_data) + + # ------------------------------------------------------------------ + # GET / DELETE response endpoints + # ------------------------------------------------------------------ + + async def _handle_get_response(self, request: "web.Request") -> "web.Response": + """GET /v1/responses/{response_id} — retrieve a stored response.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + + response_id = request.match_info["response_id"] + stored = self._response_store.get(response_id) + if stored is None: + return web.json_response(_openai_error(f"Response not found: {response_id}"), status=404) + + return web.json_response(stored["response"]) + + async def _handle_delete_response(self, request: "web.Request") -> "web.Response": + """DELETE /v1/responses/{response_id} — delete a stored response.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + + response_id = request.match_info["response_id"] + deleted = self._response_store.delete(response_id) + if not deleted: + return web.json_response(_openai_error(f"Response not found: {response_id}"), status=404) + + return web.json_response({ + "id": response_id, + "object": "response", + "deleted": True, + }) + + # ------------------------------------------------------------------ + # Cron jobs API + # ------------------------------------------------------------------ + + # Check cron module availability once (not per-request) + _CRON_AVAILABLE = False + try: + from cron.jobs import ( + list_jobs as _cron_list, + get_job as _cron_get, + create_job as _cron_create, + update_job as _cron_update, + remove_job as _cron_remove, + pause_job as _cron_pause, + resume_job as _cron_resume, + trigger_job as _cron_trigger, + ) + # Wrap as staticmethod to prevent descriptor binding — these are plain + # module functions, not instance methods. Without this, self._cron_*() + # injects ``self`` as the first positional argument and every call + # raises TypeError. + _cron_list = staticmethod(_cron_list) + _cron_get = staticmethod(_cron_get) + _cron_create = staticmethod(_cron_create) + _cron_update = staticmethod(_cron_update) + _cron_remove = staticmethod(_cron_remove) + _cron_pause = staticmethod(_cron_pause) + _cron_resume = staticmethod(_cron_resume) + _cron_trigger = staticmethod(_cron_trigger) + _CRON_AVAILABLE = True + except ImportError: + pass + + _JOB_ID_RE = __import__("re").compile(r"[a-f0-9]{12}") + # Allowed fields for update — prevents clients injecting arbitrary keys + _UPDATE_ALLOWED_FIELDS = {"name", "schedule", "prompt", "deliver", "skills", "skill", "repeat", "enabled"} + _MAX_NAME_LENGTH = 200 + _MAX_PROMPT_LENGTH = 5000 + + def _check_jobs_available(self) -> Optional["web.Response"]: + """Return error response if cron module isn't available.""" + if not self._CRON_AVAILABLE: + return web.json_response( + {"error": "Cron module not available"}, status=501, + ) + return None + + def _check_job_id(self, request: "web.Request") -> tuple: + """Validate and extract job_id. Returns (job_id, error_response).""" + job_id = request.match_info["job_id"] + if not self._JOB_ID_RE.fullmatch(job_id): + return job_id, web.json_response( + {"error": "Invalid job ID format"}, status=400, + ) + return job_id, None + + async def _handle_list_jobs(self, request: "web.Request") -> "web.Response": + """GET /api/jobs — list all cron jobs.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + cron_err = self._check_jobs_available() + if cron_err: + return cron_err + try: + include_disabled = request.query.get("include_disabled", "").lower() in ("true", "1") + jobs = self._cron_list(include_disabled=include_disabled) + return web.json_response({"jobs": jobs}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + async def _handle_create_job(self, request: "web.Request") -> "web.Response": + """POST /api/jobs — create a new cron job.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + cron_err = self._check_jobs_available() + if cron_err: + return cron_err + try: + body = await request.json() + name = (body.get("name") or "").strip() + schedule = (body.get("schedule") or "").strip() + prompt = body.get("prompt", "") + deliver = body.get("deliver", "local") + skills = body.get("skills") + repeat = body.get("repeat") + + if not name: + return web.json_response({"error": "Name is required"}, status=400) + if len(name) > self._MAX_NAME_LENGTH: + return web.json_response( + {"error": f"Name must be ≤ {self._MAX_NAME_LENGTH} characters"}, status=400, + ) + if not schedule: + return web.json_response({"error": "Schedule is required"}, status=400) + if len(prompt) > self._MAX_PROMPT_LENGTH: + return web.json_response( + {"error": f"Prompt must be ≤ {self._MAX_PROMPT_LENGTH} characters"}, status=400, + ) + if repeat is not None and (not isinstance(repeat, int) or repeat < 1): + return web.json_response({"error": "Repeat must be a positive integer"}, status=400) + + kwargs = { + "prompt": prompt, + "schedule": schedule, + "name": name, + "deliver": deliver, + } + if skills: + kwargs["skills"] = skills + if repeat is not None: + kwargs["repeat"] = repeat + + job = self._cron_create(**kwargs) + return web.json_response({"job": job}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + async def _handle_get_job(self, request: "web.Request") -> "web.Response": + """GET /api/jobs/{job_id} — get a single cron job.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + cron_err = self._check_jobs_available() + if cron_err: + return cron_err + job_id, id_err = self._check_job_id(request) + if id_err: + return id_err + try: + job = self._cron_get(job_id) + if not job: + return web.json_response({"error": "Job not found"}, status=404) + return web.json_response({"job": job}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + async def _handle_update_job(self, request: "web.Request") -> "web.Response": + """PATCH /api/jobs/{job_id} — update a cron job.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + cron_err = self._check_jobs_available() + if cron_err: + return cron_err + job_id, id_err = self._check_job_id(request) + if id_err: + return id_err + try: + body = await request.json() + # Whitelist allowed fields to prevent arbitrary key injection + sanitized = {k: v for k, v in body.items() if k in self._UPDATE_ALLOWED_FIELDS} + if not sanitized: + return web.json_response({"error": "No valid fields to update"}, status=400) + # Validate lengths if present + if "name" in sanitized and len(sanitized["name"]) > self._MAX_NAME_LENGTH: + return web.json_response( + {"error": f"Name must be ≤ {self._MAX_NAME_LENGTH} characters"}, status=400, + ) + if "prompt" in sanitized and len(sanitized["prompt"]) > self._MAX_PROMPT_LENGTH: + return web.json_response( + {"error": f"Prompt must be ≤ {self._MAX_PROMPT_LENGTH} characters"}, status=400, + ) + job = self._cron_update(job_id, sanitized) + if not job: + return web.json_response({"error": "Job not found"}, status=404) + return web.json_response({"job": job}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + async def _handle_delete_job(self, request: "web.Request") -> "web.Response": + """DELETE /api/jobs/{job_id} — delete a cron job.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + cron_err = self._check_jobs_available() + if cron_err: + return cron_err + job_id, id_err = self._check_job_id(request) + if id_err: + return id_err + try: + success = self._cron_remove(job_id) + if not success: + return web.json_response({"error": "Job not found"}, status=404) + return web.json_response({"ok": True}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + async def _handle_pause_job(self, request: "web.Request") -> "web.Response": + """POST /api/jobs/{job_id}/pause — pause a cron job.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + cron_err = self._check_jobs_available() + if cron_err: + return cron_err + job_id, id_err = self._check_job_id(request) + if id_err: + return id_err + try: + job = self._cron_pause(job_id) + if not job: + return web.json_response({"error": "Job not found"}, status=404) + return web.json_response({"job": job}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + async def _handle_resume_job(self, request: "web.Request") -> "web.Response": + """POST /api/jobs/{job_id}/resume — resume a paused cron job.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + cron_err = self._check_jobs_available() + if cron_err: + return cron_err + job_id, id_err = self._check_job_id(request) + if id_err: + return id_err + try: + job = self._cron_resume(job_id) + if not job: + return web.json_response({"error": "Job not found"}, status=404) + return web.json_response({"job": job}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + async def _handle_run_job(self, request: "web.Request") -> "web.Response": + """POST /api/jobs/{job_id}/run — trigger immediate execution.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + cron_err = self._check_jobs_available() + if cron_err: + return cron_err + job_id, id_err = self._check_job_id(request) + if id_err: + return id_err + try: + job = self._cron_trigger(job_id) + if not job: + return web.json_response({"error": "Job not found"}, status=404) + return web.json_response({"job": job}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + # ------------------------------------------------------------------ + # Output extraction helper + # ------------------------------------------------------------------ + + @staticmethod + def _extract_output_items(result: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Build the full output item array from the agent's messages. + + Walks *result["messages"]* and emits: + - ``function_call`` items for each tool_call on assistant messages + - ``function_call_output`` items for each tool-role message + - a final ``message`` item with the assistant's text reply + """ + items: List[Dict[str, Any]] = [] + messages = result.get("messages", []) + + for msg in messages: + role = msg.get("role") + if role == "assistant" and msg.get("tool_calls"): + for tc in msg["tool_calls"]: + func = tc.get("function", {}) + items.append({ + "type": "function_call", + "name": func.get("name", ""), + "arguments": func.get("arguments", ""), + "call_id": tc.get("id", ""), + }) + elif role == "tool": + items.append({ + "type": "function_call_output", + "call_id": msg.get("tool_call_id", ""), + "output": msg.get("content", ""), + }) + + # Final assistant message + final = result.get("final_response", "") + if not final: + final = result.get("error", "(No response generated)") + + items.append({ + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": final, + } + ], + }) + return items + + # ------------------------------------------------------------------ + # Agent execution + # ------------------------------------------------------------------ + + async def _run_agent( + self, + user_message: str, + conversation_history: List[Dict[str, str]], + ephemeral_system_prompt: Optional[str] = None, + session_id: Optional[str] = None, + stream_delta_callback=None, + tool_progress_callback=None, + agent_ref: Optional[list] = None, + ) -> tuple: + """ + Create an agent and run a conversation in a thread executor. + + Returns ``(result_dict, usage_dict)`` where *usage_dict* contains + ``input_tokens``, ``output_tokens`` and ``total_tokens``. + + If *agent_ref* is a one-element list, the AIAgent instance is stored + at ``agent_ref[0]`` before ``run_conversation`` begins. This allows + callers (e.g. the SSE writer) to call ``agent.interrupt()`` from + another thread to stop in-progress LLM calls. + """ + loop = asyncio.get_event_loop() + + def _run(): + agent = self._create_agent( + ephemeral_system_prompt=ephemeral_system_prompt, + session_id=session_id, + stream_delta_callback=stream_delta_callback, + tool_progress_callback=tool_progress_callback, + ) + if agent_ref is not None: + agent_ref[0] = agent + result = agent.run_conversation( + user_message=user_message, + conversation_history=conversation_history, + task_id="default", + ) + usage = { + "input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0, + "output_tokens": getattr(agent, "session_completion_tokens", 0) or 0, + "total_tokens": getattr(agent, "session_total_tokens", 0) or 0, + } + return result, usage + + return await loop.run_in_executor(None, _run) + + # ------------------------------------------------------------------ + # /v1/runs — structured event streaming + # ------------------------------------------------------------------ + + _MAX_CONCURRENT_RUNS = 10 # Prevent unbounded resource allocation + _RUN_STREAM_TTL = 300 # seconds before orphaned runs are swept + + def _make_run_event_callback(self, run_id: str, loop: "asyncio.AbstractEventLoop"): + """Return a tool_progress_callback that pushes structured events to the run's SSE queue.""" + def _push(event: Dict[str, Any]) -> None: + q = self._run_streams.get(run_id) + if q is None: + return + try: + loop.call_soon_threadsafe(q.put_nowait, event) + except Exception: + pass + + def _callback(event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs): + ts = time.time() + if event_type == "tool.started": + _push({ + "event": "tool.started", + "run_id": run_id, + "timestamp": ts, + "tool": tool_name, + "preview": preview, + }) + elif event_type == "tool.completed": + _push({ + "event": "tool.completed", + "run_id": run_id, + "timestamp": ts, + "tool": tool_name, + "duration": round(kwargs.get("duration", 0), 3), + "error": kwargs.get("is_error", False), + }) + elif event_type == "reasoning.available": + _push({ + "event": "reasoning.available", + "run_id": run_id, + "timestamp": ts, + "text": preview or "", + }) + # _thinking and subagent_progress are intentionally not forwarded + + return _callback + + async def _handle_runs(self, request: "web.Request") -> "web.Response": + """POST /v1/runs — start an agent run, return run_id immediately.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + + # Enforce concurrency limit + if len(self._run_streams) >= self._MAX_CONCURRENT_RUNS: + return web.json_response( + _openai_error(f"Too many concurrent runs (max {self._MAX_CONCURRENT_RUNS})", code="rate_limit_exceeded"), + status=429, + ) + + try: + body = await request.json() + except Exception: + return web.json_response(_openai_error("Invalid JSON"), status=400) + + raw_input = body.get("input") + if not raw_input: + return web.json_response(_openai_error("Missing 'input' field"), status=400) + + user_message = raw_input if isinstance(raw_input, str) else (raw_input[-1].get("content", "") if isinstance(raw_input, list) else "") + if not user_message: + return web.json_response(_openai_error("No user message found in input"), status=400) + + run_id = f"run_{uuid.uuid4().hex}" + loop = asyncio.get_running_loop() + q: "asyncio.Queue[Optional[Dict]]" = asyncio.Queue() + self._run_streams[run_id] = q + self._run_streams_created[run_id] = time.time() + + event_cb = self._make_run_event_callback(run_id, loop) + + # Also wire stream_delta_callback so message.delta events flow through + def _text_cb(delta: Optional[str]) -> None: + if delta is None: + return + try: + loop.call_soon_threadsafe(q.put_nowait, { + "event": "message.delta", + "run_id": run_id, + "timestamp": time.time(), + "delta": delta, + }) + except Exception: + pass + + instructions = body.get("instructions") + previous_response_id = body.get("previous_response_id") + + # Accept explicit conversation_history from the request body. + # Precedence: explicit conversation_history > previous_response_id. + conversation_history: List[Dict[str, str]] = [] + raw_history = body.get("conversation_history") + if raw_history: + if not isinstance(raw_history, list): + return web.json_response( + _openai_error("'conversation_history' must be an array of message objects"), + status=400, + ) + for i, entry in enumerate(raw_history): + if not isinstance(entry, dict) or "role" not in entry or "content" not in entry: + return web.json_response( + _openai_error(f"conversation_history[{i}] must have 'role' and 'content' fields"), + status=400, + ) + conversation_history.append({"role": str(entry["role"]), "content": str(entry["content"])}) + if previous_response_id: + logger.debug("Both conversation_history and previous_response_id provided; using conversation_history") + + if not conversation_history and previous_response_id: + stored = self._response_store.get(previous_response_id) + if stored: + conversation_history = list(stored.get("conversation_history", [])) + if instructions is None: + instructions = stored.get("instructions") + + # When input is a multi-message array, extract all but the last + # message as conversation history (the last becomes user_message). + # Only fires when no explicit history was provided. + if not conversation_history and isinstance(raw_input, list) and len(raw_input) > 1: + for msg in raw_input[:-1]: + if isinstance(msg, dict) and msg.get("role") and msg.get("content"): + content = msg["content"] + if isinstance(content, list): + # Flatten multi-part content blocks to text + content = " ".join( + part.get("text", "") for part in content + if isinstance(part, dict) and part.get("type") == "text" + ) + conversation_history.append({"role": msg["role"], "content": str(content)}) + + session_id = body.get("session_id") or run_id + ephemeral_system_prompt = instructions + + async def _run_and_close(): + try: + agent = self._create_agent( + ephemeral_system_prompt=ephemeral_system_prompt, + session_id=session_id, + stream_delta_callback=_text_cb, + tool_progress_callback=event_cb, + ) + def _run_sync(): + r = agent.run_conversation( + user_message=user_message, + conversation_history=conversation_history, + task_id="default", + ) + u = { + "input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0, + "output_tokens": getattr(agent, "session_completion_tokens", 0) or 0, + "total_tokens": getattr(agent, "session_total_tokens", 0) or 0, + } + return r, u + + result, usage = await asyncio.get_running_loop().run_in_executor(None, _run_sync) + final_response = result.get("final_response", "") if isinstance(result, dict) else "" + q.put_nowait({ + "event": "run.completed", + "run_id": run_id, + "timestamp": time.time(), + "output": final_response, + "usage": usage, + }) + except Exception as exc: + logger.exception("[api_server] run %s failed", run_id) + try: + q.put_nowait({ + "event": "run.failed", + "run_id": run_id, + "timestamp": time.time(), + "error": str(exc), + }) + except Exception: + pass + finally: + # Sentinel: signal SSE stream to close + try: + q.put_nowait(None) + except Exception: + pass + + task = asyncio.create_task(_run_and_close()) + try: + self._background_tasks.add(task) + except TypeError: + pass + if hasattr(task, "add_done_callback"): + task.add_done_callback(self._background_tasks.discard) + + return web.json_response({"run_id": run_id, "status": "started"}, status=202) + + async def _handle_run_events(self, request: "web.Request") -> "web.StreamResponse": + """GET /v1/runs/{run_id}/events — SSE stream of structured agent lifecycle events.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + + run_id = request.match_info["run_id"] + + # Allow subscribing slightly before the run is registered (race condition window) + for _ in range(20): + if run_id in self._run_streams: + break + await asyncio.sleep(0.05) + else: + return web.json_response(_openai_error(f"Run not found: {run_id}", code="run_not_found"), status=404) + + q = self._run_streams[run_id] + + response = web.StreamResponse( + status=200, + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + await response.prepare(request) + + try: + while True: + try: + event = await asyncio.wait_for(q.get(), timeout=30.0) + except asyncio.TimeoutError: + await response.write(b": keepalive\n\n") + continue + if event is None: + # Run finished — send final SSE comment and close + await response.write(b": stream closed\n\n") + break + payload = f"data: {json.dumps(event)}\n\n" + await response.write(payload.encode()) + except Exception as exc: + logger.debug("[api_server] SSE stream error for run %s: %s", run_id, exc) + finally: + self._run_streams.pop(run_id, None) + self._run_streams_created.pop(run_id, None) + + return response + + async def _sweep_orphaned_runs(self) -> None: + """Periodically clean up run streams that were never consumed.""" + while True: + await asyncio.sleep(60) + now = time.time() + stale = [ + run_id + for run_id, created_at in list(self._run_streams_created.items()) + if now - created_at > self._RUN_STREAM_TTL + ] + for run_id in stale: + logger.debug("[api_server] sweeping orphaned run %s", run_id) + self._run_streams.pop(run_id, None) + self._run_streams_created.pop(run_id, None) + + # ------------------------------------------------------------------ + # BasePlatformAdapter interface + # ------------------------------------------------------------------ + + async def connect(self) -> bool: + """Start the aiohttp web server.""" + if not AIOHTTP_AVAILABLE: + logger.warning("[%s] aiohttp not installed", self.name) + return False + + try: + mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware) if mw is not None] + self._app = web.Application(middlewares=mws) + self._app["api_server_adapter"] = self + self._app.router.add_get("/health", self._handle_health) + self._app.router.add_get("/v1/health", self._handle_health) + self._app.router.add_get("/v1/models", self._handle_models) + self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions) + self._app.router.add_post("/v1/responses", self._handle_responses) + self._app.router.add_get("/v1/responses/{response_id}", self._handle_get_response) + self._app.router.add_delete("/v1/responses/{response_id}", self._handle_delete_response) + # Cron jobs management API + self._app.router.add_get("/api/jobs", self._handle_list_jobs) + self._app.router.add_post("/api/jobs", self._handle_create_job) + self._app.router.add_get("/api/jobs/{job_id}", self._handle_get_job) + self._app.router.add_patch("/api/jobs/{job_id}", self._handle_update_job) + self._app.router.add_delete("/api/jobs/{job_id}", self._handle_delete_job) + self._app.router.add_post("/api/jobs/{job_id}/pause", self._handle_pause_job) + self._app.router.add_post("/api/jobs/{job_id}/resume", self._handle_resume_job) + self._app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job) + # Structured event streaming + self._app.router.add_post("/v1/runs", self._handle_runs) + self._app.router.add_get("/v1/runs/{run_id}/events", self._handle_run_events) + # Start background sweep to clean up orphaned (unconsumed) run streams + sweep_task = asyncio.create_task(self._sweep_orphaned_runs()) + try: + self._background_tasks.add(sweep_task) + except TypeError: + pass + if hasattr(sweep_task, "add_done_callback"): + sweep_task.add_done_callback(self._background_tasks.discard) + + # Refuse to start network-accessible without authentication + if is_network_accessible(self._host) and not self._api_key: + logger.error( + "[%s] Refusing to start: binding to %s requires API_SERVER_KEY. " + "Set API_SERVER_KEY or use the default 127.0.0.1.", + self.name, self._host, + ) + return False + + # Refuse to start network-accessible with a placeholder key. + # Ported from openclaw/openclaw#64586. + if is_network_accessible(self._host) and self._api_key: + try: + from hermes_cli.auth import has_usable_secret + if not has_usable_secret(self._api_key, min_length=8): + logger.error( + "[%s] Refusing to start: API_SERVER_KEY is set to a " + "placeholder value. Generate a real secret " + "(e.g. `openssl rand -hex 32`) and set API_SERVER_KEY " + "before exposing the API server on %s.", + self.name, self._host, + ) + return False + except ImportError: + pass + + # Port conflict detection — fail fast if port is already in use + try: + with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s: + _s.settimeout(1) + _s.connect(('127.0.0.1', self._port)) + logger.error('[%s] Port %d already in use. Set a different port in config.yaml: platforms.api_server.port', self.name, self._port) + return False + except (ConnectionRefusedError, OSError): + pass # port is free + + self._runner = web.AppRunner(self._app) + await self._runner.setup() + self._site = web.TCPSite(self._runner, self._host, self._port) + await self._site.start() + + self._mark_connected() + if not self._api_key: + logger.warning( + "[%s] ⚠️ No API key configured (API_SERVER_KEY / platforms.api_server.key). " + "All requests will be accepted without authentication. " + "Set an API key for production deployments to prevent " + "unauthorized access to sessions, responses, and cron jobs.", + self.name, + ) + logger.info( + "[%s] API server listening on http://%s:%d (model: %s)", + self.name, self._host, self._port, self._model_name, + ) + return True + + except Exception as e: + logger.error("[%s] Failed to start API server: %s", self.name, e) + return False + + async def disconnect(self) -> None: + """Stop the aiohttp web server.""" + self._mark_disconnected() + if self._site: + await self._site.stop() + self._site = None + if self._runner: + await self._runner.cleanup() + self._runner = None + self._app = None + logger.info("[%s] API server stopped", self.name) + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """ + Not used — HTTP request/response cycle handles delivery directly. + """ + return SendResult(success=False, error="API server uses HTTP request/response, not send()") + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Return basic info about the API server.""" + return { + "name": "API Server", + "type": "api", + "host": self._host, + "port": self._port, + } diff --git a/mindcli/_vendor/gateway/platforms/base.py b/mindcli/_vendor/gateway/platforms/base.py new file mode 100644 index 0000000..f7943da --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/base.py @@ -0,0 +1,2071 @@ +""" +Base platform adapter interface. + +All platform adapters (Telegram, Discord, WhatsApp) inherit from this +and implement the required methods. +""" + +import asyncio +import ipaddress +import logging +import os +import random +import re +import socket as _socket +import subprocess +import sys +import uuid +from abc import ABC, abstractmethod +from urllib.parse import urlsplit + +logger = logging.getLogger(__name__) + + +def utf16_len(s: str) -> int: + """Count UTF-16 code units in *s*. + + Telegram's message-length limit (4 096) is measured in UTF-16 code units, + **not** Unicode code-points. Characters outside the Basic Multilingual + Plane (emoji like 😀, CJK Extension B, musical symbols, …) are encoded as + surrogate pairs and therefore consume **two** UTF-16 code units each, even + though Python's ``len()`` counts them as one. + + Ported from nearai/ironclaw#2304 which discovered the same discrepancy in + Rust's ``chars().count()``. + """ + return len(s.encode("utf-16-le")) // 2 + + +def _prefix_within_utf16_limit(s: str, limit: int) -> str: + """Return the longest prefix of *s* whose UTF-16 length ≤ *limit*. + + Unlike a plain ``s[:limit]``, this respects surrogate-pair boundaries so + we never slice a multi-code-unit character in half. + """ + if utf16_len(s) <= limit: + return s + # Binary search for the longest safe prefix + lo, hi = 0, len(s) + while lo < hi: + mid = (lo + hi + 1) // 2 + if utf16_len(s[:mid]) <= limit: + lo = mid + else: + hi = mid - 1 + return s[:lo] + + +def _custom_unit_to_cp(s: str, budget: int, len_fn) -> int: + """Return the largest codepoint offset *n* such that ``len_fn(s[:n]) <= budget``. + + Used by :meth:`BasePlatformAdapter.truncate_message` when *len_fn* measures + length in units different from Python codepoints (e.g. UTF-16 code units). + Falls back to binary search which is O(log n) calls to *len_fn*. + """ + if len_fn(s) <= budget: + return len(s) + lo, hi = 0, len(s) + while lo < hi: + mid = (lo + hi + 1) // 2 + if len_fn(s[:mid]) <= budget: + lo = mid + else: + hi = mid - 1 + return lo + + +def is_network_accessible(host: str) -> bool: + """Return True if *host* would expose the server beyond loopback. + + Loopback addresses (127.0.0.1, ::1, IPv4-mapped ::ffff:127.0.0.1) + are local-only. Unspecified addresses (0.0.0.0, ::) bind all + interfaces. Hostnames are resolved; DNS failure fails closed. + """ + try: + addr = ipaddress.ip_address(host) + if addr.is_loopback: + return False + # ::ffff:127.0.0.1 — Python reports is_loopback=False for mapped + # addresses, so check the underlying IPv4 explicitly. + if getattr(addr, "ipv4_mapped", None) and addr.ipv4_mapped.is_loopback: + return False + return True + except ValueError: + # when host variable is a hostname, we should try to resolve below + pass + + try: + resolved = _socket.getaddrinfo( + host, None, _socket.AF_UNSPEC, _socket.SOCK_STREAM, + ) + # if the hostname resolves into at least one non-loopback address, + # then we consider it to be network accessible + for _family, _type, _proto, _canonname, sockaddr in resolved: + addr = ipaddress.ip_address(sockaddr[0]) + if not addr.is_loopback: + return True + return False + except (_socket.gaierror, OSError): + return True + + +def _detect_macos_system_proxy() -> str | None: + """Read the macOS system HTTP(S) proxy via ``scutil --proxy``. + + Returns an ``http://host:port`` URL string if an HTTP or HTTPS proxy is + enabled, otherwise *None*. Falls back silently on non-macOS or on any + subprocess error. + """ + if sys.platform != "darwin": + return None + try: + out = subprocess.check_output( + ["scutil", "--proxy"], timeout=3, text=True, stderr=subprocess.DEVNULL, + ) + except Exception: + return None + + props: dict[str, str] = {} + for line in out.splitlines(): + line = line.strip() + if " : " in line: + key, _, val = line.partition(" : ") + props[key.strip()] = val.strip() + + # Prefer HTTPS, fall back to HTTP + for enable_key, host_key, port_key in ( + ("HTTPSEnable", "HTTPSProxy", "HTTPSPort"), + ("HTTPEnable", "HTTPProxy", "HTTPPort"), + ): + if props.get(enable_key) == "1": + host = props.get(host_key) + port = props.get(port_key) + if host and port: + return f"http://{host}:{port}" + return None + + +def resolve_proxy_url(platform_env_var: str | None = None) -> str | None: + """Return a proxy URL from env vars, or macOS system proxy. + + Check order: + 0. *platform_env_var* (e.g. ``DISCORD_PROXY``) — highest priority + 1. HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (and lowercase variants) + 2. macOS system proxy via ``scutil --proxy`` (auto-detect) + + Returns *None* if no proxy is found. + """ + if platform_env_var: + value = (os.environ.get(platform_env_var) or "").strip() + if value: + return value + for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", + "https_proxy", "http_proxy", "all_proxy"): + value = (os.environ.get(key) or "").strip() + if value: + return value + return _detect_macos_system_proxy() + + +def proxy_kwargs_for_bot(proxy_url: str | None) -> dict: + """Build kwargs for ``commands.Bot()`` / ``discord.Client()`` with proxy. + + Returns: + - SOCKS URL → ``{"connector": ProxyConnector(..., rdns=True)}`` + - HTTP URL → ``{"proxy": url}`` + - *None* → ``{}`` + + ``rdns=True`` forces remote DNS resolution through the proxy — required + by many SOCKS implementations (Shadowrocket, Clash) and essential for + bypassing DNS pollution behind the GFW. + """ + if not proxy_url: + return {} + if proxy_url.lower().startswith("socks"): + try: + from aiohttp_socks import ProxyConnector + + connector = ProxyConnector.from_url(proxy_url, rdns=True) + return {"connector": connector} + except ImportError: + logger.warning( + "aiohttp_socks not installed — SOCKS proxy %s ignored. " + "Run: pip install aiohttp-socks", + proxy_url, + ) + return {} + return {"proxy": proxy_url} + + +def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]: + """Build kwargs for standalone ``aiohttp.ClientSession`` with proxy. + + Returns ``(session_kwargs, request_kwargs)`` where: + - SOCKS → ``({"connector": ProxyConnector(...)}, {})`` + - HTTP → ``({}, {"proxy": url})`` + - None → ``({}, {})`` + + Usage:: + + sess_kw, req_kw = proxy_kwargs_for_aiohttp(proxy_url) + async with aiohttp.ClientSession(**sess_kw) as session: + async with session.get(url, **req_kw) as resp: + ... + """ + if not proxy_url: + return {}, {} + if proxy_url.lower().startswith("socks"): + try: + from aiohttp_socks import ProxyConnector + + connector = ProxyConnector.from_url(proxy_url, rdns=True) + return {"connector": connector}, {} + except ImportError: + logger.warning( + "aiohttp_socks not installed — SOCKS proxy %s ignored. " + "Run: pip install aiohttp-socks", + proxy_url, + ) + return {}, {} + return {}, {"proxy": proxy_url} + + +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Any, Callable, Awaitable, Tuple +from enum import Enum + +from pathlib import Path as _Path +sys.path.insert(0, str(_Path(__file__).resolve().parents[2])) + +from gateway.config import Platform, PlatformConfig +from gateway.session import SessionSource, build_session_key +from hermes_constants import get_hermes_dir + + +GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = ( + "Secure secret entry is not supported over messaging. " + "Load this skill in the local CLI to be prompted, or add the key to ~/.hermes/.env manually." +) + + +def safe_url_for_log(url: str, max_len: int = 80) -> str: + """Return a URL string safe for logs (no query/fragment/userinfo).""" + if max_len <= 0: + return "" + + if url is None: + return "" + + raw = str(url) + if not raw: + return "" + + try: + parsed = urlsplit(raw) + except Exception: + return raw[:max_len] + + if parsed.scheme and parsed.netloc: + # Strip potential embedded credentials (user:pass@host). + netloc = parsed.netloc.rsplit("@", 1)[-1] + base = f"{parsed.scheme}://{netloc}" + path = parsed.path or "" + if path and path != "/": + basename = path.rsplit("/", 1)[-1] + safe = f"{base}/.../{basename}" if basename else f"{base}/..." + else: + safe = base + else: + safe = raw + + if len(safe) <= max_len: + return safe + if max_len <= 3: + return "." * max_len + return f"{safe[:max_len - 3]}..." + + +async def _ssrf_redirect_guard(response): + """Re-validate each redirect target to prevent redirect-based SSRF. + + Without this, an attacker can host a public URL that 302-redirects to + http://169.254.169.254/ and bypass the pre-flight is_safe_url() check. + + Must be async because httpx.AsyncClient awaits response event hooks. + """ + if response.is_redirect and response.next_request: + redirect_url = str(response.next_request.url) + from tools.url_safety import is_safe_url + if not is_safe_url(redirect_url): + raise ValueError( + f"Blocked redirect to private/internal address: {safe_url_for_log(redirect_url)}" + ) + + +# --------------------------------------------------------------------------- +# Image cache utilities +# +# When users send images on messaging platforms, we download them to a local +# cache directory so they can be analyzed by the vision tool (which accepts +# local file paths). This avoids issues with ephemeral platform URLs +# (e.g. Telegram file URLs expire after ~1 hour). +# --------------------------------------------------------------------------- + +# Default location: {HERMES_HOME}/cache/images/ (legacy: image_cache/) +IMAGE_CACHE_DIR = get_hermes_dir("cache/images", "image_cache") + + +def get_image_cache_dir() -> Path: + """Return the image cache directory, creating it if it doesn't exist.""" + IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) + return IMAGE_CACHE_DIR + + +def _looks_like_image(data: bytes) -> bool: + """Return True if *data* starts with a known image magic-byte sequence.""" + if len(data) < 4: + return False + if data[:8] == b"\x89PNG\r\n\x1a\n": + return True + if data[:3] == b"\xff\xd8\xff": + return True + if data[:6] in (b"GIF87a", b"GIF89a"): + return True + if data[:2] == b"BM": + return True + if data[:4] == b"RIFF" and len(data) >= 12 and data[8:12] == b"WEBP": + return True + return False + + +def cache_image_from_bytes(data: bytes, ext: str = ".jpg") -> str: + """ + Save raw image bytes to the cache and return the absolute file path. + + Args: + data: Raw image bytes. + ext: File extension including the dot (e.g. ".jpg", ".png"). + + Returns: + Absolute path to the cached image file as a string. + + Raises: + ValueError: If *data* does not look like a valid image (e.g. an HTML + error page returned by the upstream server). + """ + if not _looks_like_image(data): + snippet = data[:80].decode("utf-8", errors="replace") + raise ValueError( + f"Refusing to cache non-image data as {ext} " + f"(starts with: {snippet!r})" + ) + cache_dir = get_image_cache_dir() + filename = f"img_{uuid.uuid4().hex[:12]}{ext}" + filepath = cache_dir / filename + filepath.write_bytes(data) + return str(filepath) + + +async def cache_image_from_url(url: str, ext: str = ".jpg", retries: int = 2) -> str: + """ + Download an image from a URL and save it to the local cache. + + Retries on transient failures (timeouts, 429, 5xx) with exponential + backoff so a single slow CDN response doesn't lose the media. + + Args: + url: The HTTP/HTTPS URL to download from. + ext: File extension including the dot (e.g. ".jpg", ".png"). + retries: Number of retry attempts on transient failures. + + Returns: + Absolute path to the cached image file as a string. + + Raises: + ValueError: If the URL targets a private/internal network (SSRF protection). + """ + from tools.url_safety import is_safe_url + if not is_safe_url(url): + raise ValueError(f"Blocked unsafe URL (SSRF protection): {safe_url_for_log(url)}") + + import asyncio + import httpx + import logging as _logging + _log = _logging.getLogger(__name__) + + last_exc = None + async with httpx.AsyncClient( + timeout=30.0, + follow_redirects=True, + event_hooks={"response": [_ssrf_redirect_guard]}, + ) as client: + for attempt in range(retries + 1): + try: + response = await client.get( + url, + headers={ + "User-Agent": "Mozilla/5.0 (compatible; HermesAgent/1.0)", + "Accept": "image/*,*/*;q=0.8", + }, + ) + response.raise_for_status() + return cache_image_from_bytes(response.content, ext) + except (httpx.TimeoutException, httpx.HTTPStatusError) as exc: + last_exc = exc + if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 429: + raise + if attempt < retries: + wait = 1.5 * (attempt + 1) + _log.debug( + "Media cache retry %d/%d for %s (%.1fs): %s", + attempt + 1, + retries, + safe_url_for_log(url), + wait, + exc, + ) + await asyncio.sleep(wait) + continue + raise + raise last_exc + + +def cleanup_image_cache(max_age_hours: int = 24) -> int: + """ + Delete cached images older than *max_age_hours*. + + Returns the number of files removed. + """ + import time + + cache_dir = get_image_cache_dir() + cutoff = time.time() - (max_age_hours * 3600) + removed = 0 + for f in cache_dir.iterdir(): + if f.is_file() and f.stat().st_mtime < cutoff: + try: + f.unlink() + removed += 1 + except OSError: + pass + return removed + + +# --------------------------------------------------------------------------- +# Audio cache utilities +# +# Same pattern as image cache -- voice messages from platforms are downloaded +# here so the STT tool (OpenAI Whisper) can transcribe them from local files. +# --------------------------------------------------------------------------- + +AUDIO_CACHE_DIR = get_hermes_dir("cache/audio", "audio_cache") + + +def get_audio_cache_dir() -> Path: + """Return the audio cache directory, creating it if it doesn't exist.""" + AUDIO_CACHE_DIR.mkdir(parents=True, exist_ok=True) + return AUDIO_CACHE_DIR + + +def cache_audio_from_bytes(data: bytes, ext: str = ".ogg") -> str: + """ + Save raw audio bytes to the cache and return the absolute file path. + + Args: + data: Raw audio bytes. + ext: File extension including the dot (e.g. ".ogg", ".mp3"). + + Returns: + Absolute path to the cached audio file as a string. + """ + cache_dir = get_audio_cache_dir() + filename = f"audio_{uuid.uuid4().hex[:12]}{ext}" + filepath = cache_dir / filename + filepath.write_bytes(data) + return str(filepath) + + +async def cache_audio_from_url(url: str, ext: str = ".ogg", retries: int = 2) -> str: + """ + Download an audio file from a URL and save it to the local cache. + + Retries on transient failures (timeouts, 429, 5xx) with exponential + backoff so a single slow CDN response doesn't lose the media. + + Args: + url: The HTTP/HTTPS URL to download from. + ext: File extension including the dot (e.g. ".ogg", ".mp3"). + retries: Number of retry attempts on transient failures. + + Returns: + Absolute path to the cached audio file as a string. + + Raises: + ValueError: If the URL targets a private/internal network (SSRF protection). + """ + from tools.url_safety import is_safe_url + if not is_safe_url(url): + raise ValueError(f"Blocked unsafe URL (SSRF protection): {safe_url_for_log(url)}") + + import asyncio + import httpx + import logging as _logging + _log = _logging.getLogger(__name__) + + last_exc = None + async with httpx.AsyncClient( + timeout=30.0, + follow_redirects=True, + event_hooks={"response": [_ssrf_redirect_guard]}, + ) as client: + for attempt in range(retries + 1): + try: + response = await client.get( + url, + headers={ + "User-Agent": "Mozilla/5.0 (compatible; HermesAgent/1.0)", + "Accept": "audio/*,*/*;q=0.8", + }, + ) + response.raise_for_status() + return cache_audio_from_bytes(response.content, ext) + except (httpx.TimeoutException, httpx.HTTPStatusError) as exc: + last_exc = exc + if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 429: + raise + if attempt < retries: + wait = 1.5 * (attempt + 1) + _log.debug( + "Audio cache retry %d/%d for %s (%.1fs): %s", + attempt + 1, + retries, + safe_url_for_log(url), + wait, + exc, + ) + await asyncio.sleep(wait) + continue + raise + raise last_exc + + +# --------------------------------------------------------------------------- +# Document cache utilities +# +# Same pattern as image/audio cache -- documents from platforms are downloaded +# here so the agent can reference them by local file path. +# --------------------------------------------------------------------------- + +DOCUMENT_CACHE_DIR = get_hermes_dir("cache/documents", "document_cache") + +SUPPORTED_DOCUMENT_TYPES = { + ".pdf": "application/pdf", + ".md": "text/markdown", + ".txt": "text/plain", + ".log": "text/plain", + ".zip": "application/zip", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", +} + + +def get_document_cache_dir() -> Path: + """Return the document cache directory, creating it if it doesn't exist.""" + DOCUMENT_CACHE_DIR.mkdir(parents=True, exist_ok=True) + return DOCUMENT_CACHE_DIR + + +def cache_document_from_bytes(data: bytes, filename: str) -> str: + """ + Save raw document bytes to the cache and return the absolute file path. + + The cached filename preserves the original human-readable name with a + unique prefix: ``doc_{uuid12}_{original_filename}``. + + Args: + data: Raw document bytes. + filename: Original filename (e.g. "report.pdf"). + + Returns: + Absolute path to the cached document file as a string. + + Raises: + ValueError: If the sanitized path escapes the cache directory. + """ + cache_dir = get_document_cache_dir() + # Sanitize: strip directory components, null bytes, and control characters + safe_name = Path(filename).name if filename else "document" + safe_name = safe_name.replace("\x00", "").strip() + if not safe_name or safe_name in (".", ".."): + safe_name = "document" + cached_name = f"doc_{uuid.uuid4().hex[:12]}_{safe_name}" + filepath = cache_dir / cached_name + # Final safety check: ensure path stays inside cache dir + if not filepath.resolve().is_relative_to(cache_dir.resolve()): + raise ValueError(f"Path traversal rejected: {filename!r}") + filepath.write_bytes(data) + return str(filepath) + + +def cleanup_document_cache(max_age_hours: int = 24) -> int: + """ + Delete cached documents older than *max_age_hours*. + + Returns the number of files removed. + """ + import time + + cache_dir = get_document_cache_dir() + cutoff = time.time() - (max_age_hours * 3600) + removed = 0 + for f in cache_dir.iterdir(): + if f.is_file() and f.stat().st_mtime < cutoff: + try: + f.unlink() + removed += 1 + except OSError: + pass + return removed + + +class MessageType(Enum): + """Types of incoming messages.""" + TEXT = "text" + LOCATION = "location" + PHOTO = "photo" + VIDEO = "video" + AUDIO = "audio" + VOICE = "voice" + DOCUMENT = "document" + STICKER = "sticker" + COMMAND = "command" # /command style + + +class ProcessingOutcome(Enum): + """Result classification for message-processing lifecycle hooks.""" + + SUCCESS = "success" + FAILURE = "failure" + CANCELLED = "cancelled" + + +@dataclass +class MessageEvent: + """ + Incoming message from a platform. + + Normalized representation that all adapters produce. + """ + # Message content + text: str + message_type: MessageType = MessageType.TEXT + + # Source information + source: SessionSource = None + + # Original platform data + raw_message: Any = None + message_id: Optional[str] = None + + # Media attachments + # media_urls: local file paths (for vision tool access) + media_urls: List[str] = field(default_factory=list) + media_types: List[str] = field(default_factory=list) + + # Reply context + reply_to_message_id: Optional[str] = None + reply_to_text: Optional[str] = None # Text of the replied-to message (for context injection) + + # Auto-loaded skill(s) for topic/channel bindings (e.g., Telegram DM Topics, + # Discord channel_skill_bindings). A single name or ordered list. + auto_skill: Optional[str | list[str]] = None + + # Internal flag — set for synthetic events (e.g. background process + # completion notifications) that must bypass user authorization checks. + internal: bool = False + + # Timestamps + timestamp: datetime = field(default_factory=datetime.now) + + def is_command(self) -> bool: + """Check if this is a command message (e.g., /new, /reset).""" + return self.text.startswith("/") + + def get_command(self) -> Optional[str]: + """Extract command name if this is a command message.""" + if not self.is_command(): + return None + # Split on space and get first word, strip the / + parts = self.text.split(maxsplit=1) + raw = parts[0][1:].lower() if parts else None + if raw and "@" in raw: + raw = raw.split("@", 1)[0] + # Reject file paths: valid command names never contain / + if raw and "/" in raw: + return None + return raw + + def get_command_args(self) -> str: + """Get the arguments after a command.""" + if not self.is_command(): + return self.text + parts = self.text.split(maxsplit=1) + return parts[1] if len(parts) > 1 else "" + + +@dataclass +class SendResult: + """Result of sending a message.""" + success: bool + message_id: Optional[str] = None + error: Optional[str] = None + raw_response: Any = None + retryable: bool = False # True for transient connection errors — base will retry automatically + + +def merge_pending_message_event( + pending_messages: Dict[str, MessageEvent], + session_key: str, + event: MessageEvent, +) -> None: + """Store or merge a pending event for a session. + + Photo bursts/albums often arrive as multiple near-simultaneous PHOTO + events. Merge those into the existing queued event so the next turn sees + the whole burst, while non-photo follow-ups still replace the pending + event normally. + """ + existing = pending_messages.get(session_key) + if ( + existing + and getattr(existing, "message_type", None) == MessageType.PHOTO + and event.message_type == MessageType.PHOTO + ): + existing.media_urls.extend(event.media_urls) + existing.media_types.extend(event.media_types) + if event.text: + existing.text = BasePlatformAdapter._merge_caption(existing.text, event.text) + return + pending_messages[session_key] = event + + +# Error substrings that indicate a transient *connection* failure worth retrying. +# "timeout" / "timed out" / "readtimeout" / "writetimeout" are intentionally +# excluded: a read/write timeout on a non-idempotent call (e.g. send_message) +# means the request may have reached the server — retrying risks duplicate +# delivery. "connecttimeout" is safe because the connection was never +# established. Platforms that know a timeout is safe to retry should set +# SendResult.retryable = True explicitly. +_RETRYABLE_ERROR_PATTERNS = ( + "connecterror", + "connectionerror", + "connectionreset", + "connectionrefused", + "connecttimeout", + "network", + "broken pipe", + "remotedisconnected", + "eoferror", +) + + +# Type for message handlers +MessageHandler = Callable[[MessageEvent], Awaitable[Optional[str]]] + + +class BasePlatformAdapter(ABC): + """ + Base class for platform adapters. + + Subclasses implement platform-specific logic for: + - Connecting and authenticating + - Receiving messages + - Sending messages/responses + - Handling media + """ + + def __init__(self, config: PlatformConfig, platform: Platform): + self.config = config + self.platform = platform + self._message_handler: Optional[MessageHandler] = None + self._running = False + self._fatal_error_code: Optional[str] = None + self._fatal_error_message: Optional[str] = None + self._fatal_error_retryable = True + self._fatal_error_handler: Optional[Callable[["BasePlatformAdapter"], Awaitable[None] | None]] = None + + # Track active message handlers per session for interrupt support + # Key: session_key (e.g., chat_id), Value: (event, asyncio.Event for interrupt) + self._active_sessions: Dict[str, asyncio.Event] = {} + self._pending_messages: Dict[str, MessageEvent] = {} + # Background message-processing tasks spawned by handle_message(). + # Gateway shutdown cancels these so an old gateway instance doesn't keep + # working on a task after --replace or manual restarts. + self._background_tasks: set[asyncio.Task] = set() + self._expected_cancelled_tasks: set[asyncio.Task] = set() + self._busy_session_handler: Optional[Callable[[MessageEvent, str], Awaitable[bool]]] = None + # Chats where auto-TTS on voice input is disabled (set by /voice off) + self._auto_tts_disabled_chats: set = set() + # Chats where typing indicator is paused (e.g. during approval waits). + # _keep_typing skips send_typing when the chat_id is in this set. + self._typing_paused: set = set() + + @property + def has_fatal_error(self) -> bool: + return self._fatal_error_message is not None + + @property + def fatal_error_message(self) -> Optional[str]: + return self._fatal_error_message + + @property + def fatal_error_code(self) -> Optional[str]: + return self._fatal_error_code + + @property + def fatal_error_retryable(self) -> bool: + return self._fatal_error_retryable + + def set_fatal_error_handler(self, handler: Callable[["BasePlatformAdapter"], Awaitable[None] | None]) -> None: + self._fatal_error_handler = handler + + def _mark_connected(self) -> None: + self._running = True + self._fatal_error_code = None + self._fatal_error_message = None + self._fatal_error_retryable = True + try: + from gateway.status import write_runtime_status + write_runtime_status(platform=self.platform.value, platform_state="connected", error_code=None, error_message=None) + except Exception: + pass + + def _mark_disconnected(self) -> None: + self._running = False + if self.has_fatal_error: + return + try: + from gateway.status import write_runtime_status + write_runtime_status(platform=self.platform.value, platform_state="disconnected", error_code=None, error_message=None) + except Exception: + pass + + def _set_fatal_error(self, code: str, message: str, *, retryable: bool) -> None: + self._running = False + self._fatal_error_code = code + self._fatal_error_message = message + self._fatal_error_retryable = retryable + try: + from gateway.status import write_runtime_status + write_runtime_status( + platform=self.platform.value, + platform_state="fatal", + error_code=code, + error_message=message, + ) + except Exception: + pass + + async def _notify_fatal_error(self) -> None: + handler = self._fatal_error_handler + if not handler: + return + result = handler(self) + if asyncio.iscoroutine(result): + await result + + def _acquire_platform_lock(self, scope: str, identity: str, resource_desc: str) -> bool: + """Acquire a scoped lock for this adapter. Returns True on success.""" + from gateway.status import acquire_scoped_lock + self._platform_lock_scope = scope + self._platform_lock_identity = identity + acquired, existing = acquire_scoped_lock( + scope, identity, metadata={'platform': self.platform.value} + ) + if acquired: + return True + owner_pid = existing.get('pid') if isinstance(existing, dict) else None + message = ( + f'{resource_desc} already in use' + + (f' (PID {owner_pid})' if owner_pid else '') + + '. Stop the other gateway first.' + ) + logger.error('[%s] %s', self.name, message) + self._set_fatal_error(f'{scope}_lock', message, retryable=False) + return False + + def _release_platform_lock(self) -> None: + """Release the scoped lock acquired by _acquire_platform_lock.""" + identity = getattr(self, '_platform_lock_identity', None) + if not identity: + return + from gateway.status import release_scoped_lock + release_scoped_lock(self._platform_lock_scope, identity) + self._platform_lock_identity = None + + @property + def name(self) -> str: + """Human-readable name for this adapter.""" + return self.platform.value.title() + + @property + def is_connected(self) -> bool: + """Check if adapter is currently connected.""" + return self._running + + def set_message_handler(self, handler: MessageHandler) -> None: + """ + Set the handler for incoming messages. + + The handler receives a MessageEvent and should return + an optional response string. + """ + self._message_handler = handler + + def set_busy_session_handler(self, handler: Optional[Callable[[MessageEvent, str], Awaitable[bool]]]) -> None: + """Set an optional handler for messages arriving during active sessions.""" + self._busy_session_handler = handler + + def set_session_store(self, session_store: Any) -> None: + """ + Set the session store for checking active sessions. + + Used by adapters that need to check if a thread/conversation + has an active session before processing messages (e.g., Slack + thread replies without explicit mentions). + """ + self._session_store = session_store + + @abstractmethod + async def connect(self) -> bool: + """ + Connect to the platform and start receiving messages. + + Returns True if connection was successful. + """ + pass + + @abstractmethod + async def disconnect(self) -> None: + """Disconnect from the platform.""" + pass + + @abstractmethod + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> SendResult: + """ + Send a message to a chat. + + Args: + chat_id: The chat/channel ID to send to + content: Message content (may be markdown) + reply_to: Optional message ID to reply to + metadata: Additional platform-specific options + + Returns: + SendResult with success status and message ID + """ + pass + + async def edit_message( + self, + chat_id: str, + message_id: str, + content: str, + ) -> SendResult: + """ + Edit a previously sent message. Optional — platforms that don't + support editing return success=False and callers fall back to + sending a new message. + """ + return SendResult(success=False, error="Not supported") + + async def send_typing(self, chat_id: str, metadata=None) -> None: + """ + Send a typing indicator. + + Override in subclasses if the platform supports it. + metadata: optional dict with platform-specific context (e.g. thread_id for Slack). + """ + pass + + async def stop_typing(self, chat_id: str) -> None: + """Stop a persistent typing indicator (if the platform uses one). + + Override in subclasses that start background typing loops. + Default is a no-op for platforms with one-shot typing indicators. + """ + pass + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """ + Send an image natively via the platform API. + + Override in subclasses to send images as proper attachments + instead of plain-text URLs. Default falls back to sending the + URL as a text message. + """ + # Fallback: send URL as text (subclasses override for native images) + text = f"{caption}\n{image_url}" if caption else image_url + return await self.send(chat_id=chat_id, content=text, reply_to=reply_to) + + async def send_animation( + self, + chat_id: str, + animation_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """ + Send an animated GIF natively via the platform API. + + Override in subclasses to send GIFs as proper animations + (e.g., Telegram send_animation) so they auto-play inline. + Default falls back to send_image. + """ + return await self.send_image(chat_id=chat_id, image_url=animation_url, caption=caption, reply_to=reply_to, metadata=metadata) + + @staticmethod + def _is_animation_url(url: str) -> bool: + """Check if a URL points to an animated GIF (vs a static image).""" + lower = url.lower().split('?')[0] # Strip query params + return lower.endswith('.gif') + + @staticmethod + def extract_images(content: str) -> Tuple[List[Tuple[str, str]], str]: + """ + Extract image URLs from markdown and HTML image tags in a response. + + Finds patterns like: + - ![alt text](https://example.com/image.png) + - + - + + Args: + content: The response text to scan. + + Returns: + Tuple of (list of (url, alt_text) pairs, cleaned content with image tags removed). + """ + images = [] + cleaned = content + + # Match markdown images: ![alt](url) + md_pattern = r'!\[([^\]]*)\]\((https?://[^\s\)]+)\)' + for match in re.finditer(md_pattern, content): + alt_text = match.group(1) + url = match.group(2) + # Only extract URLs that look like actual images + if any(url.lower().endswith(ext) or ext in url.lower() for ext in + ['.png', '.jpg', '.jpeg', '.gif', '.webp', 'fal.media', 'fal-cdn', 'replicate.delivery']): + images.append((url, alt_text)) + + # Match HTML img tags: or or + html_pattern = r']+)["\']?\s*/?>\s*(?:)?' + for match in re.finditer(html_pattern, content): + url = match.group(1) + images.append((url, "")) + + # Remove only the matched image tags from content (not all markdown images) + if images: + extracted_urls = {url for url, _ in images} + def _remove_if_extracted(match): + url = match.group(2) if match.lastindex >= 2 else match.group(1) + return '' if url in extracted_urls else match.group(0) + cleaned = re.sub(md_pattern, _remove_if_extracted, cleaned) + cleaned = re.sub(html_pattern, _remove_if_extracted, cleaned) + # Clean up leftover blank lines + cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip() + + return images, cleaned + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + """ + Send an audio file as a native voice message via the platform API. + + Override in subclasses to send audio as voice bubbles (Telegram) + or file attachments (Discord). Default falls back to sending the + file path as text. + """ + text = f"🔊 Audio: {audio_path}" + if caption: + text = f"{caption}\n{text}" + return await self.send(chat_id=chat_id, content=text, reply_to=reply_to) + + async def play_tts( + self, + chat_id: str, + audio_path: str, + **kwargs, + ) -> SendResult: + """ + Play auto-TTS audio for voice replies. + + Override in subclasses for invisible playback (e.g. Web UI). + Default falls back to send_voice (shows audio player). + """ + return await self.send_voice(chat_id=chat_id, audio_path=audio_path, **kwargs) + + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + """ + Send a video natively via the platform API. + + Override in subclasses to send videos as inline playable media. + Default falls back to sending the file path as text. + """ + text = f"🎬 Video: {video_path}" + if caption: + text = f"{caption}\n{text}" + return await self.send(chat_id=chat_id, content=text, reply_to=reply_to) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + """ + Send a document/file natively via the platform API. + + Override in subclasses to send files as downloadable attachments. + Default falls back to sending the file path as text. + """ + text = f"📎 File: {file_path}" + if caption: + text = f"{caption}\n{text}" + return await self.send(chat_id=chat_id, content=text, reply_to=reply_to) + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + """ + Send a local image file natively via the platform API. + + Unlike send_image() which takes a URL, this takes a local file path. + Override in subclasses for native photo attachments. + Default falls back to sending the file path as text. + """ + text = f"🖼️ Image: {image_path}" + if caption: + text = f"{caption}\n{text}" + return await self.send(chat_id=chat_id, content=text, reply_to=reply_to) + + @staticmethod + def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]: + """ + Extract MEDIA: tags and [[audio_as_voice]] directives from response text. + + The TTS tool returns responses like: + [[audio_as_voice]] + MEDIA:/path/to/audio.ogg + + Args: + content: The response text to scan. + + Returns: + Tuple of (list of (path, is_voice) pairs, cleaned content with tags removed). + """ + media = [] + cleaned = content + + # Check for [[audio_as_voice]] directive + has_voice_tag = "[[audio_as_voice]]" in content + cleaned = cleaned.replace("[[audio_as_voice]]", "") + + # Extract MEDIA: tags, allowing optional whitespace after the colon + # and quoted/backticked paths for LLM-formatted outputs. + media_pattern = re.compile( + r'''[`"']?MEDIA:\s*(?P`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a)(?=[\s`"',;:)\]}]|$)|\S+)[`"']?''' + ) + for match in media_pattern.finditer(content): + path = match.group("path").strip() + if len(path) >= 2 and path[0] == path[-1] and path[0] in "`\"'": + path = path[1:-1].strip() + path = path.lstrip("`\"'").rstrip("`\"',.;:)}]") + if path: + media.append((path, has_voice_tag)) + + # Remove MEDIA tags from content (including surrounding quote/backtick wrappers) + if media: + cleaned = media_pattern.sub('', cleaned) + cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip() + + return media, cleaned + + @staticmethod + def extract_local_files(content: str) -> Tuple[List[str], str]: + """ + Detect bare local file paths in response text for native media delivery. + + Matches absolute paths (/...) and tilde paths (~/) ending in common + image or video extensions. Validates each candidate with + ``os.path.isfile()`` to avoid false positives from URLs or + non-existent paths. + + Paths inside fenced code blocks (``` ... ```) and inline code + (`...`) are ignored so that code samples are never mutilated. + + Returns: + Tuple of (list of expanded file paths, cleaned text with the + raw path strings removed). + """ + _LOCAL_MEDIA_EXTS = ( + '.png', '.jpg', '.jpeg', '.gif', '.webp', + '.mp4', '.mov', '.avi', '.mkv', '.webm', + ) + ext_part = '|'.join(e.lstrip('.') for e in _LOCAL_MEDIA_EXTS) + + # (? bool: + return any(s <= pos < e for s, e in code_spans) + + found: list = [] # (raw_match_text, expanded_path) + for match in path_re.finditer(content): + if _in_code(match.start()): + continue + raw = match.group(0) + expanded = os.path.expanduser(raw) + if os.path.isfile(expanded): + found.append((raw, expanded)) + + # Deduplicate by expanded path, preserving discovery order + seen: set = set() + unique: list = [] + for raw, expanded in found: + if expanded not in seen: + seen.add(expanded) + unique.append((raw, expanded)) + + paths = [expanded for _, expanded in unique] + + cleaned = content + if unique: + for raw, _exp in unique: + cleaned = cleaned.replace(raw, '') + cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip() + + return paths, cleaned + + async def _keep_typing(self, chat_id: str, interval: float = 2.0, metadata=None) -> None: + """ + Continuously send typing indicator until cancelled. + + Telegram/Discord typing status expires after ~5 seconds, so we refresh every 2 + to recover quickly after progress messages interrupt it. + + Skips send_typing when the chat is in ``_typing_paused`` (e.g. while + the agent is waiting for dangerous-command approval). This is critical + for Slack's Assistant API where ``assistant_threads_setStatus`` disables + the compose box — pausing lets the user type ``/approve`` or ``/deny``. + """ + try: + while True: + if chat_id not in self._typing_paused: + await self.send_typing(chat_id, metadata=metadata) + await asyncio.sleep(interval) + except asyncio.CancelledError: + pass # Normal cancellation when handler completes + finally: + # Ensure the underlying platform typing loop is stopped. + # _keep_typing may have called send_typing() after an outer + # stop_typing() cleared the task dict, recreating the loop. + # Cancelling _keep_typing alone won't clean that up. + if hasattr(self, "stop_typing"): + try: + await self.stop_typing(chat_id) + except Exception: + pass + self._typing_paused.discard(chat_id) + + def pause_typing_for_chat(self, chat_id: str) -> None: + """Pause typing indicator for a chat (e.g. during approval waits). + + Thread-safe (CPython GIL) — can be called from the sync agent thread + while ``_keep_typing`` runs on the async event loop. + """ + self._typing_paused.add(chat_id) + + def resume_typing_for_chat(self, chat_id: str) -> None: + """Resume typing indicator for a chat after approval resolves.""" + self._typing_paused.discard(chat_id) + + # ── Processing lifecycle hooks ────────────────────────────────────────── + # Subclasses override these to react to message processing events + # (e.g. Discord adds 👀/✅/❌ reactions). + + async def on_processing_start(self, event: MessageEvent) -> None: + """Hook called when background processing begins.""" + + async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None: + """Hook called when background processing completes.""" + + async def _run_processing_hook(self, hook_name: str, *args: Any, **kwargs: Any) -> None: + """Run a lifecycle hook without letting failures break message flow.""" + hook = getattr(self, hook_name, None) + if not callable(hook): + return + try: + await hook(*args, **kwargs) + except Exception as e: + logger.warning("[%s] %s hook failed: %s", self.name, hook_name, e) + + @staticmethod + def _is_retryable_error(error: Optional[str]) -> bool: + """Return True if the error string looks like a transient network failure.""" + if not error: + return False + lowered = error.lower() + return any(pat in lowered for pat in _RETRYABLE_ERROR_PATTERNS) + + @staticmethod + def _is_timeout_error(error: Optional[str]) -> bool: + """Return True if the error string indicates a read/write timeout. + + Timeout errors are NOT retryable and should NOT trigger plain-text + fallback — the request may have already been delivered. + """ + if not error: + return False + lowered = error.lower() + return "timed out" in lowered or "readtimeout" in lowered or "writetimeout" in lowered + + async def _send_with_retry( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Any = None, + max_retries: int = 2, + base_delay: float = 2.0, + ) -> "SendResult": + """ + Send a message with automatic retry for transient network errors. + + On permanent failures (e.g. formatting / permission errors) falls back + to a plain-text version before giving up. If all attempts fail due to + network errors, sends the user a brief delivery-failure notice so they + know to retry rather than waiting indefinitely. + """ + + result = await self.send( + chat_id=chat_id, + content=content, + reply_to=reply_to, + metadata=metadata, + ) + + if result.success: + return result + + error_str = result.error or "" + is_network = result.retryable or self._is_retryable_error(error_str) + + # Timeout errors are not safe to retry (message may have been + # delivered) and not formatting errors — return the failure as-is. + if not is_network and self._is_timeout_error(error_str): + return result + + if is_network: + # Retry with exponential backoff for transient errors + for attempt in range(1, max_retries + 1): + delay = base_delay * (2 ** (attempt - 1)) + random.uniform(0, 1) + logger.warning( + "[%s] Send failed (attempt %d/%d, retrying in %.1fs): %s", + self.name, attempt, max_retries, delay, error_str, + ) + await asyncio.sleep(delay) + result = await self.send( + chat_id=chat_id, + content=content, + reply_to=reply_to, + metadata=metadata, + ) + if result.success: + logger.info("[%s] Send succeeded on retry %d", self.name, attempt) + return result + error_str = result.error or "" + if not (result.retryable or self._is_retryable_error(error_str)): + break # error switched to non-transient — fall through to plain-text fallback + else: + # All retries exhausted (loop completed without break) — notify user + logger.error("[%s] Failed to deliver response after %d retries: %s", self.name, max_retries, error_str) + notice = ( + "\u26a0\ufe0f Message delivery failed after multiple attempts. " + "Please try again \u2014 your request was processed but the response could not be sent." + ) + try: + await self.send(chat_id=chat_id, content=notice, reply_to=reply_to, metadata=metadata) + except Exception as notify_err: + logger.debug("[%s] Could not send delivery-failure notice: %s", self.name, notify_err) + return result + + # Non-network / post-retry formatting failure: try plain text as fallback + logger.warning("[%s] Send failed: %s — trying plain-text fallback", self.name, error_str) + fallback_result = await self.send( + chat_id=chat_id, + content=f"(Response formatting failed, plain text:)\n\n{content[:3500]}", + reply_to=reply_to, + metadata=metadata, + ) + if not fallback_result.success: + logger.error("[%s] Fallback send also failed: %s", self.name, fallback_result.error) + return fallback_result + + @staticmethod + def _merge_caption(existing_text: Optional[str], new_text: str) -> str: + """Merge a new caption into existing text, avoiding duplicates. + + Uses line-by-line exact match (not substring) to prevent false positives + where a shorter caption is silently dropped because it appears as a + substring of a longer one (e.g. "Meeting" inside "Meeting agenda"). + Whitespace is normalised for comparison. + """ + if not existing_text: + return new_text + existing_captions = [c.strip() for c in existing_text.split("\n\n")] + if new_text.strip() not in existing_captions: + return f"{existing_text}\n\n{new_text}".strip() + return existing_text + + async def handle_message(self, event: MessageEvent) -> None: + """ + Process an incoming message. + + This method returns quickly by spawning background tasks. + This allows new messages to be processed even while an agent is running, + enabling interruption support. + """ + if not self._message_handler: + return + + session_key = build_session_key( + event.source, + group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), + thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + ) + + # Check if there's already an active handler for this session + if session_key in self._active_sessions: + # Certain commands must bypass the active-session guard and be + # dispatched directly to the gateway runner. Without this, they + # are queued as pending messages and either: + # - leak into the conversation as user text (/stop, /new), or + # - deadlock (/approve, /deny — agent is blocked on Event.wait) + # + # Dispatch inline: call the message handler directly and send the + # response. Do NOT use _process_message_background — it manages + # session lifecycle and its cleanup races with the running task + # (see PR #4926). + cmd = event.get_command() + if cmd in ("approve", "deny", "status", "stop", "new", "reset", "background", "restart"): + logger.debug( + "[%s] Command '/%s' bypassing active-session guard for %s", + self.name, cmd, session_key, + ) + try: + _thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None + response = await self._message_handler(event) + if response: + await self._send_with_retry( + chat_id=event.source.chat_id, + content=response, + reply_to=event.message_id, + metadata=_thread_meta, + ) + except Exception as e: + logger.error("[%s] Command '/%s' dispatch failed: %s", self.name, cmd, e, exc_info=True) + return + + if self._busy_session_handler is not None: + try: + if await self._busy_session_handler(event, session_key): + return + except Exception as e: + logger.error("[%s] Busy-session handler failed: %s", self.name, e, exc_info=True) + + # Special case: photo bursts/albums frequently arrive as multiple near- + # simultaneous messages. Queue them without interrupting the active run, + # then process them immediately after the current task finishes. + if event.message_type == MessageType.PHOTO: + logger.debug("[%s] Queuing photo follow-up for session %s without interrupt", self.name, session_key) + merge_pending_message_event(self._pending_messages, session_key, event) + return # Don't interrupt now - will run after current task completes + + # Default behavior for non-photo follow-ups: interrupt the running agent + logger.debug("[%s] New message while session %s is active — triggering interrupt", self.name, session_key) + self._pending_messages[session_key] = event + # Signal the interrupt (the processing task checks this) + self._active_sessions[session_key].set() + return # Don't process now - will be handled after current task finishes + + # Mark session as active BEFORE spawning background task to close + # the race window where a second message arriving before the task + # starts would also pass the _active_sessions check and spawn a + # duplicate task. (grammY sequentialize / aiogram EventIsolation + # pattern — set the guard synchronously, not inside the task.) + self._active_sessions[session_key] = asyncio.Event() + + # Spawn background task to process this message + task = asyncio.create_task(self._process_message_background(event, session_key)) + try: + self._background_tasks.add(task) + except TypeError: + # Some tests stub create_task() with lightweight sentinels that are not + # hashable and do not support lifecycle callbacks. + return + if hasattr(task, "add_done_callback"): + task.add_done_callback(self._background_tasks.discard) + task.add_done_callback(self._expected_cancelled_tasks.discard) + + @staticmethod + def _get_human_delay() -> float: + """ + Return a random delay in seconds for human-like response pacing. + + Reads from env vars: + HERMES_HUMAN_DELAY_MODE: "off" (default) | "natural" | "custom" + HERMES_HUMAN_DELAY_MIN_MS: minimum delay in ms (default 800, custom mode) + HERMES_HUMAN_DELAY_MAX_MS: maximum delay in ms (default 2500, custom mode) + """ + import random + + mode = os.getenv("HERMES_HUMAN_DELAY_MODE", "off").lower() + if mode == "off": + return 0.0 + min_ms = int(os.getenv("HERMES_HUMAN_DELAY_MIN_MS", "800")) + max_ms = int(os.getenv("HERMES_HUMAN_DELAY_MAX_MS", "2500")) + if mode == "natural": + min_ms, max_ms = 800, 2500 + return random.uniform(min_ms / 1000.0, max_ms / 1000.0) + + async def _process_message_background(self, event: MessageEvent, session_key: str) -> None: + """Background task that actually processes the message.""" + # Track delivery outcomes for the processing-complete hook + delivery_attempted = False + delivery_succeeded = False + + def _record_delivery(result): + nonlocal delivery_attempted, delivery_succeeded + if result is None: + return + delivery_attempted = True + if getattr(result, "success", False): + delivery_succeeded = True + + # Reuse the interrupt event set by handle_message() (which marks + # the session active before spawning this task to prevent races). + # Fall back to a new Event only if the entry was removed externally. + interrupt_event = self._active_sessions.get(session_key) or asyncio.Event() + self._active_sessions[session_key] = interrupt_event + + # Start continuous typing indicator (refreshes every 2 seconds) + _thread_metadata = {"thread_id": event.source.thread_id} if event.source.thread_id else None + typing_task = asyncio.create_task(self._keep_typing(event.source.chat_id, metadata=_thread_metadata)) + + try: + await self._run_processing_hook("on_processing_start", event) + + # Call the handler (this can take a while with tool calls) + response = await self._message_handler(event) + + # Send response if any. A None/empty response is normal when + # streaming already delivered the text (already_sent=True) or + # when the message was queued behind an active agent. Log at + # DEBUG to avoid noisy warnings for expected behavior. + if not response: + logger.debug("[%s] Handler returned empty/None response for %s", self.name, event.source.chat_id) + if response: + # Extract MEDIA: tags (from TTS tool) before other processing + media_files, response = self.extract_media(response) + + # Extract image URLs and send them as native platform attachments + images, text_content = self.extract_images(response) + # Strip any remaining internal directives from message body (fixes #1561) + text_content = text_content.replace("[[audio_as_voice]]", "").strip() + text_content = re.sub(r"MEDIA:\s*\S+", "", text_content).strip() + if images: + logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response)) + + # Auto-detect bare local file paths for native media delivery + # (helps small models that don't use MEDIA: syntax) + local_files, text_content = self.extract_local_files(text_content) + if local_files: + logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files)) + + # Auto-TTS: if voice message, generate audio FIRST (before sending text) + # Skipped when the chat has voice mode disabled (/voice off) + _tts_path = None + if (event.message_type == MessageType.VOICE + and text_content + and not media_files + and event.source.chat_id not in self._auto_tts_disabled_chats): + try: + from tools.tts_tool import text_to_speech_tool, check_tts_requirements + if check_tts_requirements(): + import json as _json + speech_text = re.sub(r'[*_`#\[\]()]', '', text_content)[:4000].strip() + if not speech_text: + raise ValueError("Empty text after markdown cleanup") + tts_result_str = await asyncio.to_thread( + text_to_speech_tool, text=speech_text + ) + tts_data = _json.loads(tts_result_str) + _tts_path = tts_data.get("file_path") + except Exception as tts_err: + logger.warning("[%s] Auto-TTS failed: %s", self.name, tts_err) + + # Play TTS audio before text (voice-first experience) + if _tts_path and Path(_tts_path).exists(): + try: + await self.play_tts( + chat_id=event.source.chat_id, + audio_path=_tts_path, + metadata=_thread_metadata, + ) + finally: + try: + os.remove(_tts_path) + except OSError: + pass + + # Send the text portion + if text_content: + logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id) + result = await self._send_with_retry( + chat_id=event.source.chat_id, + content=text_content, + reply_to=event.message_id, + metadata=_thread_metadata, + ) + _record_delivery(result) + + # Human-like pacing delay between text and media + human_delay = self._get_human_delay() + + # Send extracted images as native attachments + if images: + logger.info("[%s] Extracted %d image(s) to send as attachments", self.name, len(images)) + for image_url, alt_text in images: + if human_delay > 0: + await asyncio.sleep(human_delay) + try: + logger.info( + "[%s] Sending image: %s (alt=%s)", + self.name, + safe_url_for_log(image_url), + alt_text[:30] if alt_text else "", + ) + # Route animated GIFs through send_animation for proper playback + if self._is_animation_url(image_url): + img_result = await self.send_animation( + chat_id=event.source.chat_id, + animation_url=image_url, + caption=alt_text if alt_text else None, + metadata=_thread_metadata, + ) + else: + img_result = await self.send_image( + chat_id=event.source.chat_id, + image_url=image_url, + caption=alt_text if alt_text else None, + metadata=_thread_metadata, + ) + if not img_result.success: + logger.error("[%s] Failed to send image: %s", self.name, img_result.error) + except Exception as img_err: + logger.error("[%s] Error sending image: %s", self.name, img_err, exc_info=True) + + # Send extracted media files — route by file type + _AUDIO_EXTS = {'.ogg', '.opus', '.mp3', '.wav', '.m4a'} + _VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'} + _IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'} + + for media_path, is_voice in media_files: + if human_delay > 0: + await asyncio.sleep(human_delay) + try: + ext = Path(media_path).suffix.lower() + if ext in _AUDIO_EXTS: + media_result = await self.send_voice( + chat_id=event.source.chat_id, + audio_path=media_path, + metadata=_thread_metadata, + ) + elif ext in _VIDEO_EXTS: + media_result = await self.send_video( + chat_id=event.source.chat_id, + video_path=media_path, + metadata=_thread_metadata, + ) + elif ext in _IMAGE_EXTS: + media_result = await self.send_image_file( + chat_id=event.source.chat_id, + image_path=media_path, + metadata=_thread_metadata, + ) + else: + media_result = await self.send_document( + chat_id=event.source.chat_id, + file_path=media_path, + metadata=_thread_metadata, + ) + + if not media_result.success: + logger.warning("[%s] Failed to send media (%s): %s", self.name, ext, media_result.error) + except Exception as media_err: + logger.warning("[%s] Error sending media: %s", self.name, media_err) + + # Send auto-detected local files as native attachments + for file_path in local_files: + if human_delay > 0: + await asyncio.sleep(human_delay) + try: + ext = Path(file_path).suffix.lower() + if ext in _IMAGE_EXTS: + await self.send_image_file( + chat_id=event.source.chat_id, + image_path=file_path, + metadata=_thread_metadata, + ) + elif ext in _VIDEO_EXTS: + await self.send_video( + chat_id=event.source.chat_id, + video_path=file_path, + metadata=_thread_metadata, + ) + else: + await self.send_document( + chat_id=event.source.chat_id, + file_path=file_path, + metadata=_thread_metadata, + ) + except Exception as file_err: + logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err) + + # Determine overall success for the processing hook + processing_ok = delivery_succeeded if delivery_attempted else not bool(response) + await self._run_processing_hook( + "on_processing_complete", + event, + ProcessingOutcome.SUCCESS if processing_ok else ProcessingOutcome.FAILURE, + ) + + # Check if there's a pending message that was queued during our processing + if session_key in self._pending_messages: + pending_event = self._pending_messages.pop(session_key) + logger.debug("[%s] Processing queued message from interrupt", self.name) + # Clean up current session before processing pending + if session_key in self._active_sessions: + del self._active_sessions[session_key] + typing_task.cancel() + try: + await typing_task + except asyncio.CancelledError: + pass + # Process pending message in new background task + await self._process_message_background(pending_event, session_key) + return # Already cleaned up + + except asyncio.CancelledError: + current_task = asyncio.current_task() + outcome = ProcessingOutcome.CANCELLED + if current_task is None or current_task not in self._expected_cancelled_tasks: + outcome = ProcessingOutcome.FAILURE + await self._run_processing_hook("on_processing_complete", event, outcome) + raise + except Exception as e: + await self._run_processing_hook("on_processing_complete", event, ProcessingOutcome.FAILURE) + logger.error("[%s] Error handling message: %s", self.name, e, exc_info=True) + # Send the error to the user so they aren't left with radio silence + try: + error_type = type(e).__name__ + error_detail = str(e)[:300] if str(e) else "no details available" + _thread_metadata = {"thread_id": event.source.thread_id} if event.source.thread_id else None + await self.send( + chat_id=event.source.chat_id, + content=( + f"Sorry, I encountered an error ({error_type}).\n" + f"{error_detail}\n" + "Try again or use /reset to start a fresh session." + ), + metadata=_thread_metadata, + ) + except Exception: + pass # Last resort — don't let error reporting crash the handler + finally: + # Stop typing indicator + typing_task.cancel() + try: + await typing_task + except asyncio.CancelledError: + pass + # Also cancel any platform-level persistent typing tasks (e.g. Discord) + # that may have been recreated by _keep_typing after the last stop_typing() + try: + if hasattr(self, "stop_typing"): + await self.stop_typing(event.source.chat_id) + except Exception: + pass + # Clean up session tracking + if session_key in self._active_sessions: + del self._active_sessions[session_key] + + async def cancel_background_tasks(self) -> None: + """Cancel any in-flight background message-processing tasks. + + Used during gateway shutdown/replacement so active sessions from the old + process do not keep running after adapters are being torn down. + """ + tasks = [task for task in self._background_tasks if not task.done()] + for task in tasks: + self._expected_cancelled_tasks.add(task) + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + self._background_tasks.clear() + self._expected_cancelled_tasks.clear() + self._pending_messages.clear() + self._active_sessions.clear() + + def has_pending_interrupt(self, session_key: str) -> bool: + """Check if there's a pending interrupt for a session.""" + return session_key in self._active_sessions and self._active_sessions[session_key].is_set() + + def get_pending_message(self, session_key: str) -> Optional[MessageEvent]: + """Get and clear any pending message for a session.""" + return self._pending_messages.pop(session_key, None) + + def build_source( + self, + chat_id: str, + chat_name: Optional[str] = None, + chat_type: str = "dm", + user_id: Optional[str] = None, + user_name: Optional[str] = None, + thread_id: Optional[str] = None, + chat_topic: Optional[str] = None, + user_id_alt: Optional[str] = None, + chat_id_alt: Optional[str] = None, + ) -> SessionSource: + """Helper to build a SessionSource for this platform.""" + # Normalize empty topic to None + if chat_topic is not None and not chat_topic.strip(): + chat_topic = None + return SessionSource( + platform=self.platform, + chat_id=str(chat_id), + chat_name=chat_name, + chat_type=chat_type, + user_id=str(user_id) if user_id else None, + user_name=user_name, + thread_id=str(thread_id) if thread_id else None, + chat_topic=chat_topic.strip() if chat_topic else None, + user_id_alt=user_id_alt, + chat_id_alt=chat_id_alt, + ) + + @abstractmethod + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """ + Get information about a chat/channel. + + Returns dict with at least: + - name: Chat name + - type: "dm", "group", "channel" + """ + pass + + def format_message(self, content: str) -> str: + """ + Format a message for this platform. + + Override in subclasses to handle platform-specific formatting + (e.g., Telegram MarkdownV2, Discord markdown). + + Default implementation returns content as-is. + """ + return content + + @staticmethod + def truncate_message( + content: str, + max_length: int = 4096, + len_fn: Optional["Callable[[str], int]"] = None, + ) -> List[str]: + """ + Split a long message into chunks, preserving code block boundaries. + + When a split falls inside a triple-backtick code block, the fence is + closed at the end of the current chunk and reopened (with the original + language tag) at the start of the next chunk. Multi-chunk responses + receive indicators like ``(1/3)``. + + Args: + content: The full message content + max_length: Maximum length per chunk (platform-specific) + len_fn: Optional length function for measuring string length. + Defaults to ``len`` (Unicode code-points). Pass + ``utf16_len`` for platforms that measure message + length in UTF-16 code units (e.g. Telegram). + + Returns: + List of message chunks + """ + _len = len_fn or len + if _len(content) <= max_length: + return [content] + + INDICATOR_RESERVE = 10 # room for " (XX/XX)" + FENCE_CLOSE = "\n```" + + chunks: List[str] = [] + remaining = content + # When the previous chunk ended mid-code-block, this holds the + # language tag (possibly "") so we can reopen the fence. + carry_lang: Optional[str] = None + + while remaining: + # If we're continuing a code block from the previous chunk, + # prepend a new opening fence with the same language tag. + prefix = f"```{carry_lang}\n" if carry_lang is not None else "" + + # How much body text we can fit after accounting for the prefix, + # a potential closing fence, and the chunk indicator. + headroom = max_length - INDICATOR_RESERVE - _len(prefix) - _len(FENCE_CLOSE) + if headroom < 1: + headroom = max_length // 2 + + # Everything remaining fits in one final chunk + if _len(prefix) + _len(remaining) <= max_length - INDICATOR_RESERVE: + chunks.append(prefix + remaining) + break + + # Find a natural split point (prefer newlines, then spaces). + # When _len != len (e.g. utf16_len for Telegram), headroom is + # measured in the custom unit. We need codepoint-based slice + # positions that stay within the custom-unit budget. + # + # _safe_slice_pos() maps a custom-unit budget to the largest + # codepoint offset whose custom length ≤ budget. + if _len is not len: + # Map headroom (custom units) → codepoint slice length + _cp_limit = _custom_unit_to_cp(remaining, headroom, _len) + else: + _cp_limit = headroom + region = remaining[:_cp_limit] + split_at = region.rfind("\n") + if split_at < _cp_limit // 2: + split_at = region.rfind(" ") + if split_at < 1: + split_at = _cp_limit + + # Avoid splitting inside an inline code span (`...`). + # If the text before split_at has an odd number of unescaped + # backticks, the split falls inside inline code — the resulting + # chunk would have an unpaired backtick and any special characters + # (like parentheses) inside the broken span would be unescaped, + # causing MarkdownV2 parse errors on Telegram. + candidate = remaining[:split_at] + backtick_count = candidate.count("`") - candidate.count("\\`") + if backtick_count % 2 == 1: + # Find the last unescaped backtick and split before it + last_bt = candidate.rfind("`") + while last_bt > 0 and candidate[last_bt - 1] == "\\": + last_bt = candidate.rfind("`", 0, last_bt) + if last_bt > 0: + # Try to find a space or newline just before the backtick + safe_split = candidate.rfind(" ", 0, last_bt) + nl_split = candidate.rfind("\n", 0, last_bt) + safe_split = max(safe_split, nl_split) + if safe_split > _cp_limit // 4: + split_at = safe_split + + chunk_body = remaining[:split_at] + remaining = remaining[split_at:].lstrip() + + full_chunk = prefix + chunk_body + + # Walk only the chunk_body (not the prefix we prepended) to + # determine whether we end inside an open code block. + in_code = carry_lang is not None + lang = carry_lang or "" + for line in chunk_body.split("\n"): + stripped = line.strip() + if stripped.startswith("```"): + if in_code: + in_code = False + lang = "" + else: + in_code = True + tag = stripped[3:].strip() + lang = tag.split()[0] if tag else "" + + if in_code: + # Close the orphaned fence so the chunk is valid on its own + full_chunk += FENCE_CLOSE + carry_lang = lang + else: + carry_lang = None + + chunks.append(full_chunk) + + # Append chunk indicators when the response spans multiple messages + if len(chunks) > 1: + total = len(chunks) + chunks = [ + f"{chunk} ({i + 1}/{total})" for i, chunk in enumerate(chunks) + ] + + return chunks diff --git a/mindcli/_vendor/gateway/platforms/bluebubbles.py b/mindcli/_vendor/gateway/platforms/bluebubbles.py new file mode 100644 index 0000000..af71619 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/bluebubbles.py @@ -0,0 +1,897 @@ +"""BlueBubbles iMessage platform adapter. + +Uses the local BlueBubbles macOS server for outbound REST sends and inbound +webhooks. Supports text messaging, media attachments (images, voice, video, +documents), tapback reactions, typing indicators, and read receipts. + +Architecture based on PR #5869 (benjaminsehl) with inbound attachment +downloading from PR #4588 (YuhangLin). +""" + +import asyncio +import json +import logging +import os +import re +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional +from urllib.parse import quote + +import httpx + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, + cache_image_from_bytes, + cache_audio_from_bytes, + cache_document_from_bytes, +) +from gateway.platforms.helpers import strip_markdown + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +DEFAULT_WEBHOOK_HOST = "127.0.0.1" +DEFAULT_WEBHOOK_PORT = 8645 +DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook" +MAX_TEXT_LENGTH = 4000 + +# Tapback reaction codes (BlueBubbles associatedMessageType values) +_TAPBACK_ADDED = { + 2000: "love", 2001: "like", 2002: "dislike", + 2003: "laugh", 2004: "emphasize", 2005: "question", +} +_TAPBACK_REMOVED = { + 3000: "love", 3001: "like", 3002: "dislike", + 3003: "laugh", 3004: "emphasize", 3005: "question", +} + +# Webhook event types that carry user messages +_MESSAGE_EVENTS = {"new-message", "message", "updated-message"} + +# Log redaction patterns +_PHONE_RE = re.compile(r"\+?\d{7,15}") +_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+") + + +def _redact(text: str) -> str: + """Redact phone numbers and emails from log output.""" + text = _PHONE_RE.sub("[REDACTED]", text) + text = _EMAIL_RE.sub("[REDACTED]", text) + return text + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def check_bluebubbles_requirements() -> bool: + try: + import aiohttp # noqa: F401 + import httpx as _httpx # noqa: F401 + except ImportError: + return False + return True + + +def _normalize_server_url(raw: str) -> str: + value = (raw or "").strip() + if not value: + return "" + if not re.match(r"^https?://", value, flags=re.I): + value = f"http://{value}" + return value.rstrip("/") + + + + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class BlueBubblesAdapter(BasePlatformAdapter): + platform = Platform.BLUEBUBBLES + MAX_MESSAGE_LENGTH = MAX_TEXT_LENGTH + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.BLUEBUBBLES) + extra = config.extra or {} + self.server_url = _normalize_server_url( + extra.get("server_url") or os.getenv("BLUEBUBBLES_SERVER_URL", "") + ) + self.password = extra.get("password") or os.getenv("BLUEBUBBLES_PASSWORD", "") + self.webhook_host = ( + extra.get("webhook_host") + or os.getenv("BLUEBUBBLES_WEBHOOK_HOST", DEFAULT_WEBHOOK_HOST) + ) + self.webhook_port = int( + extra.get("webhook_port") + or os.getenv("BLUEBUBBLES_WEBHOOK_PORT", str(DEFAULT_WEBHOOK_PORT)) + ) + self.webhook_path = ( + extra.get("webhook_path") + or os.getenv("BLUEBUBBLES_WEBHOOK_PATH", DEFAULT_WEBHOOK_PATH) + ) + if not str(self.webhook_path).startswith("/"): + self.webhook_path = f"/{self.webhook_path}" + self.send_read_receipts = bool(extra.get("send_read_receipts", True)) + self.client: Optional[httpx.AsyncClient] = None + self._runner = None + self._private_api_enabled: Optional[bool] = None + self._helper_connected: bool = False + self._guid_cache: Dict[str, str] = {} + + # ------------------------------------------------------------------ + # API helpers + # ------------------------------------------------------------------ + + def _api_url(self, path: str) -> str: + sep = "&" if "?" in path else "?" + return f"{self.server_url}{path}{sep}password={quote(self.password, safe='')}" + + async def _api_get(self, path: str) -> Dict[str, Any]: + assert self.client is not None + res = await self.client.get(self._api_url(path)) + res.raise_for_status() + return res.json() + + async def _api_post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: + assert self.client is not None + res = await self.client.post(self._api_url(path), json=payload) + res.raise_for_status() + return res.json() + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def connect(self) -> bool: + if not self.server_url or not self.password: + logger.error( + "[bluebubbles] BLUEBUBBLES_SERVER_URL and BLUEBUBBLES_PASSWORD are required" + ) + return False + from aiohttp import web + + self.client = httpx.AsyncClient(timeout=30.0) + try: + await self._api_get("/api/v1/ping") + info = await self._api_get("/api/v1/server/info") + server_data = (info or {}).get("data", {}) + self._private_api_enabled = bool(server_data.get("private_api")) + self._helper_connected = bool(server_data.get("helper_connected")) + logger.info( + "[bluebubbles] connected to %s (private_api=%s, helper=%s)", + self.server_url, + self._private_api_enabled, + self._helper_connected, + ) + except Exception as exc: + logger.error( + "[bluebubbles] cannot reach server at %s: %s", self.server_url, exc + ) + if self.client: + await self.client.aclose() + self.client = None + return False + + app = web.Application() + app.router.add_get("/health", lambda _: web.Response(text="ok")) + app.router.add_post(self.webhook_path, self._handle_webhook) + self._runner = web.AppRunner(app) + await self._runner.setup() + site = web.TCPSite(self._runner, self.webhook_host, self.webhook_port) + await site.start() + self._mark_connected() + logger.info( + "[bluebubbles] webhook listening on http://%s:%s%s", + self.webhook_host, + self.webhook_port, + self.webhook_path, + ) + + # Register webhook with BlueBubbles server + # This is required for the server to know where to send events + await self._register_webhook() + + return True + + async def disconnect(self) -> None: + # Unregister webhook before cleaning up + await self._unregister_webhook() + + if self.client: + await self.client.aclose() + self.client = None + if self._runner: + await self._runner.cleanup() + self._runner = None + self._mark_disconnected() + + @property + def _webhook_url(self) -> str: + """Compute the external webhook URL for BlueBubbles registration.""" + host = self.webhook_host + if host in ("0.0.0.0", "127.0.0.1", "localhost", "::"): + host = "localhost" + return f"http://{host}:{self.webhook_port}{self.webhook_path}" + + async def _find_registered_webhooks(self, url: str) -> list: + """Return list of BB webhook entries matching *url*.""" + try: + res = await self._api_get("/api/v1/webhook") + data = res.get("data") + if isinstance(data, list): + return [wh for wh in data if wh.get("url") == url] + except Exception: + pass + return [] + + async def _register_webhook(self) -> bool: + """Register this webhook URL with the BlueBubbles server. + + BlueBubbles requires webhooks to be registered via API before + it will send events. Checks for an existing registration first + to avoid duplicates (e.g. after a crash without clean shutdown). + """ + if not self.client: + return False + + webhook_url = self._webhook_url + + # Crash resilience — reuse an existing registration if present + existing = await self._find_registered_webhooks(webhook_url) + if existing: + logger.info( + "[bluebubbles] webhook already registered: %s", webhook_url + ) + return True + + payload = { + "url": webhook_url, + "events": ["new-message", "updated-message", "message"], + } + + try: + res = await self._api_post("/api/v1/webhook", payload) + status = res.get("status", 0) + if 200 <= status < 300: + logger.info( + "[bluebubbles] webhook registered with server: %s", + webhook_url, + ) + return True + else: + logger.warning( + "[bluebubbles] webhook registration returned status %s: %s", + status, + res.get("message"), + ) + return False + except Exception as exc: + logger.warning( + "[bluebubbles] failed to register webhook with server: %s", + exc, + ) + return False + + async def _unregister_webhook(self) -> bool: + """Unregister this webhook URL from the BlueBubbles server. + + Removes *all* matching registrations to clean up any duplicates + left by prior crashes. + """ + if not self.client: + return False + + webhook_url = self._webhook_url + removed = False + + try: + for wh in await self._find_registered_webhooks(webhook_url): + wh_id = wh.get("id") + if wh_id: + res = await self.client.delete( + self._api_url(f"/api/v1/webhook/{wh_id}") + ) + res.raise_for_status() + removed = True + if removed: + logger.info( + "[bluebubbles] webhook unregistered: %s", webhook_url + ) + except Exception as exc: + logger.debug( + "[bluebubbles] failed to unregister webhook (non-critical): %s", + exc, + ) + return removed + + # ------------------------------------------------------------------ + # Chat GUID resolution + # ------------------------------------------------------------------ + + async def _resolve_chat_guid(self, target: str) -> Optional[str]: + """Resolve an email/phone to a BlueBubbles chat GUID. + + If *target* already contains a semicolon (raw GUID format like + ``iMessage;-;user@example.com``), it is returned as-is. Otherwise + the adapter queries the BlueBubbles chat list and matches on + ``chatIdentifier`` or participant address. + """ + target = (target or "").strip() + if not target: + return None + # Already a raw GUID + if ";" in target: + return target + if target in self._guid_cache: + return self._guid_cache[target] + try: + payload = await self._api_post( + "/api/v1/chat/query", + {"limit": 100, "offset": 0, "with": ["participants"]}, + ) + for chat in payload.get("data", []) or []: + guid = chat.get("guid") or chat.get("chatGuid") + identifier = chat.get("chatIdentifier") or chat.get("identifier") + if identifier == target: + if guid: + self._guid_cache[target] = guid + return guid + for part in chat.get("participants", []) or []: + if (part.get("address") or "").strip() == target and guid: + self._guid_cache[target] = guid + return guid + except Exception: + pass + return None + + async def _create_chat_for_handle( + self, address: str, message: str + ) -> SendResult: + """Create a new chat by sending the first message to *address*.""" + payload = { + "addresses": [address], + "message": message, + "tempGuid": f"temp-{datetime.utcnow().timestamp()}", + } + try: + res = await self._api_post("/api/v1/chat/new", payload) + data = res.get("data") or {} + msg_id = data.get("guid") or data.get("messageGuid") or "ok" + return SendResult(success=True, message_id=str(msg_id), raw_response=res) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + # ------------------------------------------------------------------ + # Text sending + # ------------------------------------------------------------------ + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + text = strip_markdown(content or "") + if not text: + return SendResult(success=False, error="BlueBubbles send requires text") + chunks = self.truncate_message(text, max_length=self.MAX_MESSAGE_LENGTH) + last = SendResult(success=True) + for chunk in chunks: + guid = await self._resolve_chat_guid(chat_id) + if not guid: + # If the target looks like an address, try creating a new chat + if self._private_api_enabled and ( + "@" in chat_id or re.match(r"^\+\d+", chat_id) + ): + return await self._create_chat_for_handle(chat_id, chunk) + return SendResult( + success=False, + error=f"BlueBubbles chat not found for target: {chat_id}", + ) + payload: Dict[str, Any] = { + "chatGuid": guid, + "tempGuid": f"temp-{datetime.utcnow().timestamp()}", + "message": chunk, + } + if reply_to and self._private_api_enabled and self._helper_connected: + payload["method"] = "private-api" + payload["selectedMessageGuid"] = reply_to + payload["partIndex"] = 0 + try: + res = await self._api_post("/api/v1/message/text", payload) + data = res.get("data") or {} + msg_id = data.get("guid") or data.get("messageGuid") or "ok" + last = SendResult( + success=True, message_id=str(msg_id), raw_response=res + ) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + return last + + # ------------------------------------------------------------------ + # Media sending (outbound) + # ------------------------------------------------------------------ + + async def _send_attachment( + self, + chat_id: str, + file_path: str, + filename: Optional[str] = None, + caption: Optional[str] = None, + is_audio_message: bool = False, + ) -> SendResult: + """Send a file attachment via BlueBubbles multipart upload.""" + if not self.client: + return SendResult(success=False, error="Not connected") + if not os.path.isfile(file_path): + return SendResult(success=False, error=f"File not found: {file_path}") + + guid = await self._resolve_chat_guid(chat_id) + if not guid: + return SendResult(success=False, error=f"Chat not found: {chat_id}") + + fname = filename or os.path.basename(file_path) + try: + with open(file_path, "rb") as f: + files = {"attachment": (fname, f, "application/octet-stream")} + data: Dict[str, str] = { + "chatGuid": guid, + "name": fname, + "tempGuid": uuid.uuid4().hex, + } + if is_audio_message: + data["isAudioMessage"] = "true" + res = await self.client.post( + self._api_url("/api/v1/message/attachment"), + files=files, + data=data, + timeout=120, + ) + res.raise_for_status() + result = res.json() + + if caption: + await self.send(chat_id, caption) + + if result.get("status") == 200: + rdata = result.get("data") or {} + msg_id = rdata.get("guid") if isinstance(rdata, dict) else None + return SendResult( + success=True, message_id=msg_id, raw_response=result + ) + return SendResult( + success=False, + error=result.get("message", "Attachment upload failed"), + ) + except Exception as e: + return SendResult(success=False, error=str(e)) + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + try: + from gateway.platforms.base import cache_image_from_url + + local_path = await cache_image_from_url(image_url) + return await self._send_attachment(chat_id, local_path, caption=caption) + except Exception: + return await super().send_image(chat_id, image_url, caption, reply_to) + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + return await self._send_attachment(chat_id, image_path, caption=caption) + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + return await self._send_attachment( + chat_id, audio_path, caption=caption, is_audio_message=True + ) + + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + return await self._send_attachment(chat_id, video_path, caption=caption) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + return await self._send_attachment( + chat_id, file_path, filename=file_name, caption=caption + ) + + async def send_animation( + self, + chat_id: str, + animation_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + return await self.send_image( + chat_id, animation_url, caption, reply_to, metadata + ) + + # ------------------------------------------------------------------ + # Typing indicators + # ------------------------------------------------------------------ + + async def send_typing(self, chat_id: str, metadata=None) -> None: + if not self._private_api_enabled or not self._helper_connected or not self.client: + return + try: + guid = await self._resolve_chat_guid(chat_id) + if guid: + encoded = quote(guid, safe="") + await self.client.post( + self._api_url(f"/api/v1/chat/{encoded}/typing"), timeout=5 + ) + except Exception: + pass + + async def stop_typing(self, chat_id: str) -> None: + if not self._private_api_enabled or not self._helper_connected or not self.client: + return + try: + guid = await self._resolve_chat_guid(chat_id) + if guid: + encoded = quote(guid, safe="") + await self.client.delete( + self._api_url(f"/api/v1/chat/{encoded}/typing"), timeout=5 + ) + except Exception: + pass + + # ------------------------------------------------------------------ + # Read receipts + # ------------------------------------------------------------------ + + async def mark_read(self, chat_id: str) -> bool: + if not self._private_api_enabled or not self._helper_connected or not self.client: + return False + try: + guid = await self._resolve_chat_guid(chat_id) + if guid: + encoded = quote(guid, safe="") + await self.client.post( + self._api_url(f"/api/v1/chat/{encoded}/read"), timeout=5 + ) + return True + except Exception: + pass + return False + + # ------------------------------------------------------------------ + # Tapback reactions + # ------------------------------------------------------------------ + + # ------------------------------------------------------------------ + # Chat info + # ------------------------------------------------------------------ + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + is_group = ";+;" in (chat_id or "") + info: Dict[str, Any] = { + "name": chat_id, + "type": "group" if is_group else "dm", + } + try: + guid = await self._resolve_chat_guid(chat_id) + if guid: + encoded = quote(guid, safe="") + res = await self._api_get( + f"/api/v1/chat/{encoded}?with=participants" + ) + data = (res or {}).get("data", {}) + display_name = ( + data.get("displayName") + or data.get("chatIdentifier") + or chat_id + ) + participants = [] + for p in data.get("participants", []) or []: + addr = (p.get("address") or "").strip() + if addr: + participants.append(addr) + info["name"] = display_name + if participants: + info["participants"] = participants + except Exception: + pass + return info + + def format_message(self, content: str) -> str: + return strip_markdown(content) + + # ------------------------------------------------------------------ + # Inbound attachment downloading (from #4588) + # ------------------------------------------------------------------ + + async def _download_attachment( + self, att_guid: str, att_meta: Dict[str, Any] + ) -> Optional[str]: + """Download an attachment from BlueBubbles and cache it locally. + + Returns the local file path on success, None on failure. + """ + if not self.client: + return None + try: + encoded = quote(att_guid, safe="") + resp = await self.client.get( + self._api_url(f"/api/v1/attachment/{encoded}/download"), + timeout=60, + follow_redirects=True, + ) + resp.raise_for_status() + data = resp.content + + mime = (att_meta.get("mimeType") or "").lower() + transfer_name = att_meta.get("transferName", "") + + if mime.startswith("image/"): + ext_map = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + "image/heic": ".jpg", + "image/heif": ".jpg", + "image/tiff": ".jpg", + } + ext = ext_map.get(mime, ".jpg") + return cache_image_from_bytes(data, ext) + + if mime.startswith("audio/"): + ext_map = { + "audio/mp3": ".mp3", + "audio/mpeg": ".mp3", + "audio/ogg": ".ogg", + "audio/wav": ".wav", + "audio/x-caf": ".mp3", + "audio/mp4": ".m4a", + "audio/aac": ".m4a", + } + ext = ext_map.get(mime, ".mp3") + return cache_audio_from_bytes(data, ext) + + # Videos, documents, and everything else + filename = transfer_name or f"file_{uuid.uuid4().hex[:8]}" + return cache_document_from_bytes(data, filename) + + except Exception as exc: + logger.warning( + "[bluebubbles] failed to download attachment %s: %s", + _redact(att_guid), + exc, + ) + return None + + # ------------------------------------------------------------------ + # Webhook handling + # ------------------------------------------------------------------ + + def _extract_payload_record( + self, payload: Dict[str, Any] + ) -> Optional[Dict[str, Any]]: + data = payload.get("data") + if isinstance(data, dict): + return data + if isinstance(data, list): + for item in data: + if isinstance(item, dict): + return item + if isinstance(payload.get("message"), dict): + return payload.get("message") + return payload if isinstance(payload, dict) else None + + @staticmethod + def _value(*candidates: Any) -> Optional[str]: + for candidate in candidates: + if isinstance(candidate, str) and candidate.strip(): + return candidate.strip() + return None + + async def _handle_webhook(self, request): + from aiohttp import web + + token = ( + request.query.get("password") + or request.query.get("guid") + or request.headers.get("x-password") + or request.headers.get("x-guid") + or request.headers.get("x-bluebubbles-guid") + ) + if token != self.password: + return web.json_response({"error": "unauthorized"}, status=401) + try: + raw = await request.read() + body = raw.decode("utf-8", errors="replace") + try: + payload = json.loads(body) + except Exception: + from urllib.parse import parse_qs + + form = parse_qs(body) + payload_str = ( + form.get("payload") + or form.get("data") + or form.get("message") + or [""] + )[0] + payload = json.loads(payload_str) if payload_str else {} + except Exception as exc: + logger.error("[bluebubbles] webhook parse error: %s", exc) + return web.json_response({"error": "invalid payload"}, status=400) + + event_type = self._value(payload.get("type"), payload.get("event")) or "" + # Only process message events; silently acknowledge everything else + if event_type and event_type not in _MESSAGE_EVENTS: + return web.Response(text="ok") + + record = self._extract_payload_record(payload) or {} + is_from_me = bool( + record.get("isFromMe") + or record.get("fromMe") + or record.get("is_from_me") + ) + if is_from_me: + return web.Response(text="ok") + + # Skip tapback reactions delivered as messages + assoc_type = record.get("associatedMessageType") + if isinstance(assoc_type, int) and assoc_type in { + **_TAPBACK_ADDED, + **_TAPBACK_REMOVED, + }: + return web.Response(text="ok") + + text = ( + self._value( + record.get("text"), record.get("message"), record.get("body") + ) + or "" + ) + + # --- Inbound attachment handling --- + attachments = record.get("attachments") or [] + media_urls: List[str] = [] + media_types: List[str] = [] + msg_type = MessageType.TEXT + + for att in attachments: + att_guid = att.get("guid", "") + if not att_guid: + continue + cached = await self._download_attachment(att_guid, att) + if cached: + mime = (att.get("mimeType") or "").lower() + media_urls.append(cached) + media_types.append(mime) + if mime.startswith("image/"): + msg_type = MessageType.PHOTO + elif mime.startswith("audio/") or (att.get("uti") or "").endswith( + "caf" + ): + msg_type = MessageType.VOICE + elif mime.startswith("video/"): + msg_type = MessageType.VIDEO + else: + msg_type = MessageType.DOCUMENT + + # With multiple attachments, prefer PHOTO if any images present + if len(media_urls) > 1: + mime_prefixes = {(m or "").split("/")[0] for m in media_types} + if "image" in mime_prefixes: + msg_type = MessageType.PHOTO + + if not text and media_urls: + text = "(attachment)" + # --- End attachment handling --- + + chat_guid = self._value( + record.get("chatGuid"), + payload.get("chatGuid"), + record.get("chat_guid"), + payload.get("chat_guid"), + payload.get("guid"), + ) + chat_identifier = self._value( + record.get("chatIdentifier"), + record.get("identifier"), + payload.get("chatIdentifier"), + payload.get("identifier"), + ) + sender = ( + self._value( + record.get("handle", {}).get("address") + if isinstance(record.get("handle"), dict) + else None, + record.get("sender"), + record.get("from"), + record.get("address"), + ) + or chat_identifier + or chat_guid + ) + if not (chat_guid or chat_identifier) and sender: + chat_identifier = sender + if not sender or not (chat_guid or chat_identifier) or not text: + return web.json_response({"error": "missing message fields"}, status=400) + + session_chat_id = chat_guid or chat_identifier + is_group = bool(record.get("isGroup")) or (";+;" in (chat_guid or "")) + source = self.build_source( + chat_id=session_chat_id, + chat_name=chat_identifier or sender, + chat_type="group" if is_group else "dm", + user_id=sender, + user_name=sender, + chat_id_alt=chat_identifier, + ) + event = MessageEvent( + text=text, + message_type=msg_type, + source=source, + raw_message=payload, + message_id=self._value( + record.get("guid"), + record.get("messageGuid"), + record.get("id"), + ), + reply_to_message_id=self._value( + record.get("threadOriginatorGuid"), + record.get("associatedMessageGuid"), + ), + media_urls=media_urls, + media_types=media_types, + ) + task = asyncio.create_task(self.handle_message(event)) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + + # Fire-and-forget read receipt + if self.send_read_receipts and session_chat_id: + asyncio.create_task(self.mark_read(session_chat_id)) + + return web.Response(text="ok") + diff --git a/mindcli/_vendor/gateway/platforms/dashscope_realtime.py b/mindcli/_vendor/gateway/platforms/dashscope_realtime.py new file mode 100644 index 0000000..d8964a9 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/dashscope_realtime.py @@ -0,0 +1,359 @@ +""" +MIND OS 3.0 — 实时录音 WS 管线 (Phase B) + +从 V2 asr-proxy.cjs 移植到 Python asyncio + aiohttp。 + +端点:GET /mindos-next/ws/record?token={JWT}&chatId={chatId} + +协议(与 V2 客户端兼容): + 客户端 → 服务端: binary PCM16 帧 / {"type":"stop"} + 服务端 → 客户端: {"type":"proxy_connected","chatId":"..."} + {"type":"partial","text":"..."} + {"type":"final","text":"..."} + {"type":"speech_started"/"speech_stopped"} + {"type":"asr_reconnecting","attempt":N} + {"type":"error","message":"..."} + +DashScope Realtime API(V5 协议,与 V2 asr-proxy.cjs 完全相同): + wss://dashscope.aliyuncs.com/api-ws/v1/realtime?model=qwen3-asr-flash-realtime + Auth: Authorization: bearer {DASHSCOPE_API_KEY} + 发送: {"type":"input_audio_buffer.append","audio":""} + 停止: {"type":"input_audio_buffer.commit"} + {"type":"session.finish"} + 接收: session.created / conversation.item.input_audio_transcription.* / + input_audio_buffer.speech_* / session.finished / error +""" + +import asyncio +import base64 +import json +import logging +import os +import time +from datetime import datetime +from pathlib import Path + +from aiohttp import web + +logger = logging.getLogger("dashscope_realtime") + +DASHSCOPE_ASR_URL = "wss://dashscope.aliyuncs.com/api-ws/v1/realtime" +DASHSCOPE_ASR_MODEL = os.getenv("DASHSCOPE_REALTIME_MODEL", "qwen3-asr-flash-realtime") +MAX_RECONNECTS = 3 +RECONNECT_BASE_MS = 1000 + +# ★ 架构修复:这里直接存放 MindOSSSEServer 实例的引用。 +# mindos_sse.py 在 start() 里导入本模块时调用 register_server(self), +# 始终使用同一个对象,彻底绕开 sys.modules 命名空间问题。 +_sse_server = None + +def register_server(server) -> None: + global _sse_server + _sse_server = server + logger.info("[Realtime] SSE server 已注入: %s", type(server).__name__) + + +# ─── 公开入口(由 mindos_sse.py 注册为路由) ───────────────── + +async def handleWsRecord(request: web.Request) -> web.WebSocketResponse: + """ + GET /mindos-next/ws/record?token={JWT}&chatId={chatId} + aiohttp WS handler。 + """ + from gateway.platforms.mindos_sse import _verifyTokenAsync, _CORS_HEADERS # type: ignore + + # 1. Auth via ?token= query param + token = request.rel_url.query.get("token", "") + chatId = request.rel_url.query.get("chatId", "") + if not token: + raise web.HTTPForbidden() + + user = await _verifyTokenAsync(token) + if not user: + raise web.HTTPUnauthorized() + userId = user.get("sub") or user.get("userId", "") + + if not chatId: + chatId = f"chat_{int(time.time() * 1000)}" + + meetingId = request.rel_url.query.get("meetingId") or f"rec_{int(time.time() * 1000)}" + source = request.rel_url.query.get("source", "mic") # "mic" | "system" + + logger.info("[Realtime] 客户端连接 userId=%s chatId=%s meeting=%s source=%s", userId, chatId, meetingId, source) + + # 2. 升级到 WS + clientWs = web.WebSocketResponse(heartbeat=30) + await clientWs.prepare(request) + + # 3. 初始化 MD 文件(wiki/{userId}/raw/) + wiki_root = os.getenv("MINDOS_WIKI_DIR", os.path.expanduser("~/.hermes/wiki")) + wiki_dir = Path(wiki_root) / userId / "raw" + wiki_dir.mkdir(parents=True, exist_ok=True) + + now = datetime.now() + suffix = "system" if source == "system" else "mic" + md_filename = f"{now.strftime('%Y-%m-%d_%H%M')}_{suffix}_recording.md" + md_file = wiki_dir / md_filename + rel_path = f"raw/{md_filename}" + + # 写文件头 + source_label = "系统拾音" if source == "system" else "麦克风" + md_file.write_text( + f"# 录音转写 {now.strftime('%Y-%m-%d %H:%M')}({source_label})\n\n> 实时录制于 MindOS\n\n", + encoding="utf-8", + ) + + total_chars = 0 + sentences = [] # 收集完整句子 + reconnects = 0 + ds_ready = asyncio.Event() + stop_event = asyncio.Event() + audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() + + # ─── DashScope WS 协程 ────────────────────────────────── + + async def run_dashscope(): + nonlocal reconnects, total_chars + + api_key = os.getenv("DASHSCOPE_API_KEY", "") + if not api_key: + await _send_client(clientWs, {"type": "error", "message": "ASR 未配置 DASHSCOPE_API_KEY"}) + return + + import aiohttp as _aio + url = f"{DASHSCOPE_ASR_URL}?model={DASHSCOPE_ASR_MODEL}" + headers = {"Authorization": f"bearer {api_key}"} + + while reconnects <= MAX_RECONNECTS and not stop_event.is_set(): + try: + async with _aio.ClientSession() as session: + async with session.ws_connect(url, headers=headers) as dsWs: + ds_ready.clear() # 连接成功,但等 session.created 后再就绪 + reconnects = 0 # 连上就重置 + logger.info("[Realtime] DashScope 已连接 meeting=%s", meetingId) + + async def send_audio_loop(): + """从队列取音频帧发给 DashScope""" + while True: + chunk = await audio_queue.get() + if chunk is None: + # stop 信号 + try: + await dsWs.send_str(json.dumps( + {"type": "input_audio_buffer.commit"})) + await dsWs.send_str(json.dumps( + {"type": "session.finish"})) + except Exception: + pass + return + if dsWs.closed: + return + try: + b64 = base64.b64encode(chunk).decode() + await dsWs.send_str(json.dumps( + {"type": "input_audio_buffer.append", "audio": b64})) + except Exception as e: + logger.warning("[Realtime] send audio err: %s", e) + + # 并发:发音频 + 收结果 + send_task = asyncio.create_task(send_audio_loop()) + + async for msg in dsWs: + if msg.type != _aio.WSMsgType.TEXT: + continue + try: + evt = json.loads(msg.data) + except Exception: + continue + + etype = evt.get("type", "") + + if etype == "session.created": + # V2 验证:DashScope V5 Realtime 不需要 session.update + # 直接就绪,开始接收音频帧 + ds_ready.set() + await _send_client(clientWs, { + "type": "proxy_connected", "chatId": chatId}) + + elif etype == "conversation.item.input_audio_transcription.completed": + text = (evt.get("transcript") or "").strip() + if text: + sentences.append(text) + total_chars += len(text) + elapsed = int(time.time() * 1000 - start_ms) // 1000 + mm, ss = divmod(elapsed, 60) + # 实时写 MD + with md_file.open("a", encoding="utf-8") as f: + f.write(f"[{mm:02d}:{ss:02d}] {text}\n") + await _send_client(clientWs, {"type": "final", "text": text}) + + elif etype == "conversation.item.input_audio_transcription.text": + text = (evt.get("transcript") or "").strip() + if text: + await _send_client(clientWs, {"type": "partial", "text": text}) + + elif etype in ( + "input_audio_buffer.speech_started", + "input_audio_buffer.speech_stopped", + ): + short = "speech_started" if "started" in etype else "speech_stopped" + await _send_client(clientWs, {"type": short}) + + elif etype == "session.finished": + logger.info("[Realtime] session.finished meeting=%s", meetingId) + send_task.cancel() + stop_event.set() + return + + elif etype == "error": + err_msg = evt.get("error", {}).get("message") or str(evt) + logger.error("[Realtime] DashScope error: %s", err_msg) + await _send_client(clientWs, {"type": "error", "message": err_msg}) + + send_task.cancel() + + except Exception as e: + logger.warning("[Realtime] DashScope 连接异常: %s", e) + + if stop_event.is_set(): + return + + # 重连 + reconnects += 1 + if reconnects > MAX_RECONNECTS: + logger.error("[Realtime] 重连次数耗尽 meeting=%s", meetingId) + break + + delay = RECONNECT_BASE_MS * (2 ** (reconnects - 1)) / 1000 + logger.warning("[Realtime] %ss 后重连(%d/%d) meeting=%s", + delay, reconnects, MAX_RECONNECTS, meetingId) + await _send_client(clientWs, { + "type": "asr_reconnecting", + "attempt": reconnects, + "maxAttempts": MAX_RECONNECTS, + }) + ds_ready.clear() + await asyncio.sleep(delay) + + # ─── 主循环:接收客户端消息 ────────────────────────────── + + start_ms = int(time.time() * 1000) + ds_task = asyncio.create_task(run_dashscope()) + + import aiohttp as _aio2 + async for msg in clientWs: + if msg.type == _aio2.WSMsgType.BINARY: + # PCM16 帧 → 入队 + await ds_ready.wait() # 等 DashScope 握手完成再转发 + await audio_queue.put(msg.data) + + elif msg.type == _aio2.WSMsgType.TEXT: + try: + cmd = json.loads(msg.data) + if cmd.get("type") == "stop": + logger.info("[Realtime] 收到 stop 命令 meeting=%s", meetingId) + await audio_queue.put(None) # 通知 send_audio_loop 发 commit+finish + except Exception: + pass + + elif msg.type in (_aio2.WSMsgType.ERROR, _aio2.WSMsgType.CLOSE): + break + + # 客户端断开:确保停止 + stop_event.set() + await audio_queue.put(None) + try: + await asyncio.wait_for(ds_task, timeout=10) + except (asyncio.TimeoutError, Exception): + pass + + # ─── Finalize:写 MD 结尾 + 写 DB + 推 SSE ────────────── + + # P0-4: 计算实时录音总时长 + duration_seconds = (time.time() * 1000 - start_ms) / 1000.0 + + await _finalize( + md_file=md_file, + rel_path=rel_path, + sentences=sentences, + total_chars=total_chars, + chatId=chatId, + meetingId=meetingId, + userId=userId, + duration_seconds=duration_seconds, + source=source, + ) + + logger.info("[Realtime] 会话结束 meeting=%s chars=%d", meetingId, total_chars) + return clientWs + + +# ─── 内部工具函数 ───────────────────────────────────────────── + +async def _send_client(ws: web.WebSocketResponse, data: dict) -> None: + """安全发送 JSON 到客户端""" + if ws.closed: + return + try: + await ws.send_str(json.dumps(data, ensure_ascii=False)) + except Exception: + pass + + +async def _finalize( + md_file: Path, + rel_path: str, + sentences: list[str], + total_chars: int, + chatId: str, + meetingId: str, + userId: str, + duration_seconds: float = 0.0, + source: str = "mic", +) -> None: + """写 MD 结尾、持久化到 DB、推送 SSE md:appended。 + + 使用 voice2md_atoms 共享原子层(④ db_persist + ⑤ sse_push)。 + """ + from voice2md_atoms import ( # type: ignore + persist_audio_result, push_md_appended, deduct_asr_credits, + ) + + # 写结尾标记 + try: + with md_file.open("a", encoding="utf-8") as f: + f.write("\n---(录音结束)\n") + except Exception as e: + logger.warning("[Realtime] 写 MD 结尾失败: %s", e) + + # 读完整内容(用于内嵌推送) + md_content = "" + try: + md_content = md_file.read_text(encoding="utf-8") + except Exception: + pass + + # ④ db_persist(chars=0 也写 DB,确保刷新后可恢复) + persist_audio_result( + chat_id=chatId, user_id=userId, + file_name=md_file.name, md_path=rel_path, + chars=total_chars, md_content=md_content, + oss_read_url="", # 实时录音无 OSS URL + ) + + # 积分扣减(实时录音:2 credits/秒) + asr_credits = max(1, int(duration_seconds * 2)) + deduct_asr_credits( + user_id=userId, chat_id=chatId, credits=asr_credits, + tx_type="asr_realtime", model="qwen3-asr-flash-realtime", + seconds=duration_seconds, + ) + + # ⑤ sse_push + # ★ 使用模块级 _sse_server(由 mindos_sse.start() 直接注入) + push_md_appended( + sse_server=_sse_server, user_id=userId, chat_id=chatId, + file=rel_path, chars=total_chars, md_content=md_content, + message=f"✅ 录音转写完成({total_chars} 字)", + source=source, + ) + diff --git a/mindcli/_vendor/gateway/platforms/deepview_materials.py b/mindcli/_vendor/gateway/platforms/deepview_materials.py new file mode 100644 index 0000000..5b5e4fa --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/deepview_materials.py @@ -0,0 +1,425 @@ +""" +⚠️ DEPRECATED(文档解析部分)— 请勿复制或修改以下函数 ⚠️ + +以下函数已被 infra.pipelines.anyfile2md 统一管线取代: + - _is_vlm_needed() → infra.atoms.text_sniffer + - _extract_pdf_vlm() → infra.atoms.page_rasterizer + vlm_ocr + oss_presign + - _extract_pdf_to_md() → infra.atoms.text_extractor + - _extract_docx() → infra.atoms.text_extractor + - _extract_txt() → infra.atoms.text_extractor + - _simulate_extract() → 不再需要 + +✅ _extract_audio_asr() 是 deepview 专有的音频 ASR 管线,不在废弃范围。 + +迁移方式参考 xinzong_materials.py(2026-04-20 已完成迁移): + from infra.pipelines.anyfile2md import parseLocal + result = await parseLocal(str(raw_path), original_filename) + +新的统一管线位于: + mindOSv2/hermes-overlay/infra/pipelines/anyfile2md.py + mindOSv2/hermes-overlay/infra/atoms/ (6 个原子操作) +""" + +import asyncio +import hashlib +import os +import time +import logging +import json +import re +from pathlib import Path +from hermes_constants import get_hermes_home + +logger = logging.getLogger(__name__) + +async def process_uploaded_material_oss( + oss_key: str, + original_filename: str, + context_id: str, + push_event_fn, + user_id: str, + org_id: str = "org_001" +): + """ + 1. Downloads native PDF from OSS + 2. Runs deduplication + 3. Sniffs doc to decide between VLM or pure text + 4. Executes the relevant pipeline to produce .md + """ + from hermes_constants import get_hermes_home + # 1. Fetch from OSS + oss_ak = os.getenv("ALIYUN_ACCESS_KEY_ID", "") + oss_sk = os.getenv("ALIYUN_ACCESS_KEY_SECRET", "") + oss_bucket = os.getenv("OSS_BUCKET", "meetings-dev") + oss_endpoint = os.getenv("OSS_ENDPOINT", "oss-cn-beijing.aliyuncs.com") + + if not all([oss_ak, oss_sk]): + logger.error("[DeepviewMaterials] Missing OSS keys in backend .env. Skipping ingestion.") + return + + try: + import oss2 + auth = oss2.Auth(oss_ak, oss_sk) + bucket = oss2.Bucket(auth, oss_endpoint, oss_bucket) + + logger.info(f"[DeepviewMaterials] Over internal network fetching {oss_key}...") + result = bucket.get_object(oss_key) + file_bytes = result.read() + except Exception as e: + logger.error(f"[DeepviewMaterials] OSS Download failed: {e}") + return + + # 2. Hashing + file_hash = hashlib.md5(file_bytes).hexdigest() + + # 3. 解析 Context 路由(深维专属双上下文 FS-as-Database 管线) + storageDir = os.getenv("DEEPVIEW_STORAGE_DIR", os.path.expanduser("~/Downloads/Coding/医生助理智能体/backend/storage")) + storage_root = Path(storageDir) + user_id_safe = user_id if user_id else "unknown" + org_id_safe = org_id if org_id else "org_001" + + user_dir = storage_root / "users" / user_id_safe + org_dir = storage_root / "orgs" / org_id_safe + platform_dir = storage_root / "platform" + + ext = os.path.splitext(original_filename)[1].lower() + + # 过滤恶意文件 + if ext == '.url' or ext == '.exe' or ext == '.sh': + logger.warning(f"Rejected unsafe file: {original_filename}") + return + + # 定义路由目标 + if context_id.startswith("recording:"): + recordingId = context_id.split(":", 1)[1] + parts = recordingId.split("/") + if len(parts) > 1: + # 旧格式(已归档):recording:clientId/asrId + clientId = parts[0] + asrId = parts[-1] + base_dir = user_dir / "clients" / clientId / "history" + raw_path = base_dir / f"{asrId}_raw{ext}" + md_path = base_dir / f"{asrId}.md" + else: + # 新格式(Inbox):recording:asrId + asrId = parts[0] + base_dir = user_dir / "inbox" / asrId + raw_path = base_dir / f"asr_raw{ext}" + md_path = base_dir / "asr.md" + elif context_id.startswith("client:"): + clientId = context_id.split(":", 1)[1] + base_dir = user_dir / "clients" / clientId + # 客户全景上下文:碎片化资料存档(可能是合同、病历单据等) + raw_path = base_dir / f"{file_hash}_raw{ext}" + md_path = base_dir / f"{file_hash}.md" + elif context_id.startswith("wiki:") or context_id == "deepview": + # 存入本机构域的 wiki 库 + base_dir = org_dir / "wiki" + raw_path = base_dir / f"{file_hash}_raw{ext}" + md_path = base_dir / f"{file_hash}.md" + elif context_id.startswith("platform:"): + # 预留平台运维口 + base_dir = platform_dir / "wiki" + raw_path = base_dir / f"{file_hash}_raw{ext}" + md_path = base_dir / f"{file_hash}.md" + elif context_id.startswith("doctor:"): + base_dir = user_dir + raw_path = base_dir / f"doctor_profile_raw{ext}" + md_path = base_dir / "doctor_profile.md" + else: + # Fallback 容错隔离区 + base_dir = user_dir / "misc" / context_id.replace(":", "_") + raw_path = base_dir / f"{file_hash}_raw{ext}" + md_path = base_dir / f"{file_hash}.md" + + base_dir.mkdir(parents=True, exist_ok=True) + + # 幂等性检查:文件是否已经存在 + if md_path.exists(): + logger.info(f"[DeepviewMaterials] File {original_filename} already processed for {context_id}.") + push_event_fn(user_id, "material:done", { + "projectId": context_id, + "filename": original_filename, + "fileId": file_hash + }) + return + + # 存储原始二进制文件(用于溯源和重试) + with open(raw_path, "wb") as f: + f.write(file_bytes) + + # Process + loop = asyncio.get_event_loop() + try: + if ext == ".pdf": + # Sniffer + if _is_vlm_needed(str(raw_path)): + logger.info("[DeepviewMaterials] Sniffer: Routing to OSS+VLM Pipeline.") + await loop.run_in_executor(None, _extract_pdf_vlm, str(raw_path), str(md_path), bucket, file_hash, original_filename) + else: + logger.info("[DeepviewMaterials] Sniffer: Routing to Pymupdf4llm text Pipeline.") + await loop.run_in_executor(None, _extract_pdf_to_md, str(raw_path), str(md_path), original_filename) + elif ext in [".docx", ".doc"]: + await loop.run_in_executor(None, _extract_docx, str(raw_path), str(md_path), original_filename) + elif ext in [".txt", ".md", ".csv"]: + await loop.run_in_executor(None, _extract_txt, str(raw_path), str(md_path), original_filename) + elif ext in [".m4a", ".mp3", ".wav", ".webm", ".ogg"]: + await loop.run_in_executor(None, _extract_audio_asr, str(raw_path), str(md_path), original_filename, bucket, oss_key) + else: + await loop.run_in_executor(None, _simulate_extract, str(md_path), original_filename) + + # ★ 持久化到 deepview_materials 表(DB 唯一真相) + try: + from hermes_state import SessionDB + db = SessionDB() + def _do(conn): + conn.execute( + "INSERT OR IGNORE INTO deepview_materials (id, filename, context_id, user_id, source, created_at) " + "VALUES (?, ?, ?, ?, 'upload', ?)", + (file_hash, original_filename, context_id, user_id, time.time()) + ) + db._execute_write(_do) + except Exception as e: + logger.warning(f"[DeepviewMaterials] Failed to persist to DB: {e}") + + # Emit SSE success + logger.info(f"[DeepviewMaterials] Successfully ingested {original_filename}") + push_event_fn(user_id, "material:done", { + "projectId": context_id, + "filename": original_filename, + "fileId": file_hash + }) + + except Exception as e: + logger.error(f"[DeepviewMaterials] Failed to extract {original_filename}: {e}", exc_info=True) + + +def _is_vlm_needed(pdf_path: str) -> bool: + try: + import fitz + doc = fitz.open(pdf_path) + if len(doc) == 0: return False + + check_pages = min(2, len(doc)) + total_text_len = 0 + vlm_vote = 0 + + for i in range(check_pages): + page = doc[i] + rect = page.rect + width, height = rect.width, rect.height + if width > height and (width / height) > 1.2: + vlm_vote += 1 + text = page.get_text() + total_text_len += len(text.strip()) + + doc.close() + + # If any page is landscape => PPT => VLM + if vlm_vote > 0: + return True + + # If extremely low text density => Scanned => VLM + if check_pages > 0 and (total_text_len / check_pages) < 100: + return True + + return False + except Exception as e: + logger.error(f"Sniffer error: {e}") + return False # Fallback to pymupdf text + +def _extract_pdf_vlm(pdf_path: str, md_path: str, bucket, file_hash: str, original_filename: str): + import fitz + import tempfile + from openai import OpenAI + + # 统一收拢于服务端的 LiteLLM 代理管线(彻底废弃单独的 DASHSCOPE 授权) + litellm_key = os.getenv("GEMINI_API_KEY") + litellm_base = os.getenv("GEMINI_BASE_URL", "http://127.0.0.1:4000/v1") + vlm_model = os.getenv("DEEPVIEW_MODEL", "gemini3.1pro-vertex") + + with tempfile.TemporaryDirectory() as tmp_dir: + doc = fitz.open(pdf_path) + zoom = 200 / 72 + mat = fitz.Matrix(zoom, zoom) + + urls = [] + for i, page in enumerate(doc): + pix = page.get_pixmap(matrix=mat) + img_path = os.path.join(tmp_dir, f"page_{i + 1:03d}.png") + pix.save(img_path) + pix = None + + oss_key = f"deepview-assets/{file_hash}/slides/page_{i+1:03d}.png" + bucket.put_object_from_file(oss_key, img_path) + url = bucket.sign_url('GET', oss_key, 3600*24) + urls.append(url) + + doc.close() + + if not litellm_key or not litellm_base: + with open(md_path, "w", encoding="utf-8") as f: + f.write(f"# Document: VLM Extraction Skipped\n\n缺少LiteLLM网关配置 (GEMINI_API_KEY/GEMINI_BASE_URL)。已将 {len(urls)} 页图片传至 OSS。") + return + + client = OpenAI(base_url=litellm_base, api_key=litellm_key, timeout=120) + + markdown_blocks = [] + for i, u in enumerate(urls): + prompt = "详细分析这张页面图片。如果是PPT请提炼核心观点、标题和要素。如果是扫描件请保留每一段具体文字。用纯粹的Markdown格式输出,不要使用```markdown包裹。" + try: + resp = client.chat.completions.create( + model=vlm_model, + messages=[{ + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": u}}, + {"type": "text", "text": prompt} + ] + }], + max_tokens=2048, + temperature=0.1 + ) + txt = resp.choices[0].message.content.strip() + if txt.startswith("```markdown"): + txt = txt[11:] + txt = txt.strip("`\n ") + markdown_blocks.append(f"## 第 {i+1} 页\n\n{txt}\n") + except Exception as e: + logger.error(f"LiteLLM VLM error page {i+1}: {e}") + markdown_blocks.append(f"## 第 {i+1} 页\n*(视觉提取超时或失败)*\n") + + with open(md_path, "w", encoding="utf-8") as f: + f.write(f"# Document: {original_filename}\n\n") + f.write("\n---\n".join(markdown_blocks)) + +def _extract_pdf_to_md(pdf_path: str, md_path: str, original_filename: str): + try: + import pymupdf4llm + md_text = pymupdf4llm.to_markdown(pdf_path) + with open(md_path, "w", encoding="utf-8") as f: + f.write(f"# Document: {original_filename}\n\n") + f.write(md_text) + except Exception as e: + logger.error(f"pymupdf4llm error: {e}") + _simulate_extract(md_path, os.path.basename(pdf_path)) + +def _extract_docx(docx_path: str, md_path: str, original_filename: str): + try: + import docx + doc = docx.Document(docx_path) + text = "\n\n".join([p.text for p in doc.paragraphs if p.text.strip()]) + with open(md_path, "w", encoding="utf-8") as f: + f.write(f"# Document: {original_filename}\n\n") + f.write(text) + except Exception as e: + logger.error(f"docx error: {e}") + _simulate_extract(md_path, os.path.basename(docx_path)) + +def _extract_txt(txt_path: str, md_path: str, original_filename: str): + try: + with open(txt_path, "r", encoding="utf-8") as f: + text = f.read() + with open(md_path, "w", encoding="utf-8") as f: + f.write(f"# Document: {original_filename}\n\n") + f.write(text) + except Exception as e: + logger.error(f"txt error: {e}") + _simulate_extract(md_path, os.path.basename(txt_path)) + +def _extract_audio_asr(audio_path: str, md_path: str, original_filename: str, bucket, oss_key: str): + import requests + import time + try: + dashscope_key = os.getenv("DASHSCOPE_API_KEY", "") + if not dashscope_key: + logger.error("[DeepviewMaterials] Missing DASHSCOPE_API_KEY for ASR.") + _simulate_extract(md_path, f"{original_filename} (ASR Failed: No DASHSCOPE_API_KEY)") + return + + logger.info(f"[DeepviewMaterials] Submitting Long Audio ASR Task for {original_filename}") + + # 1. 签名前端传上来的 OSS URL (给阿里大模型长音频异步读取用) + audio_url = bucket.sign_url('GET', oss_key, 3600 * 24) + + # 2. 提交异步听写任务 + headers = { + "Authorization": f"Bearer {dashscope_key}", + "Content-Type": "application/json", + "X-DashScope-Async": "enable" + } + payload = { + "model": "paraformer-v2", # qwen-audio 长语音引擎(唯一支持说话人分离的离线异步服务) + "input": {"file_urls": [audio_url]}, + "parameters": { + "diarization_enabled": True # 开启说话人角色分离 (音色解析) + } + } + + resp = requests.post("https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription", json=payload, headers=headers) + if resp.status_code != 200: + logger.error(f"[DeepviewMaterials] ASR Submit Failed: {resp.text}") + _simulate_extract(md_path, f"{original_filename} (ASR Submit Failed)") + return + + task_id = resp.json()["output"]["task_id"] + logger.info(f"[DeepviewMaterials] ASR Task Submitted: {task_id}. Polling...") + + # 3. 轮询结果 + polling_url = f"https://dashscope.aliyuncs.com/api/v1/tasks/{task_id}" + while True: + status_resp = requests.get(polling_url, headers=headers) + if status_resp.status_code != 200: + logger.error(f"[DeepviewMaterials] ASR Polling HTTP Error: {status_resp.text}") + break + + data = status_resp.json() + status = data["output"]["task_status"] + + if status == "SUCCEEDED": + result_url = data["output"]["results"][0]["transcription_url"] + result_resp = requests.get(result_url) + transcripts = result_resp.json().get("transcripts", []) + + if not transcripts: + with open(md_path, "w", encoding="utf-8") as f: + f.write(f"# 🎵 面诊录音: {original_filename}\n\n*(未能提取出任何语音)*") + return + + sentences = transcripts[0].get("sentences", []) + + # 4. 格式化落盘:音色换行隔离 + with open(md_path, "w", encoding="utf-8") as f: + f.write(f"# 🎵 面诊录音: {original_filename}\n\n") + last_speaker = None + for s in sentences: + spk = s.get("speaker_id", "Unknown") + # Paraformer 通常返回 spk_0, spk_1 或者根据音色聚类 + spk_label = f"**说话人 {spk}**" if spk != "Unknown" else "**未知说话人**" + text = s.get("text", "") + + if spk != last_speaker: + f.write(f"\n\n{spk_label}: {text}") + last_speaker = spk + else: + f.write(f" {text}") + + logger.info(f"[DeepviewMaterials] ASR Diarization successfully completed for {original_filename}") + return + + elif status == "FAILED": + logger.error(f"[DeepviewMaterials] ASR Task Failed Internally: {data}") + break + + time.sleep(3) # 轮询间隔 + + # 兜底 + _simulate_extract(md_path, f"{original_filename} (ASR Polling Failed or Timeout)") + + except Exception as e: + logger.error(f"[DeepviewMaterials] audio error: {e}", exc_info=True) + _simulate_extract(md_path, os.path.basename(audio_path)) + +def _simulate_extract(md_path: str, original_filename: str): + with open(md_path, "w", encoding="utf-8") as f: + f.write(f"# Document: {original_filename}\n\nNotice: Extracted via simple fallback parser.") diff --git a/mindcli/_vendor/gateway/platforms/deepview_sse.py b/mindcli/_vendor/gateway/platforms/deepview_sse.py new file mode 100644 index 0000000..d1be124 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/deepview_sse.py @@ -0,0 +1,1485 @@ +""" +深维面诊智能体 SSE 桥接适配器 (deepview_sse.py) +""" +import sys as _sys, os as _os +_this_dir = _os.path.dirname(_os.path.abspath(__file__)) +if _this_dir in _sys.path: + _sys.path.remove(_this_dir) +_sys.path.append(_this_dir) + +_overlay_dir = _os.environ.get("HERMES_OVERLAY_DIR", _os.path.abspath(_os.path.join(_this_dir, "..", "..", "..", "..", "mindOSv2", "hermes-overlay"))) +if not _os.path.isdir(_overlay_dir): + # Production fallback: 实际位置(通过 find /opt/apps -name hermes-overlay 确认) + _overlay_dir = "/opt/apps/mindos-next/hermes-overlay" + +if _os.path.isdir(_overlay_dir) and _overlay_dir not in _sys.path: + _sys.path.insert(0, _overlay_dir) + +import asyncio +import json +import logging +import os +import time +import uuid +from typing import Any, Dict, Optional + +try: + from aiohttp import web, ClientSession, ClientTimeout + AIOHTTP_AVAILABLE = True +except ImportError: + AIOHTTP_AVAILABLE = False + web = None + +from platforms.sse_base.sse_server import BaseSSEServer, CORS_HEADERS +from platforms.sse_base import callback_atoms as atoms + +logger = logging.getLogger(__name__) + +_CORS_HEADERS = CORS_HEADERS + +class DeepviewSSEServer(BaseSSEServer): + def __init__(self, host: str = "127.0.0.1", port: int = 8653): + super().__init__(prefix="/deepview", host=host, port=port) + self._initMaterialsDb() + self._initReportsDb() + self._initProfilesDb() + + # ── V3 宪法管理员名单(与 MindOS NEXT 保持一致) ── + _ADMIN_USER_IDS = {"11c1cece-2422-41e7-86f0-1f54b6162b95"} + + def register_routes(self, app): + # 业务 API + app.router.add_get(f"{self._prefix}/api/profile", self._handleProfileGet) + app.router.add_post(f"{self._prefix}/api/profile", self._handleProfilePost) + app.router.add_post(f"{self._prefix}/api/report/generate", self._handleReportGenerate) + app.router.add_get(f"{self._prefix}/api/report/get", self._handleReportGet) + app.router.add_get(f"{self._prefix}/api/reports/list", self._handleReportsList) + app.router.add_post(f"{self._prefix}/api/materials/upload-token", self._handleMaterialsUploadToken) + app.router.add_post(f"{self._prefix}/api/materials/confirm", self._handleMaterialsConfirm) + app.router.add_get(f"{self._prefix}/api/materials/list", self._handleMaterialsList) + app.router.add_get(f"{self._prefix}/api/clients/list", self._handleClientsList) + app.router.add_post(f"{self._prefix}/api/client/create", self._handleClientCreate) + app.router.add_get(f"{self._prefix}/api/client/profile", self._handleClientProfile) + app.router.add_post(f"{self._prefix}/api/report/archive", self._handleReportArchive) + app.router.add_get(f"{self._prefix}/api/clients/{{id}}/profile-report", self._handleClientProfileReport) + # 积分与管理 API(复用 MindOS NEXT 标准实现) + app.router.add_get(f"{self._prefix}/api/credits", self._handleCredits) + app.router.add_get(f"{self._prefix}/api/admin/dashboard", self._handleAdminDashboard) + app.router.add_post(f"{self._prefix}/api/admin/credits/topup", self._handleAdminTopup) + + def get_callback_atoms(self) -> list: + return [atoms.make_stream_delta, atoms.make_tool_thinking] + + def get_hydrate_post_processors(self) -> list: + """DeepView 后处理:过滤掉 tool/system 消息。 + + DeepView 的产出物(报告/素材)走独立 DB 表,不需要文件水合。 + 但需要过滤非可见角色的消息,避免泄漏 tool_calls 细节到前端。 + """ + from platforms.sse_base.hydrate_atoms import _filterVisibleMessages + return [_filterVisibleMessages] + + def _get_platform_source(self) -> str: + return "deepview" + + + def _get_storage_root(self) -> str: + """覆写基座方法:指向 Deepview 的 storage 目录。""" + return os.getenv( + "DEEPVIEW_STORAGE_DIR", + os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + "storage", + ), + ) + + def _resolve_org_id(self, user: dict) -> str: + """覆写基座方法:从 JWT 中提取 orgId。""" + return user.get("org", user.get("orgId", "org_001")) + + def _extract_chat_params(self, body: dict, user: dict) -> dict: + return { + "contextId": body.get("contextId", "deepview"), + "doctorId": body.get("doctorId", "doc_001") + } + + def _getUserStorageDir(self, userId: str) -> str: + storageDir = os.getenv("DEEPVIEW_STORAGE_DIR", os.path.expanduser("~/Downloads/Coding/医生助理智能体/backend/storage")) + userDir = os.path.join(storageDir, "users", userId) + os.makedirs(userDir, exist_ok=True) + return userDir + + def _getOrgStorageDir(self, orgId: str) -> str: + storageDir = os.getenv("DEEPVIEW_STORAGE_DIR", os.path.expanduser("~/Downloads/Coding/医生助理智能体/backend/storage")) + orgDir = os.path.join(storageDir, "orgs", orgId) + os.makedirs(orgDir, exist_ok=True) + return orgDir + + async def _runChat(self, userId: str, chatId: str, text: str, contextId: str = "deepview", doctorId: str = "doc_001", **kwargs) -> None: + try: + from run_agent import AIAgent + from hermes_state import SessionDB + + db = SessionDB() + loop = asyncio.get_event_loop() + + credit_check = db.check_credits(userId) + if not credit_check["allowed"]: + self._pushEvent(userId, "agent:error", { + "chatId": chatId, + "message": "今日免费额度已用完,请联系管理员充值。", + "errorType": "quota_exceeded", + "credits": credit_check, + }) + return + + _snap = db.get_session(chatId) or {} + _snap_input = _snap.get("input_tokens") or 0 + _snap_output = _snap.get("output_tokens") or 0 + + storageDir = os.getenv("DEEPVIEW_STORAGE_DIR", os.path.expanduser("~/Downloads/Coding/医生助理智能体/backend/storage")) + userDir = self._getUserStorageDir(userId) + orgId = "org_001" + orgDir = self._getOrgStorageDir(orgId) + platformDir = os.path.join(storageDir, "platform") + + # SKILL.md 已通过三元域装配自动注入到 AIAgent 的 skills system prompt, + # 此处只构建运行时上下文(SKILL.md 无法预知的动态信息) + systemPrompt = "" + + if contextId.startswith("recording:"): + recordingId = contextId.split(":", 1)[1] + parts = recordingId.split("/") + clientId = parts[0] if len(parts) > 1 else "unknown_client" + asrId = parts[-1] + asrPath = f"{userDir}/clients/{clientId}/history/{asrId}.md" + + sp_chunks = [ + "", + "## 🎙️ 当前上下文模式:单次面诊录音复盘", + f"- 录音 ASR 文件:{asrPath}", + f"- 医生风格档案:{userDir}/doctor_profile.md", + f"- 企业知识库目录:{orgDir}/wiki/", + f"- 平台规则参考:{platformDir}/wiki/", + "", + "### 你的工作重心", + "基于这一次面诊录音,分析信任断点、沟通体征、改进建议。", + "不要读取该客户的完整 profile.md(那是全景档案模式的职责)。", + "" + ] + systemPrompt += "\n".join(sp_chunks) + elif contextId.startswith("client:"): + clientId = contextId.split(":", 1)[1] + sp_chunks = [ + "", + "## 👤 当前上下文模式:客户全景档案", + f"- 客户档案目录:{userDir}/clients/{clientId}/", + f"- 核心档案:{userDir}/clients/{clientId}/profile.md", + f"- 历史录音目录:{userDir}/clients/{clientId}/history/", + f"- 医生风格档案:{userDir}/doctor_profile.md", + f"- 企业知识库目录:{orgDir}/wiki/", + f"- 平台规则参考:{platformDir}/wiki/", + "", + "### 你的工作重心", + "基于该客户的全生命周期数据,提供诊前策略、社交杠杆分析、跨品类破冰方案。", + "优先读取 profile.md 获取全景概览,必要时深入 history/ 追溯原始录音细节。", + "" + ] + systemPrompt += "\n".join(sp_chunks) + else: + systemPrompt += f"\n\n## 通用模式\n企业知识库目录:{orgDir}/wiki/\n平台规则参考:{platformDir}/wiki/\n" + + systemPrompt += "\n\n【严格输出规范】\n严禁在最终回答中输出任何 JSON 结构、工具调用过程记录、文件读取错误等底层调试信息。你必须将系统结果和发现转化为专业、流畅的文档级 Markdown 给医生阅读!\n" + + history = db.get_messages_as_conversation(chatId) + stream_cb, tool_cb, reasoning_cb = self._makeCallbacks(userId, chatId, loop) + + agent = AIAgent( + model=os.getenv("DEEPVIEW_MODEL", "gemini-pro-vertex"), + base_url=os.getenv("OPENAI_API_BASE", ""), + api_key=os.getenv("OPENAI_API_KEY", ""), + enabled_toolsets=["file"], + stream_delta_callback=stream_cb, + tool_progress_callback=tool_cb, + reasoning_callback=reasoning_cb, + quiet_mode=True, + platform="deepview", + session_id=chatId, + session_db=db, + user_id=userId, + ) + + result = await self._run_agent_task( + lambda: agent.run_conversation( + user_message=text, + system_message=systemPrompt, + conversation_history=history, + ), + userId, chatId + ) + + finalResponse = result.get("final_response", "") + if not finalResponse: + finalResponse = result.get("error", "(未生成回答)") + + _MODEL_RATES = { + "deepseek": (1, 2), + "qwen-plus": (4, 12), + "gemini-pro-vertex": (9, 36), + } + try: + session_info = db.get_session(chatId) + if session_info: + delta_input = max(0, (session_info.get("input_tokens") or 0) - _snap_input) + delta_output = max(0, (session_info.get("output_tokens") or 0) - _snap_output) + effective_model = session_info.get("model") or "qwen-plus" + in_rate, out_rate = _MODEL_RATES.get(effective_model, (4, 12)) + credits_used = max(1, (delta_input * in_rate + delta_output * out_rate) // 1000) + db.deduct_credits( + user_id=userId, credits=credits_used, tx_type="llm_chat", + session_id=chatId, model=effective_model, + raw_metric=json.dumps({ + "delta_input": delta_input, "delta_output": delta_output, + "in_rate": in_rate, "out_rate": out_rate, + }), + ) + except Exception as _ce: + logger.warning("[DeepviewSSE] credit deduction failed (non-fatal): %s", _ce) + + self._pushEvent(userId, "agent:done", { + "chatId": chatId, + "fullAnswer": finalResponse, + }) + + except Exception as e: + logger.error("[DeepviewSSE] Chat error for %s/%s: %s", userId, chatId, e, exc_info=True) + self._pushEvent(userId, "agent:error", { + "chatId": chatId, + "message": f"处理失败:{str(e)}", + }) + + def _initProfilesDb(self) -> None: + """确保 deepview_user_profiles 表存在,用于存储额外的用户档案""" + try: + from hermes_state import SessionDB + db = SessionDB() + with db._lock: + db._conn.execute(""" + CREATE TABLE IF NOT EXISTS deepview_user_profiles ( + user_id TEXT PRIMARY KEY, + real_name TEXT, + company TEXT, + role TEXT, + voice_url TEXT, + updated_at REAL NOT NULL + ) + """) + # Try to add real_name column if it's missing (for upgrade) + try: + db._conn.execute("ALTER TABLE deepview_user_profiles ADD COLUMN real_name TEXT") + except: + pass + db._conn.commit() + except Exception as e: + logger.warning("[DeepviewSSE] Failed to init profiles table: %s", e) + + async def _handleProfileGet(self, request: "web.Request") -> "web.Response": + """GET /deepview/profile""" + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=_CORS_HEADERS) + + try: + from hermes_state import SessionDB + db = SessionDB() + with db._lock: + row = db._conn.execute( + "SELECT real_name, company, role, voice_url FROM deepview_user_profiles WHERE user_id = ?", + (user["userId"],) + ).fetchone() + + if row: + return web.json_response({ + "realName": row[0], + "company": row[1], + "role": row[2], + "voiceUrl": row[3] + }, headers=_CORS_HEADERS) + else: + return web.json_response({ + "realName": "", + "company": "", + "role": "", + "voiceUrl": "" + }, headers=_CORS_HEADERS) + except Exception as e: + logger.error("Failed to get profile: %s", e) + return web.json_response({"error": str(e)}, status=500, headers=_CORS_HEADERS) + + async def _handleProfilePost(self, request: "web.Request") -> "web.Response": + """POST /deepview/profile""" + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=_CORS_HEADERS) + + try: + data = await request.json() + real_name = data.get("realName", "") + company = data.get("company", "") + role = data.get("role", "") + voice_url = data.get("voiceUrl", "") + + from hermes_state import SessionDB + import time + db = SessionDB() + with db._lock: + db._conn.execute(""" + INSERT INTO deepview_user_profiles (user_id, real_name, company, role, voice_url, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + real_name=excluded.real_name, + company=excluded.company, + role=excluded.role, + voice_url=excluded.voice_url, + updated_at=excluded.updated_at + """, (user["userId"], real_name, company, role, voice_url, time.time())) + db._conn.commit() + return web.json_response({"success": True}, headers=_CORS_HEADERS) + except Exception as e: + logger.error("Failed to set profile: %s", e) + return web.json_response({"error": str(e)}, status=500, headers=_CORS_HEADERS) + + async def _handleReportGenerate(self, request: "web.Request") -> "web.Response": + """ + POST /deepview/report/generate + Body: { contextId, doctorId? } + 同步生成 JSON 报告,包含最多 3 次重试逻辑。 + """ + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=_CORS_HEADERS) + + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400, headers=_CORS_HEADERS) + + import uuid + userId = user["userId"] + contextId = body.get("contextId", "") + doctorId = body.get("doctorId", "doc_001") + presetReportId = body.get("presetReportId") + + if not contextId: + return web.json_response({"error": "Missing contextId"}, status=400, headers=_CORS_HEADERS) + + userObj = await self._extractUser(request) + userId = userObj["userId"] if userObj else "unknown" + userDir = self._getUserStorageDir(userId) + + # 解析 orgId + orgId = userObj.get("org", "org_001") if userObj else "org_001" + orgDir = self._getOrgStorageDir(orgId) + + storageDir = os.getenv("DEEPVIEW_STORAGE_DIR", os.path.expanduser("~/Downloads/Coding/医生助理智能体/backend/storage")) + platformDir = os.path.join(storageDir, "platform") + + # SKILL.md 已通过三元域装配自动注入,此处只构建运行时上下文 + systemPrompt = f"\n\n## 运行时上下文\n企业知识库:{orgDir}/wiki/\n平台规则:{platformDir}/wiki/\n" + + if contextId.startswith("recording:"): + recordingId = contextId.split(":", 1)[1] + # Inbox 模式:ASR 文件在 inbox/{reportId}/ 下 + # 也兼容旧格式 recording:clientId/asrId + parts = recordingId.split("/") + if len(parts) > 1: + # 旧格式(已归档):recording:clientId/asrId + clientId = parts[0] + asrId = parts[-1] + asrPath = f"{userDir}/clients/{clientId}/history/{asrId}.md" + clientProfilePath = f"{userDir}/clients/{clientId}/profile.md" + reportDraftPath = f"{userDir}/clients/{clientId}/history/{asrId}/report_draft.md" + else: + # 新格式(Inbox):recording:asrId + clientId = None + asrId = parts[0] + asrPath = f"{userDir}/inbox/{asrId}/asr.md" + clientProfilePath = None + reportDraftPath = f"{userDir}/inbox/{asrId}/report_draft.md" + systemPrompt += f"\n## 🎙️ 第1模式:单条面诊录音复盘\n你的唯一任务是提取基于这通录音的战术复盘。\n录音路径:{asrPath}\n医生档案参考:{userDir}/doctor_profile.md" + systemPrompt += f"\n\n📄 【输出要求】将完整的分析报告写入以下路径:{reportDraftPath}" + systemPrompt += f"\n文件第一行必须为来源标注:" + if clientProfilePath and os.path.exists(clientProfilePath): + systemPrompt += f"\n客户档案参考:{clientProfilePath}" + systemPrompt += f"\n\n🚨 【系统硬约束:Speaker 天然推断指令】" + systemPrompt += f"\n原生录音 ASR 中仅含有 Speaker_1、Speaker_2 等无感情编号。请根据对话上下文(谁在做专业科普与处方,谁在表述容貌焦虑与顾虑)自然反演出真实的医生和客户对应关系。" + systemPrompt += f"\n在输出所有报告正文时,请直接使用真实姓名,彻底抹除 Speaker_X 痕迹。" + else: + clientId = contextId.split(":", 1)[1] if ":" in contextId else contextId + reportDraftPath = None # 全景档案模式由 _generateClientProfile 处理 + systemPrompt += f"\n## 👤 第2模式:客户全景档案战略\n你的唯一任务是生成该客户的全景战略洞察报告。\n档案路径:{userDir}/clients/{clientId}/profile.md\n历史录音目录:{userDir}/clients/{clientId}/history/\n医生风格:{userDir}/doctor_profile.md" + # 从 pipeline/deepview_xray 加载 Stage1 Prompt + try: + skill_dir = os.path.join(os.path.dirname(__file__), "..", "..", "pipeline", "deepview_xray") + with open(os.path.join(skill_dir, "PROMPT_stage1.md"), "r", encoding="utf-8") as f: + stage1PromptTemplate = f.read() + except Exception as e: + logger.error(f"[DeepviewSSE] Cannot read deepview_xray/PROMPT_stage1.md: {e}") + stage1PromptTemplate = "请按照 SKILL.md 的组件库要求生成报告。" + + stage1UserPrompt = stage1PromptTemplate.replace("{historyDir}", f"{userDir}/clients/{clientId}/history" if clientId else f"{userDir}/inbox/{asrId}") + stage1UserPrompt = stage1UserPrompt.replace("{reportMdPath}", reportDraftPath if reportDraftPath else "输出目录") + stage1UserPrompt = stage1UserPrompt.replace("{sourceRecordings}", contextId) + + + from run_agent import AIAgent + from hermes_state import SessionDB + db = SessionDB() + loop = asyncio.get_event_loop() + + try: + agent = AIAgent( + model=os.getenv("DEEPVIEW_MODEL", "gemini-pro-vertex"), + enabled_toolsets=["file"], + quiet_mode=True, + platform="deepview_xray", + session_id=str(uuid.uuid4()), + session_db=db, + user_id=userId, + ) + + userMsg = stage1UserPrompt + + result = await loop.run_in_executor( + None, + lambda: agent.run_conversation( + user_message=userMsg, + system_message=systemPrompt, + ), + ) + + # md-first: 优先从 Agent 写入的物理文件读取,兜底 final_response + if reportDraftPath and os.path.exists(reportDraftPath): + with open(reportDraftPath, "r", encoding="utf-8") as f: + mdReport = f.read().strip() + logger.info(f"[DeepviewSSE] Stage 1 done (from report_draft.md), length={len(mdReport)} chars") + else: + mdReport = result.get("final_response", "").strip() + logger.info(f"[DeepviewSSE] Stage 1 done (from final_response), length={len(mdReport)} chars") + # 兜底:如果 Agent 返回了内容但未写文件,帮它落盘(审计留痕) + if reportDraftPath and mdReport and len(mdReport) > 100: + os.makedirs(os.path.dirname(reportDraftPath), exist_ok=True) + with open(reportDraftPath, "w", encoding="utf-8") as f: + f.write(f"\n") + f.write(mdReport) + logger.info(f"[DeepviewSSE] Fallback: wrote report_draft.md from final_response") + + if not mdReport or len(mdReport) < 100: + return web.json_response({"error": "Stage 1 报告内容为空"}, status=500, headers=_CORS_HEADERS) + + except Exception as e: + logger.error(f"[DeepviewSSE] Stage 1 error: {e}") + return web.json_response({"error": f"Stage 1 failed: {e}"}, status=500, headers=_CORS_HEADERS) + + # ── Stage 2 格式引擎: qwen3-plus via LiteLLM (JSON Schema 硬约束) ── + # 读取外置 Stage2 Prompt 和 Schema + try: + skill_dir = os.path.join(os.path.dirname(__file__), "..", "..", "pipeline", "deepview_xray") + with open(os.path.join(skill_dir, "PROMPT_stage2.md"), "r", encoding="utf-8") as f: + stage2SystemPrompt = f.read() + import json + with open(os.path.join(skill_dir, "SCHEMA_xray.json"), "r", encoding="utf-8") as f: + jsonSchema = json.load(f) + except Exception as e: + logger.error(f"[DeepviewSSE] Cannot read deepview_xray Stage2 config: {e}") + stage2SystemPrompt = "你是一个 JSON 转换器。" + jsonSchema = {} + + try: + from openai import OpenAI + litellmClient = OpenAI( + base_url=os.getenv("GEMINI_BASE_URL", "http://127.0.0.1:4000/v1"), + api_key=os.getenv("GEMINI_API_KEY", "sk-placeholder"), + ) + + stage2Response = await loop.run_in_executor( + None, + lambda: litellmClient.chat.completions.create( + model=os.getenv("DEEPVIEW_STAGE2_MODEL", "qwen-plus"), + messages=[ + {"role": "system", "content": stage2SystemPrompt}, + {"role": "user", "content": mdReport} + ], + response_format={"type": "json_object"}, + max_tokens=8192, + user=userId, + extra_body={ + "metadata": { + "deepview_task": contextId, + "deepview_stage": "stage2_json", + "deepview_user": userId, + } + }, + ) + ) + + jsonOutput = stage2Response.choices[0].message.content.strip() + parsedData = json.loads(jsonOutput) + + logger.info(f"[DeepviewSSE] Stage 2 (json_object) done, keys={list(parsedData.keys())}") + xray = parsedData.get('xray', {}) + for mk in ['module1', 'module2', 'module3', 'module4', 'module5']: + logger.info(f"[DeepviewSSE] xray.{mk}: {'✅' if xray.get(mk) else '❌ MISSING'}") + + # ── 持久化:写入 DB(hermes state.db)── + import uuid + reportId = presetReportId if presetReportId else "rep_" + uuid.uuid4().hex[:8] + + # Programmatically inject the real report identifier instead of hallucinated one + parsedData['reportCode'] = f"DW-AMXG-{reportId[4:].upper()}" + parsedData['id'] = reportId + parsedData['context_id'] = contextId + + + try: + from hermes_state import SessionDB + reportDb = SessionDB() + reportJson = json.dumps(parsedData, ensure_ascii=False) + with reportDb._lock: + if presetReportId: + reportDb._conn.execute( + "UPDATE deepview_reports_v2 SET status='completed', report_json=?, created_at=? WHERE report_id=?", + (reportJson, time.time(), presetReportId) + ) + else: + reportDb._conn.execute( + "INSERT OR REPLACE INTO deepview_reports_v2 (report_id, context_id, user_id, report_json, created_at, status) VALUES (?, ?, ?, ?, ?, 'completed')", + (reportId, contextId, userId, reportJson, time.time()) + ) + reportDb._conn.commit() + logger.info(f"[DeepviewSSE] Report persisted to DB: {contextId} -> {reportId}") + except Exception as dbErr: + logger.error(f"[DeepviewSSE] DB persist failed: {dbErr}") + + safeBody = json.dumps({"success": True, "reportId": reportId, "data": parsedData}, ensure_ascii=False) + return web.Response(text=safeBody, content_type="application/json", headers=_CORS_HEADERS) + + except Exception as e: + logger.error(f"[DeepviewSSE] Stage 2 error: {e}") + return web.json_response({"error": f"Stage 2 (json conversion) failed: {e}"}, status=500, headers=_CORS_HEADERS) + + async def _handleReportGet(self, request: "web.Request") -> "web.Response": + """ + GET /deepview/report/get?reportId=rep_xxx + 从 DB 读取已持久化的报告 JSON。 + """ + if request.method == "OPTIONS": + return web.Response(status=200, headers=_CORS_HEADERS) + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=_CORS_HEADERS) + + reportId = request.query.get("reportId", "") + if not reportId: + return web.json_response({"error": "Missing reportId"}, status=400, headers=_CORS_HEADERS) + + try: + from hermes_state import SessionDB + db = SessionDB() + with db._lock: + cursor = db._conn.execute( + "SELECT report_json, created_at, client_id, context_id FROM deepview_reports_v2 WHERE report_id = ?", + (reportId,) + ) + row = cursor.fetchone() + + if not row: + return web.json_response({"error": "Report not found", "reportId": reportId}, status=404, headers=_CORS_HEADERS) + + reportData = json.loads(row[0]) + clientId = row[2] + contextId = row[3] + + # Hot patch reportCode for older generated reports that might have hallucinated ones + reportData["reportCode"] = f"DW-AMXG-{reportId[4:].upper()}" + reportData["id"] = reportId + reportData["context_id"] = contextId + + # Attach clientId into the data payload so the frontend knows if it's archived + if clientId: + reportData["clientId"] = clientId + + safeBody = json.dumps({"success": True, "data": reportData}, ensure_ascii=False) + return web.Response(text=safeBody, content_type="application/json", headers=_CORS_HEADERS) + except Exception as e: + logger.error(f"[DeepviewSSE] Report get error: {e}") + return web.json_response({"error": str(e)}, status=500, headers=_CORS_HEADERS) + + async def _handleReportsList(self, request: "web.Request") -> "web.Response": + """ + GET /deepview/reports/list + 返回当前用户所有的面诊报告,按时间倒序排列。包含生成中的占位记录。 + """ + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=_CORS_HEADERS) + + try: + from hermes_state import SessionDB + db = SessionDB() + with db._lock: + cursor = db._conn.execute( + "SELECT report_id, context_id, report_json, created_at, status, client_id FROM deepview_reports_v2 WHERE user_id = ? ORDER BY created_at DESC", + (user["userId"],) + ) + rows = cursor.fetchall() + + reports = [] + for row in rows: + rep_id, ctx_id, r_json, c_time, status, client_id = row + try: + data = json.loads(r_json) + except: + data = {} + reports.append({ + "id": rep_id, + "contextId": ctx_id, + "status": status, + "createdAt": c_time, + "clientId": client_id, + "data": data + }) + + return web.json_response({"success": True, "reports": reports}, headers=_CORS_HEADERS) + except Exception as e: + logger.error(f"[DeepviewSSE] Reports list error: {e}") + return web.json_response({"error": str(e)}, status=500, headers=_CORS_HEADERS) + + async def _handleMaterialsUploadToken(self, request: "web.Request") -> "web.Response": + """ + POST /deepview/materials/upload_token + Get a pre-signed URL for direct OSS upload. + """ + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=_CORS_HEADERS) + + try: + body = await request.json() + except: + body = {} + + filename = body.get("filename", "unknown.pdf") + import os + ext = os.path.splitext(filename)[1].lower() + import uuid + file_uid = uuid.uuid4().hex + object_key = f"deepview-raw/{user['userId']}/{file_uid}{ext}" + + oss_ak = os.getenv("ALIYUN_ACCESS_KEY_ID", "") + oss_sk = os.getenv("ALIYUN_ACCESS_KEY_SECRET", "") + oss_bucket = os.getenv("OSS_BUCKET", "meetings-dev") + oss_endpoint = os.getenv("OSS_ENDPOINT", "oss-cn-beijing.aliyuncs.com") + + if not all([oss_ak, oss_sk]): + # 降级:如果服务器没配 OSS 参数,或者本地测试,可能直接失败 + return web.json_response({"error": "Server missing OSS credentials (ALIYUN_ACCESS_KEY_ID)"}, status=500, headers=_CORS_HEADERS) + + import oss2 + auth = oss2.Auth(oss_ak, oss_sk) + bucket = oss2.Bucket(auth, oss_endpoint, oss_bucket) + + # 必须显式指定 Content-Type 来生成签名,否则前端带默认 MIME type 会导致 OSS 拒签 403 + headers = {'Content-Type': 'application/octet-stream'} + put_url = bucket.sign_url("PUT", object_key, 3600, headers=headers) + if put_url.startswith("http://"): + put_url = put_url.replace("http://", "https://", 1) + + return web.json_response({ + "putUrl": put_url, + "ossKey": object_key, + "filename": filename + }, headers=_CORS_HEADERS) + + async def _handleMaterialsConfirm(self, request: "web.Request") -> "web.Response": + """ + POST /deepview/materials/confirm + Frontend calls this after OSS PUT succeeds. + """ + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=_CORS_HEADERS) + + try: + body = await request.json() + except: + return web.json_response({"error": "Invalid JSON"}, status=400, headers=_CORS_HEADERS) + + oss_key = body.get("ossKey") + context_id = body.get("contextId", "deepview") + filename = body.get("filename", "unknown.pdf") + + if not oss_key: + return web.json_response({"error": "Missing ossKey"}, status=400, headers=_CORS_HEADERS) + + # Offload to background task + try: + from .deepview_materials import process_uploaded_material_oss + except ImportError: + from gateway.platforms.deepview_materials import process_uploaded_material_oss + + import uuid + import time + report_id = "rep_" + uuid.uuid4().hex[:8] + + # INSERT placeholder INTO deepview_reports_v2 + if context_id.startswith("recording:"): + try: + from hermes_state import SessionDB + db = SessionDB() + with db._lock: + db._conn.execute( + "INSERT INTO deepview_reports_v2 (report_id, context_id, user_id, report_json, created_at, status) VALUES (?, ?, ?, '{}', ?, 'processing')", + (report_id, context_id, user["userId"], time.time()) + ) + db._conn.commit() + except Exception as e: + logger.error(f"Failed to create processing report placeholder: {e}") + + async def _wrapper(): + # 1. ASR stage + await process_uploaded_material_oss( + oss_key=oss_key, + original_filename=filename, + context_id=context_id, + push_event_fn=self._pushEvent, + user_id=user["userId"], + org_id=user.get("org", "org_001") + ) + # 2. X-ray stage (Auto orchestrate) + if context_id.startswith("recording:"): + import sys, os + try: + port = int(os.environ.get('DEEPVIEW_SSE_PORT', 8653)) + import aiohttp + async with aiohttp.ClientSession() as session: + url = f"http://127.0.0.1:{port}/deepview/api/report/generate" + headers = {"Authorization": request.headers.get("Authorization", "")} + logger.info(f"[DeepviewSSE] Auto-triggering report generation internally for {context_id} -> {report_id}") + async with session.post(url, json={"contextId": context_id, "doctorId": "doc_001", "presetReportId": report_id}, headers=headers) as resp: + res_json = await resp.json() + if res_json.get("success"): + self._pushEvent(user["userId"], "report:ready", {"reportId": report_id}) + else: + logger.error(f"[DeepviewSSE] Internal generate failed: {res_json}") + # Mark as failed in DB + from hermes_state import SessionDB + db = SessionDB() + with db._lock: + db._conn.execute("UPDATE deepview_reports_v2 SET status='failed' WHERE report_id=?", (report_id,)) + db._conn.commit() + except Exception as e: + logger.error(f"[DeepviewSSE] Wrapper pipeline failed: {e}") + + asyncio.create_task(_wrapper()) + + return web.json_response({"received": True, "reportId": report_id if context_id.startswith("recording:") else None}, headers=_CORS_HEADERS) + + # ────────────────────────────────────────────── + # 会话列表 API + # ────────────────────────────────────────────── + + def _initReportsDb(self) -> None: + """确保 deepview_reports 表存在(复用 Hermes state.db)。""" + try: + from hermes_state import SessionDB + db = SessionDB() + with db._lock: + db._conn.execute(""" + CREATE TABLE IF NOT EXISTS deepview_reports_v2 ( + report_id TEXT PRIMARY KEY, + context_id TEXT NOT NULL, + user_id TEXT NOT NULL, + report_json TEXT NOT NULL, + created_at REAL NOT NULL, + status TEXT DEFAULT 'completed', + client_id TEXT DEFAULT NULL + ) + """) + cursor = db._conn.execute("PRAGMA table_info(deepview_reports_v2)") + columns = [row[1] for row in cursor.fetchall()] + if 'status' not in columns: + db._conn.execute("ALTER TABLE deepview_reports_v2 ADD COLUMN status TEXT DEFAULT 'completed'") + if 'client_id' not in columns: + db._conn.execute("ALTER TABLE deepview_reports_v2 ADD COLUMN client_id TEXT DEFAULT NULL") + + db._conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_deepview_reports_v2_user + ON deepview_reports_v2(user_id) + """) + db._conn.commit() + logger.info("[DeepviewSSE] Reports table ready") + except Exception as e: + logger.warning("[DeepviewSSE] Failed to init reports table: %s", e) + + # ────────────────────────────────────────────── + # 积分与管理 API(复用 MindOS NEXT 标准实现) + # ────────────────────────────────────────────── + + async def _requireAdmin(self, request): + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=_CORS_HEADERS) + if user["userId"] not in self._ADMIN_USER_IDS: + return web.json_response({"error": "Forbidden"}, status=403, headers=_CORS_HEADERS) + return user + + async def _handleCredits(self, request): + """GET /deepview/api/credits""" + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=_CORS_HEADERS) + + try: + from hermes_state import SessionDB + db = SessionDB() + credits = db.check_credits(user["userId"]) + return web.json_response(credits, headers=_CORS_HEADERS) + except Exception as e: + logger.error("Failed to check credits: %s", e) + return web.json_response({"error": str(e)}, status=500, headers=_CORS_HEADERS) + + async def _handleAdminDashboard(self, request): + """GET /deepview/api/admin/dashboard?days=7""" + result = await self._requireAdmin(request) + if isinstance(result, web.Response): + return result + try: + days = int(request.query.get("days", "7")) + from hermes_state import SessionDB + db = SessionDB() + dashboard = db.admin_dashboard(days=days) + return web.json_response(dashboard, headers=_CORS_HEADERS) + except Exception as e: + logger.error("Admin dashboard error: %s", e) + return web.json_response({"error": str(e)}, status=500, headers=_CORS_HEADERS) + + async def _handleAdminTopup(self, request): + """POST /deepview/api/admin/credits/topup""" + result = await self._requireAdmin(request) + if isinstance(result, web.Response): + return result + try: + body = await request.json() + target_user = body.get("userId") + amount = int(body.get("amount", 0)) + reason = body.get("reason", "admin_topup") + if not target_user or amount <= 0: + return web.json_response( + {"error": "需要 userId 和正整数 amount"}, status=400, headers=_CORS_HEADERS + ) + from hermes_state import SessionDB + db = SessionDB() + updated = db.admin_topup(target_user, amount, reason) + logger.info("[Admin] topup userId=%s amount=%d reason=%s", target_user, amount, reason) + return web.json_response({"ok": True, "credits": updated}, headers=_CORS_HEADERS) + except Exception as e: + logger.error("Admin topup error: %s", e) + return web.json_response({"error": str(e)}, status=500, headers=_CORS_HEADERS) + + # ────────────────────────────────────────────── + # 素材文件持久化 + # ────────────────────────────────────────────── + + # ★ 驨总基础知识库文件(预填充到 DB,统一管理) + _DEEPVIEW_BASE_FILES = [ + ("xz_0", "00_前言.md", "deepview"), + ("xz_1", "01_第一章 品牌机会论证.md", "deepview"), + ("xz_2", "02_第二章 原点市场与产品概念.md", "deepview"), + ("xz_3", "03_第三章 品牌九要素之语言表达.md", "deepview"), + ("xz_4", "04_第四章 品牌九要素之视觉表达.md", "deepview"), + ("xz_5", "05_第五章 产品设计、广告与公关建品牌.md", "deepview"), + ("xz_6", "06_第六章 新产品上市之品牌试错期.md", "deepview"), + ("xz_7", "07_第七章 品牌的增长.md", "deepview"), + ("xz_8", "08_第八章 连锁合作谈判.md", "deepview"), + ("xz_9", "09_第九章 打赢商战.md", "deepview"), + ] + + def _initMaterialsDb(self) -> None: + """确保 deepview_materials 表存在并预填基础文件。""" + try: + from hermes_state import SessionDB + db = SessionDB() + with db._lock: + db._conn.execute(""" + CREATE TABLE IF NOT EXISTS deepview_materials ( + id TEXT PRIMARY KEY, + filename TEXT NOT NULL, + context_id TEXT DEFAULT 'deepview', + user_id TEXT, + source TEXT DEFAULT 'base', + created_at REAL NOT NULL + ) + """) + db._conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_deepview_materials_ctx + ON deepview_materials(context_id) + """) + # 预填基础知识库文件 + for fid, fname, ctx in self._DEEPVIEW_BASE_FILES: + db._conn.execute( + "INSERT OR IGNORE INTO deepview_materials (id, filename, context_id, source, created_at) " + "VALUES (?, ?, ?, 'base', ?)", + (fid, fname, ctx, 0) # created_at=0 表示基础文件 + ) + db._conn.commit() + logger.info("[DeepviewSSE] Materials table ready") + except Exception as e: + logger.warning("[DeepviewSSE] Failed to init materials table: %s", e) + + async def _handleMaterialsList(self, request: "web.Request") -> "web.Response": + """ + GET /deepview/materials/list?contextId=deepview + + 返回指定 context 下的所有素材文件(基础 + 用户上传)。 + """ + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=_CORS_HEADERS) + + contextId = request.query.get("contextId", "deepview") + + try: + from hermes_state import SessionDB + db = SessionDB() + with db._lock: + cursor = db._conn.execute( + "SELECT id, filename, context_id, source " + "FROM deepview_materials WHERE context_id = ? ORDER BY created_at ASC", + (contextId,) + ) + rows = cursor.fetchall() + + files = [] + for r in rows: + row = dict(r) + files.append({ + "id": row["id"], + "name": row["filename"], + "projectId": row["context_id"], + "source": row["source"], + }) + + return web.json_response({"files": files}, headers=_CORS_HEADERS) + except Exception as e: + logger.error("Failed to list materials: %s", e) + return web.json_response({"error": str(e)}, status=500, headers=_CORS_HEADERS) + + # ────────────────────────────────────────────── + # 客户信息 API (Phase 4) + # ────────────────────────────────────────────── + + async def _handleClientsList(self, request: "web.Request") -> "web.Response": + """ + GET /deepview/clients/list + 返回所有客户记录列表。扫描 storage/clients 目录。 + """ + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=_CORS_HEADERS) + + try: + from hermes_state import SessionDB + db = SessionDB() + with db._lock: + cursor = db._conn.execute( + "SELECT c.client_id, c.name, c.phone, c.created_at, c.updated_at, p.profile_json " + "FROM deepview_clients c " + "LEFT JOIN deepview_client_profiles p ON c.client_id = p.client_id " + "WHERE c.user_id=? ORDER BY c.updated_at DESC", + (user["userId"],) + ) + rows = cursor.fetchall() + + clients = [] + for row in rows: + c_id, c_name, c_phone, c_created, c_updated, p_json = row + + ltcStatus = None + if p_json: + try: + profile = json.loads(p_json) + topics = profile.get("nextVisitBrief", {}).get("topicsToPrepare", []) + risks = profile.get("nextVisitBrief", {}).get("keyRisks", []) + ltcStatus = { + "topics": topics, + "risks": risks + } + except: + pass + + clients.append({ + "id": c_id, + "name": c_name, + "phone": c_phone, + "createdAt": c_created, + "updatedAt": c_updated, + "ltcStatus": ltcStatus + }) + + return web.json_response({"clients": clients}, headers=_CORS_HEADERS) + except Exception as e: + logger.error(f"[DeepviewSSE] DB Clients list failed: {e}") + return web.json_response({"error": str(e)}, status=500, headers=_CORS_HEADERS) + async def _handleClientCreate(self, request: "web.Request") -> "web.Response": + """ + POST /deepview/clients/create + 动态建档 + """ + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=_CORS_HEADERS) + + try: + body = await request.json() + except: + return web.json_response({"error": "Invalid JSON"}, status=400, headers=_CORS_HEADERS) + + name = body.get("name", "").strip() + phone = body.get("phone", "").strip() + + if not name: + return web.json_response({"error": "Name is required"}, status=400, headers=_CORS_HEADERS) + + import uuid + import time + clientId = f"p_{str(uuid.uuid4())[:8]}" + + userDir = self._getUserStorageDir(user["userId"]) + clientDir = os.path.join(userDir, "clients", clientId) + os.makedirs(clientDir, exist_ok=True) + + profileData = f"# 客户基本档案\n姓名:{name}\n手机号/尾号:{phone}\n建档时间:{time.strftime('%Y-%m-%d')}\n\n## 标签\n- 新客\n\n## AI 沉淀洞察\n- 暂无\n" + with open(os.path.join(clientDir, "profile.md"), "w", encoding="utf-8") as f: + f.write(profileData) + + try: + from hermes_state import SessionDB + db = SessionDB() + with db._lock: + db._conn.execute( + "INSERT INTO deepview_clients (client_id, user_id, name, phone) VALUES (?, ?, ?, ?)", + (clientId, user["userId"], name, phone) + ) + db._conn.commit() + logger.info(f"[DeepviewSSE] Created client {clientId} in DB") + except Exception as e: + logger.error(f"[DeepviewSSE] Failed to create client {clientId} in DB: {e}") + + return web.json_response({ + "id": clientId, + "name": name, + "phone": phone + }, headers=_CORS_HEADERS) + + async def _handleClientProfile(self, request: "web.Request") -> "web.Response": + """ + GET /deepview/clients/{id}/profile + 读取并返回某个客户的 profile.md。 + """ + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=_CORS_HEADERS) + + clientId = request.match_info.get("id", "") + if not clientId: + return web.json_response({"error": "Missing client ID"}, status=400, headers=_CORS_HEADERS) + + userDir = self._getUserStorageDir(user["userId"]) + profilePath = os.path.join(userDir, "clients", clientId, "profile.md") + + content = "" + if os.path.exists(profilePath): + with open(profilePath, "r", encoding="utf-8") as f: + content = f.read() + + return web.json_response({ + "id": clientId, + "profileContent": content + }, headers=_CORS_HEADERS) + + # ────────────────────────────────────────────── + # 报告归档 (Inbox → Client) + # ────────────────────────────────────────────── + + async def _handleReportArchive(self, request: "web.Request") -> "web.Response": + """ + POST /deepview/reports/archive + Body: { reportId, clientId } + 将 inbox 中的报告物理迁移到 clients/{clientId}/history/ 并更新 DB。 + """ + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=_CORS_HEADERS) + + try: + body = await request.json() + except: + return web.json_response({"error": "Invalid JSON"}, status=400, headers=_CORS_HEADERS) + + reportId = body.get("reportId", "").strip() + clientId = body.get("clientId", "").strip() + + if not reportId or not clientId: + return web.json_response({"error": "Missing reportId or clientId"}, status=400, headers=_CORS_HEADERS) + + userId = user["userId"] + userDir = self._getUserStorageDir(user["userId"]) + + # 1. 从 DB 中查询真实的 context_id 从而获取 ASR ID,执行物理文件迁移 + import shutil + from hermes_state import SessionDB + db = SessionDB() + + with db._lock: + ctx_row = db._conn.execute( + "SELECT context_id FROM deepview_reports_v2 WHERE report_id=? AND user_id=?", + (reportId, userId) + ).fetchone() + + originalCtx = ctx_row[0] if ctx_row else "" + inboxDir = None + if originalCtx.startswith("recording:"): + ctxPayload = originalCtx.split(":", 1)[1] + if "/" not in ctxPayload: + asrId = ctxPayload + inboxDir = os.path.join(userDir, "inbox", asrId) + + targetDir = os.path.join(userDir, "clients", clientId, "history", reportId) + + if inboxDir and os.path.exists(inboxDir): + os.makedirs(os.path.dirname(targetDir), exist_ok=True) + shutil.move(inboxDir, targetDir) + logger.info(f"[DeepviewSSE] Archived {inboxDir} → {targetDir}") + else: + logger.warning(f"[DeepviewSSE] Inbox dir not found or already migrated for {reportId}, skip file migration") + + # 2. DB 更新:标记 client_id + 更新 context_id + try: + from hermes_state import SessionDB + db = SessionDB() + with db._lock: + # Update report records to associate with client + cursor = db._conn.execute("SELECT report_json FROM deepview_reports_v2 WHERE report_id=? AND user_id=?", (reportId, userId)) + row = cursor.fetchone() + + # Update clientName directly into the json to avoid joins in reports/list and report/get + if row: + try: + rData = json.loads(row[0]) + + # Find client name + c_cursor = db._conn.execute("SELECT name FROM deepview_clients WHERE client_id=?", (clientId,)) + c_row = c_cursor.fetchone() + c_name = c_row[0] if c_row else "未知访客" + + rData["clientName"] = c_name + db._conn.execute("UPDATE deepview_reports_v2 SET report_json=? WHERE report_id=?", (json.dumps(rData, ensure_ascii=False), reportId)) + except: + pass + + db._conn.execute( + "UPDATE deepview_reports_v2 SET client_id=?, context_id=? WHERE report_id=? AND user_id=?", + (clientId, f"recording:{clientId}/{reportId}", reportId, userId) + ) + + # Update client's updated_at timestamp to float to top in the list + db._conn.execute("UPDATE deepview_clients SET updated_at=strftime('%s','now') WHERE client_id=?", (clientId,)) + + db._conn.commit() + logger.info(f"[DeepviewSSE] DB archived: {reportId} → client {clientId}") + except Exception as e: + logger.error(f"[DeepviewSSE] Archive DB update failed: {e}") + return web.json_response({"error": str(e)}, status=500, headers=_CORS_HEADERS) + + # 异步触发客户全景报告生成/刷新 + try: + userObj = await self._extractUser(request) + userSub = userObj["userId"] if userObj else "unknown" + userStorageDir = self._getUserStorageDir(userSub) + asyncio.create_task(self._generateClientProfile(userId, clientId, userStorageDir, userSub)) + logger.info(f"[DeepviewSSE] Async client profile generation triggered for {clientId}") + except Exception as triggerErr: + logger.error(f"[DeepviewSSE] Profile gen trigger failed: {triggerErr}") + + return web.json_response({"success": True, "reportId": reportId, "clientId": clientId}, headers=_CORS_HEADERS) + + # ────────────────────────────────────────────── + # 客户全景报告生成管道 (Profile Pipeline) + # ────────────────────────────────────────────── + + async def _generateClientProfile(self, userId: str, clientId: str, userDir: str, userSub: str) -> None: + """ + 两段式异步管道: + Stage 1 (Hermes Agent): 读取 profile.md + history/*.md → 生成 Markdown 分析 + Stage 2 (Qwen JSON): Markdown → 结构化 JSON (Schema 硬约束) + """ + import time as _time + loop = asyncio.get_event_loop() + clientDir = os.path.join(userDir, "clients", clientId) + profileMdPath = os.path.join(clientDir, "profile.md") + historyDir = os.path.join(clientDir, "history") + + # 收集所有源录音 ID + sourceRecordings = [] + if os.path.exists(historyDir): + for item in os.listdir(historyDir): + itemPath = os.path.join(historyDir, item) + if os.path.isdir(itemPath): + sourceRecordings.append(item) + elif item.endswith(".md"): + sourceRecordings.append(item.replace(".md", "")) + + if not sourceRecordings: + logger.info(f"[DeepviewSSE] No recordings for client {clientId}, skipping profile gen") + return + + # ── Stage 1: Hermes Agent (md2md) ── + storageDir = os.getenv("DEEPVIEW_STORAGE_DIR", os.path.expanduser("~/Downloads/Coding/医生助理智能体/backend/storage")) + orgId = "org_001" + orgDir = self._getOrgStorageDir(orgId) + platformDir = os.path.join(storageDir, "platform") + + # SKILL.md 已通过三元域装配自动注入,此处只构建运行时上下文 + systemPrompt = f"\n\n## 运行时上下文\n企业知识库:{orgDir}/wiki/\n平台规则:{platformDir}/wiki/\n\n" + + # Read phase 1 prompt from external file + stage1PromptPath = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + "pipeline", "deepview_profile", "PROMPT_stage1.md", + ) + if os.path.exists(stage1PromptPath): + with open(stage1PromptPath, "r", encoding="utf-8") as f: + stage1Prompt = f.read() + else: + stage1Prompt = "## 👤 客户全景档案生成模式\n请生成档案。" # Fallback + + stage1Prompt = stage1Prompt.replace("{profileMdPath}", profileMdPath) + stage1Prompt = stage1Prompt.replace("{historyDir}", historyDir) + stage1Prompt = stage1Prompt.replace("{userDir}", userDir) + + systemPrompt += stage1Prompt + + mdReport = "" + try: + from run_agent import AIAgent + from hermes_state import SessionDB + db = SessionDB() + + import uuid + agent = AIAgent( + model=os.getenv("DEEPVIEW_MODEL", "gemini-pro-vertex"), + enabled_toolsets=["file"], + quiet_mode=True, + platform="deepview", + session_id=str(uuid.uuid4()), + session_db=db, + user_id=userId, + ) + + result = await loop.run_in_executor( + None, + lambda: agent.run_conversation( + user_message=( + f"请读取 {historyDir}/ 下所有历史录音文件(优先读取 report_draft.md 摘要,必要时回溯 asr.md 原文验证)," + f"生成该客户的全景档案报告并写入 {profileMdPath}。" + f"每条洞察必须标注来源录音文件名。" + f"文件第一行必须为:" + ), + system_message=systemPrompt, + ), + ) + # Agent 通常通过 write_file 将分析结果写入 profile.md, + # final_response 仅是操作完成的告知性消息。 + # 因此优先从物理文件获取分析内容,final_response 作为兜底。 + if os.path.exists(profileMdPath): + with open(profileMdPath, "r", encoding="utf-8") as f: + mdReport = f.read().strip() + logger.info(f"[DeepviewSSE] Profile Stage 1 done (from file), length={len(mdReport)} chars") + else: + mdReport = result.get("final_response", "").strip() + logger.info(f"[DeepviewSSE] Profile Stage 1 done (from response), length={len(mdReport)} chars") + except Exception as e: + logger.error(f"[DeepviewSSE] Profile Stage 1 failed for {clientId}: {e}") + return + + if not mdReport or len(mdReport) < 50: + logger.warning(f"[DeepviewSSE] Profile Stage 1 output too short for {clientId}") + return + + # ── Stage 2: JSON 格式引擎 ── + schemaPath = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + "pipeline", "deepview_profile", "SCHEMA_profile.json", + ) + try: + with open(schemaPath, "r", encoding="utf-8") as f: + profileSchema = json.load(f) + except Exception as e: + logger.error(f"[DeepviewSSE] Failed to load SCHEMA_profile.json: {e}") + profileSchema = {} + + stage2PromptPath = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + "pipeline", "deepview_profile", "PROMPT_stage2.md", + ) + if os.path.exists(stage2PromptPath): + with open(stage2PromptPath, "r", encoding="utf-8") as f: + stage2Prompt = f.read() + else: + stage2Prompt = "你是一个 JSON 格式转换器。" # Fallback + + # 将外置 Schema 注入 Stage 2 的 system prompt 中,作为硬约束参考 + if profileSchema: + stage2Prompt += f"\n\n## 目标 JSON Schema(严格遵循)\n```json\n{json.dumps(profileSchema, indent=2, ensure_ascii=False)}\n```" + + try: + from openai import OpenAI + litellmClient = OpenAI( + base_url=os.getenv("GEMINI_BASE_URL", "http://127.0.0.1:4000/v1"), + api_key=os.getenv("GEMINI_API_KEY", "sk-placeholder"), + ) + + stage2Response = await loop.run_in_executor( + None, + lambda: litellmClient.chat.completions.create( + model=os.getenv("DEEPVIEW_STAGE2_MODEL", "qwen-plus"), + messages=[ + {"role": "system", "content": stage2Prompt}, + {"role": "user", "content": mdReport} + ], + response_format={"type": "json_object"}, + max_tokens=8192, + ) + ) + + jsonOutput = stage2Response.choices[0].message.content.strip() + parsedProfile = json.loads(jsonOutput) + logger.info(f"[DeepviewSSE] Profile Stage 2 done for {clientId}, keys={list(parsedProfile.keys())}") + + # Inject metadata + parsedProfile["_meta"] = { + "generatedAt": _time.strftime("%Y-%m-%dT%H:%M:%S+08:00"), + "generatedBy": "deepview_profile_pipeline_v1", + "sourceRecordings": sourceRecordings, + "modelUsed": f"{os.getenv('DEEPVIEW_MODEL', 'gemini-pro-vertex')} + {os.getenv('DEEPVIEW_STAGE2_MODEL', 'qwen-plus')}" + } + parsedProfile["clientId"] = clientId + + # Persist to DB + from hermes_state import SessionDB + profileDb = SessionDB() + + # 绝对真理:客户姓名是确定性数据,无论 AI 填了什么都需要以人工录入的数据库记录为准 + try: + with profileDb._lock: + name_cursor = profileDb._conn.execute("SELECT name FROM deepview_clients WHERE client_id=?", (clientId,)) + name_row = name_cursor.fetchone() + if name_row and name_row[0]: + parsedProfile["clientName"] = name_row[0] + except Exception as e: + logger.warning(f"[DeepviewSSE] Failed to lookup client name for forceful overwrite: {e}") + + profileJson = json.dumps(parsedProfile, ensure_ascii=False) + with profileDb._lock: + profileDb._conn.execute( + "INSERT OR REPLACE INTO deepview_client_profiles (client_id, user_id, profile_json, generated_at, source_recordings) VALUES (?, ?, ?, ?, ?)", + (clientId, userId, profileJson, _time.time(), json.dumps(sourceRecordings)) + ) + profileDb._conn.commit() + logger.info(f"[DeepviewSSE] Profile persisted to DB for {clientId}") + + # Notify via SSE + self._pushEvent(userId, "profile:done", { + "clientId": clientId, + "clientName": parsedProfile.get("clientName", ""), + }) + + except Exception as e: + logger.error(f"[DeepviewSSE] Profile Stage 2 failed for {clientId}: {e}") + # Notify error + self._pushEvent(userId, "profile:error", { + "clientId": clientId, + "error": str(e), + }) + + async def _handleClientProfileReport(self, request: "web.Request") -> "web.Response": + """ + GET /deepview/clients/{id}/profile-report + 返回客户全景档案的 JSON 报告。 + """ + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=_CORS_HEADERS) + + clientId = request.match_info.get("id", "") + if not clientId: + return web.json_response({"error": "Missing client ID"}, status=400, headers=_CORS_HEADERS) + + try: + from hermes_state import SessionDB + db = SessionDB() + with db._lock: + cursor = db._conn.execute( + "SELECT profile_json, generated_at FROM deepview_client_profiles WHERE client_id=?", + (clientId,) + ) + row = cursor.fetchone() + + if not row: + return web.json_response({"error": "No profile report yet", "clientId": clientId}, status=404, headers=_CORS_HEADERS) + + profileData = json.loads(row[0]) + + # 绝对真理:每次读取时,强制用数据库当前的真实姓名覆盖 JSON(防止改名不同步或 AI 幻觉) + with db._lock: + name_cursor = db._conn.execute("SELECT name FROM deepview_clients WHERE client_id=?", (clientId,)) + name_row = name_cursor.fetchone() + if name_row and name_row[0]: + profileData["clientName"] = name_row[0] + + safeBody = json.dumps({"success": True, "data": profileData, "generatedAt": row[1]}, ensure_ascii=False) + return web.Response(text=safeBody, content_type="application/json", headers=_CORS_HEADERS) + except Exception as e: + logger.error(f"[DeepviewSSE] Profile report get error: {e}") + return web.json_response({"error": str(e)}, status=500, headers=_CORS_HEADERS) + + # ────────────────────────────────────────────── + # 服务器生命周期 + # ────────────────────────────────────────────── + +async def main(): + """直接运行此文件来启动深维 SSE 服务器。""" + import sys + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + + logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s") + + server = DeepviewSSEServer( + host=os.getenv("DEEPVIEW_SSE_HOST", "127.0.0.1"), + port=int(os.getenv("DEEPVIEW_SSE_PORT", "8653")), + ) + await server.start() + + # 保持运行 + try: + while True: + await asyncio.sleep(3600) + except KeyboardInterrupt: + pass + finally: + await server.stop() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/mindcli/_vendor/gateway/platforms/dingtalk.py b/mindcli/_vendor/gateway/platforms/dingtalk.py new file mode 100644 index 0000000..dfa4f73 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/dingtalk.py @@ -0,0 +1,333 @@ +""" +DingTalk platform adapter using Stream Mode. + +Uses dingtalk-stream SDK for real-time message reception without webhooks. +Responses are sent via DingTalk's session webhook (markdown format). + +Requires: + pip install dingtalk-stream httpx + DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET env vars + +Configuration in config.yaml: + platforms: + dingtalk: + enabled: true + extra: + client_id: "your-app-key" # or DINGTALK_CLIENT_ID env var + client_secret: "your-secret" # or DINGTALK_CLIENT_SECRET env var +""" + +import asyncio +import logging +import os +import re +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +try: + import dingtalk_stream + from dingtalk_stream import ChatbotHandler, ChatbotMessage + DINGTALK_STREAM_AVAILABLE = True +except ImportError: + DINGTALK_STREAM_AVAILABLE = False + dingtalk_stream = None # type: ignore[assignment] + +try: + import httpx + HTTPX_AVAILABLE = True +except ImportError: + HTTPX_AVAILABLE = False + httpx = None # type: ignore[assignment] + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.helpers import MessageDeduplicator +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, +) + +logger = logging.getLogger(__name__) + +MAX_MESSAGE_LENGTH = 20000 +RECONNECT_BACKOFF = [2, 5, 10, 30, 60] +_SESSION_WEBHOOKS_MAX = 500 +_DINGTALK_WEBHOOK_RE = re.compile(r'^https://api\.dingtalk\.com/') + + +def check_dingtalk_requirements() -> bool: + """Check if DingTalk dependencies are available and configured.""" + if not DINGTALK_STREAM_AVAILABLE or not HTTPX_AVAILABLE: + return False + if not os.getenv("DINGTALK_CLIENT_ID") or not os.getenv("DINGTALK_CLIENT_SECRET"): + return False + return True + + +class DingTalkAdapter(BasePlatformAdapter): + """DingTalk chatbot adapter using Stream Mode. + + The dingtalk-stream SDK maintains a long-lived WebSocket connection. + Incoming messages arrive via a ChatbotHandler callback. Replies are + sent via the incoming message's session_webhook URL using httpx. + """ + + MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.DINGTALK) + + extra = config.extra or {} + self._client_id: str = extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID", "") + self._client_secret: str = extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET", "") + + self._stream_client: Any = None + self._stream_task: Optional[asyncio.Task] = None + self._http_client: Optional["httpx.AsyncClient"] = None + + # Message deduplication + self._dedup = MessageDeduplicator(max_size=1000) + # Map chat_id -> session_webhook for reply routing + self._session_webhooks: Dict[str, str] = {} + + # -- Connection lifecycle ----------------------------------------------- + + async def connect(self) -> bool: + """Connect to DingTalk via Stream Mode.""" + if not DINGTALK_STREAM_AVAILABLE: + logger.warning("[%s] dingtalk-stream not installed. Run: pip install dingtalk-stream", self.name) + return False + if not HTTPX_AVAILABLE: + logger.warning("[%s] httpx not installed. Run: pip install httpx", self.name) + return False + if not self._client_id or not self._client_secret: + logger.warning("[%s] DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET required", self.name) + return False + + try: + self._http_client = httpx.AsyncClient(timeout=30.0) + + credential = dingtalk_stream.Credential(self._client_id, self._client_secret) + self._stream_client = dingtalk_stream.DingTalkStreamClient(credential) + + # Capture the current event loop for cross-thread dispatch + loop = asyncio.get_running_loop() + handler = _IncomingHandler(self, loop) + self._stream_client.register_callback_handler( + dingtalk_stream.ChatbotMessage.TOPIC, handler + ) + + self._stream_task = asyncio.create_task(self._run_stream()) + self._mark_connected() + logger.info("[%s] Connected via Stream Mode", self.name) + return True + except Exception as e: + logger.error("[%s] Failed to connect: %s", self.name, e) + return False + + async def _run_stream(self) -> None: + """Run the blocking stream client with auto-reconnection.""" + backoff_idx = 0 + while self._running: + try: + logger.debug("[%s] Starting stream client...", self.name) + await asyncio.to_thread(self._stream_client.start) + except asyncio.CancelledError: + return + except Exception as e: + if not self._running: + return + logger.warning("[%s] Stream client error: %s", self.name, e) + + if not self._running: + return + + delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)] + logger.info("[%s] Reconnecting in %ds...", self.name, delay) + await asyncio.sleep(delay) + backoff_idx += 1 + + async def disconnect(self) -> None: + """Disconnect from DingTalk.""" + self._running = False + self._mark_disconnected() + + if self._stream_task: + self._stream_task.cancel() + try: + await self._stream_task + except asyncio.CancelledError: + pass + self._stream_task = None + + if self._http_client: + await self._http_client.aclose() + self._http_client = None + + self._stream_client = None + self._session_webhooks.clear() + self._dedup.clear() + logger.info("[%s] Disconnected", self.name) + + # -- Inbound message processing ----------------------------------------- + + async def _on_message(self, message: "ChatbotMessage") -> None: + """Process an incoming DingTalk chatbot message.""" + msg_id = getattr(message, "message_id", None) or uuid.uuid4().hex + if self._dedup.is_duplicate(msg_id): + logger.debug("[%s] Duplicate message %s, skipping", self.name, msg_id) + return + + text = self._extract_text(message) + if not text: + logger.debug("[%s] Empty message, skipping", self.name) + return + + # Chat context + conversation_id = getattr(message, "conversation_id", "") or "" + conversation_type = getattr(message, "conversation_type", "1") + is_group = str(conversation_type) == "2" + sender_id = getattr(message, "sender_id", "") or "" + sender_nick = getattr(message, "sender_nick", "") or sender_id + sender_staff_id = getattr(message, "sender_staff_id", "") or "" + + chat_id = conversation_id or sender_id + chat_type = "group" if is_group else "dm" + + # Store session webhook for reply routing (validate origin to prevent SSRF) + session_webhook = getattr(message, "session_webhook", None) or "" + if session_webhook and chat_id and _DINGTALK_WEBHOOK_RE.match(session_webhook): + if len(self._session_webhooks) >= _SESSION_WEBHOOKS_MAX: + # Evict oldest entry to cap memory growth + try: + self._session_webhooks.pop(next(iter(self._session_webhooks))) + except StopIteration: + pass + self._session_webhooks[chat_id] = session_webhook + + source = self.build_source( + chat_id=chat_id, + chat_name=getattr(message, "conversation_title", None), + chat_type=chat_type, + user_id=sender_id, + user_name=sender_nick, + user_id_alt=sender_staff_id if sender_staff_id else None, + ) + + # Parse timestamp + create_at = getattr(message, "create_at", None) + try: + timestamp = datetime.fromtimestamp(int(create_at) / 1000, tz=timezone.utc) if create_at else datetime.now(tz=timezone.utc) + except (ValueError, OSError, TypeError): + timestamp = datetime.now(tz=timezone.utc) + + event = MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=source, + message_id=msg_id, + raw_message=message, + timestamp=timestamp, + ) + + logger.debug("[%s] Message from %s in %s: %s", + self.name, sender_nick, chat_id[:20] if chat_id else "?", text[:50]) + await self.handle_message(event) + + @staticmethod + def _extract_text(message: "ChatbotMessage") -> str: + """Extract plain text from a DingTalk chatbot message.""" + text = getattr(message, "text", None) or "" + if isinstance(text, dict): + content = text.get("content", "").strip() + else: + content = str(text).strip() + + # Fall back to rich text if present + if not content: + rich_text = getattr(message, "rich_text", None) + if rich_text and isinstance(rich_text, list): + parts = [item["text"] for item in rich_text + if isinstance(item, dict) and item.get("text")] + content = " ".join(parts).strip() + return content + + # -- Outbound messaging ------------------------------------------------- + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a markdown reply via DingTalk session webhook.""" + metadata = metadata or {} + + session_webhook = metadata.get("session_webhook") or self._session_webhooks.get(chat_id) + if not session_webhook: + return SendResult(success=False, + error="No session_webhook available. Reply must follow an incoming message.") + + if not self._http_client: + return SendResult(success=False, error="HTTP client not initialized") + + payload = { + "msgtype": "markdown", + "markdown": {"title": "Hermes", "text": content[:self.MAX_MESSAGE_LENGTH]}, + } + + try: + resp = await self._http_client.post(session_webhook, json=payload, timeout=15.0) + if resp.status_code < 300: + return SendResult(success=True, message_id=uuid.uuid4().hex[:12]) + body = resp.text + logger.warning("[%s] Send failed HTTP %d: %s", self.name, resp.status_code, body[:200]) + return SendResult(success=False, error=f"HTTP {resp.status_code}: {body[:200]}") + except httpx.TimeoutException: + return SendResult(success=False, error="Timeout sending message to DingTalk") + except Exception as e: + logger.error("[%s] Send error: %s", self.name, e) + return SendResult(success=False, error=str(e)) + + async def send_typing(self, chat_id: str, metadata=None) -> None: + """DingTalk does not support typing indicators.""" + pass + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Return basic info about a DingTalk conversation.""" + return {"name": chat_id, "type": "group" if "group" in chat_id.lower() else "dm"} + + +# --------------------------------------------------------------------------- +# Internal stream handler +# --------------------------------------------------------------------------- + +class _IncomingHandler(ChatbotHandler if DINGTALK_STREAM_AVAILABLE else object): + """dingtalk-stream ChatbotHandler that forwards messages to the adapter.""" + + def __init__(self, adapter: DingTalkAdapter, loop: asyncio.AbstractEventLoop): + if DINGTALK_STREAM_AVAILABLE: + super().__init__() + self._adapter = adapter + self._loop = loop + + def process(self, message: "ChatbotMessage"): + """Called by dingtalk-stream in its thread when a message arrives. + + Schedules the async handler on the main event loop. + """ + loop = self._loop + if loop is None or loop.is_closed(): + logger.error("[DingTalk] Event loop unavailable, cannot dispatch message") + return dingtalk_stream.AckMessage.STATUS_OK, "OK" + + future = asyncio.run_coroutine_threadsafe(self._adapter._on_message(message), loop) + try: + future.result(timeout=60) + except Exception: + logger.exception("[DingTalk] Error processing incoming message") + + return dingtalk_stream.AckMessage.STATUS_OK, "OK" diff --git a/mindcli/_vendor/gateway/platforms/discord.py b/mindcli/_vendor/gateway/platforms/discord.py new file mode 100644 index 0000000..51a8780 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/discord.py @@ -0,0 +1,2963 @@ +from __future__ import annotations + +""" +Discord platform adapter. + +Uses discord.py library for: +- Receiving messages from servers and DMs +- Sending responses back +- Handling threads and channels +""" + +import asyncio +import logging +import os +import struct +import subprocess +import tempfile +import threading +import time +from collections import defaultdict +from typing import Callable, Dict, Optional, Any + +logger = logging.getLogger(__name__) + +VALID_THREAD_AUTO_ARCHIVE_MINUTES = {60, 1440, 4320, 10080} + +try: + import discord + from discord import Message as DiscordMessage, Intents + from discord.ext import commands + DISCORD_AVAILABLE = True +except ImportError: + DISCORD_AVAILABLE = False + discord = None + DiscordMessage = Any + Intents = Any + commands = None + +import sys +from pathlib import Path as _Path +sys.path.insert(0, str(_Path(__file__).resolve().parents[2])) + +from gateway.config import Platform, PlatformConfig +import re + +from gateway.platforms.helpers import MessageDeduplicator, ThreadParticipationTracker +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + ProcessingOutcome, + SendResult, + cache_image_from_url, + cache_audio_from_url, + cache_document_from_bytes, + SUPPORTED_DOCUMENT_TYPES, +) +from tools.url_safety import is_safe_url + + +def _clean_discord_id(entry: str) -> str: + """Strip common prefixes from a Discord user ID or username entry. + + Users sometimes paste IDs with prefixes like ``user:123``, ``<@123>``, + or ``<@!123>`` from Discord's UI or other tools. This normalises the + entry to just the bare ID or username. + """ + entry = entry.strip() + # Strip Discord mention syntax: <@123> or <@!123> + if entry.startswith("<@") and entry.endswith(">"): + entry = entry.lstrip("<@!").rstrip(">") + # Strip "user:" prefix (seen in some Discord tools / onboarding pastes) + if entry.lower().startswith("user:"): + entry = entry[5:] + return entry.strip() + + +def check_discord_requirements() -> bool: + """Check if Discord dependencies are available.""" + return DISCORD_AVAILABLE + + +class VoiceReceiver: + """Captures and decodes voice audio from a Discord voice channel. + + Attaches to a VoiceClient's socket listener, decrypts RTP packets + (NaCl transport + DAVE E2EE), decodes Opus to PCM, and buffers + per-user audio. A polling loop detects silence and delivers + completed utterances via a callback. + """ + + SILENCE_THRESHOLD = 1.5 # seconds of silence → end of utterance + MIN_SPEECH_DURATION = 0.5 # minimum seconds to process (skip noise) + SAMPLE_RATE = 48000 # Discord native rate + CHANNELS = 2 # Discord sends stereo + + def __init__(self, voice_client, allowed_user_ids: set = None): + self._vc = voice_client + self._allowed_user_ids = allowed_user_ids or set() + self._running = False + + # Decryption + self._secret_key: Optional[bytes] = None + self._dave_session = None + self._bot_ssrc: int = 0 + + # SSRC -> user_id mapping (populated from SPEAKING events) + self._ssrc_to_user: Dict[int, int] = {} + self._lock = threading.Lock() + + # Per-user audio buffers + self._buffers: Dict[int, bytearray] = defaultdict(bytearray) + self._last_packet_time: Dict[int, float] = {} + + # Opus decoder per SSRC (each user needs own decoder state) + self._decoders: Dict[int, object] = {} + + # Pause flag: don't capture while bot is playing TTS + self._paused = False + + # Debug logging counter (instance-level to avoid cross-instance races) + self._packet_debug_count = 0 + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def start(self): + """Start listening for voice packets.""" + conn = self._vc._connection + self._secret_key = bytes(conn.secret_key) + self._dave_session = conn.dave_session + self._bot_ssrc = conn.ssrc + + self._install_speaking_hook(conn) + conn.add_socket_listener(self._on_packet) + self._running = True + logger.info("VoiceReceiver started (bot_ssrc=%d)", self._bot_ssrc) + + def stop(self): + """Stop listening and clean up.""" + self._running = False + try: + self._vc._connection.remove_socket_listener(self._on_packet) + except Exception: + pass + with self._lock: + self._buffers.clear() + self._last_packet_time.clear() + self._decoders.clear() + self._ssrc_to_user.clear() + logger.info("VoiceReceiver stopped") + + def pause(self): + self._paused = True + + def resume(self): + self._paused = False + + # ------------------------------------------------------------------ + # SSRC -> user_id mapping via SPEAKING opcode hook + # ------------------------------------------------------------------ + + def map_ssrc(self, ssrc: int, user_id: int): + with self._lock: + self._ssrc_to_user[ssrc] = user_id + + def _install_speaking_hook(self, conn): + """Wrap the voice websocket hook to capture SPEAKING events (op 5). + + VoiceConnectionState stores the hook as ``conn.hook`` (public attr). + It is passed to DiscordVoiceWebSocket on each (re)connect, so we + must wrap it on the VoiceConnectionState level AND on the current + live websocket instance. + """ + original_hook = conn.hook + receiver_self = self + + async def wrapped_hook(ws, msg): + if isinstance(msg, dict) and msg.get("op") == 5: + data = msg.get("d", {}) + ssrc = data.get("ssrc") + user_id = data.get("user_id") + if ssrc and user_id: + logger.info("SPEAKING event: ssrc=%d -> user=%s", ssrc, user_id) + receiver_self.map_ssrc(int(ssrc), int(user_id)) + if original_hook: + await original_hook(ws, msg) + + # Set on connection state (for future reconnects) + conn.hook = wrapped_hook + # Set on the current live websocket (for immediate effect) + try: + from discord.utils import MISSING + if hasattr(conn, 'ws') and conn.ws is not MISSING: + conn.ws._hook = wrapped_hook + logger.info("Speaking hook installed on live websocket") + except Exception as e: + logger.warning("Could not install hook on live ws: %s", e) + + # ------------------------------------------------------------------ + # Packet handler (called from SocketReader thread) + # ------------------------------------------------------------------ + + def _on_packet(self, data: bytes): + if not self._running or self._paused: + return + + # Log first few raw packets for debugging + self._packet_debug_count += 1 + if self._packet_debug_count <= 5: + logger.debug( + "Raw UDP packet: len=%d, first_bytes=%s", + len(data), data[:4].hex() if len(data) >= 4 else "short", + ) + + if len(data) < 16: + return + + # RTP version check: top 2 bits must be 10 (version 2). + # Lower bits may vary (padding, extension, CSRC count). + # Payload type (byte 1 lower 7 bits) = 0x78 (120) for voice. + if (data[0] >> 6) != 2 or (data[1] & 0x7F) != 0x78: + if self._packet_debug_count <= 5: + logger.debug("Skipped non-RTP: byte0=0x%02x byte1=0x%02x", data[0], data[1]) + return + + first_byte = data[0] + _, _, seq, timestamp, ssrc = struct.unpack_from(">BBHII", data, 0) + + # Skip bot's own audio + if ssrc == self._bot_ssrc: + return + + # Calculate dynamic RTP header size (RFC 9335 / rtpsize mode) + cc = first_byte & 0x0F # CSRC count + has_extension = bool(first_byte & 0x10) # extension bit + header_size = 12 + (4 * cc) + (4 if has_extension else 0) + + if len(data) < header_size + 4: # need at least header + nonce + return + + # Read extension length from preamble (for skipping after decrypt) + ext_data_len = 0 + if has_extension: + ext_preamble_offset = 12 + (4 * cc) + ext_words = struct.unpack_from(">H", data, ext_preamble_offset + 2)[0] + ext_data_len = ext_words * 4 + + if self._packet_debug_count <= 10: + with self._lock: + known_user = self._ssrc_to_user.get(ssrc, "unknown") + logger.debug( + "RTP packet: ssrc=%d, seq=%d, user=%s, hdr=%d, ext_data=%d", + ssrc, seq, known_user, header_size, ext_data_len, + ) + + header = bytes(data[:header_size]) + payload_with_nonce = data[header_size:] + + # --- NaCl transport decrypt (aead_xchacha20_poly1305_rtpsize) --- + if len(payload_with_nonce) < 4: + return + nonce = bytearray(24) + nonce[:4] = payload_with_nonce[-4:] + encrypted = bytes(payload_with_nonce[:-4]) + + try: + import nacl.secret # noqa: delayed import – only in voice path + box = nacl.secret.Aead(self._secret_key) + decrypted = box.decrypt(encrypted, header, bytes(nonce)) + except Exception as e: + if self._packet_debug_count <= 10: + logger.warning("NaCl decrypt failed: %s (hdr=%d, enc=%d)", e, header_size, len(encrypted)) + return + + # Skip encrypted extension data to get the actual opus payload + if ext_data_len and len(decrypted) > ext_data_len: + decrypted = decrypted[ext_data_len:] + + # --- DAVE E2EE decrypt --- + if self._dave_session: + with self._lock: + user_id = self._ssrc_to_user.get(ssrc, 0) + if user_id: + try: + import davey + decrypted = self._dave_session.decrypt( + user_id, davey.MediaType.audio, decrypted + ) + except Exception as e: + # Unencrypted passthrough — use NaCl-decrypted data as-is + if "Unencrypted" not in str(e): + if self._packet_debug_count <= 10: + logger.warning("DAVE decrypt failed for ssrc=%d: %s", ssrc, e) + return + # If SSRC unknown (no SPEAKING event yet), skip DAVE and try + # Opus decode directly — audio may be in passthrough mode. + # Buffer will get a user_id when SPEAKING event arrives later. + + # --- Opus decode -> PCM --- + try: + if ssrc not in self._decoders: + self._decoders[ssrc] = discord.opus.Decoder() + pcm = self._decoders[ssrc].decode(decrypted) + with self._lock: + self._buffers[ssrc].extend(pcm) + self._last_packet_time[ssrc] = time.monotonic() + except Exception as e: + logger.debug("Opus decode error for SSRC %s: %s", ssrc, e) + return + + # ------------------------------------------------------------------ + # Silence detection + # ------------------------------------------------------------------ + + def _infer_user_for_ssrc(self, ssrc: int) -> int: + """Try to infer user_id for an unmapped SSRC. + + When the bot rejoins a voice channel, Discord may not resend + SPEAKING events for users already speaking. If exactly one + allowed user is in the channel, map the SSRC to them. + """ + try: + channel = self._vc.channel + if not channel: + return 0 + bot_id = self._vc.user.id if self._vc.user else 0 + allowed = self._allowed_user_ids + candidates = [ + m.id for m in channel.members + if m.id != bot_id and (not allowed or str(m.id) in allowed) + ] + if len(candidates) == 1: + uid = candidates[0] + self._ssrc_to_user[ssrc] = uid + logger.info("Auto-mapped ssrc=%d -> user=%d (sole allowed member)", ssrc, uid) + return uid + except Exception: + pass + return 0 + + def check_silence(self) -> list: + """Return list of (user_id, pcm_bytes) for completed utterances.""" + now = time.monotonic() + completed = [] + + with self._lock: + ssrc_user_map = dict(self._ssrc_to_user) + ssrc_list = list(self._buffers.keys()) + + for ssrc in ssrc_list: + last_time = self._last_packet_time.get(ssrc, now) + silence_duration = now - last_time + buf = self._buffers[ssrc] + # 48kHz, 16-bit, stereo = 192000 bytes/sec + buf_duration = len(buf) / (self.SAMPLE_RATE * self.CHANNELS * 2) + + if silence_duration >= self.SILENCE_THRESHOLD and buf_duration >= self.MIN_SPEECH_DURATION: + user_id = ssrc_user_map.get(ssrc, 0) + if not user_id: + # SSRC not mapped (SPEAKING event missing after bot rejoin). + # Infer from allowed users in the voice channel. + user_id = self._infer_user_for_ssrc(ssrc) + if user_id: + completed.append((user_id, bytes(buf))) + self._buffers[ssrc] = bytearray() + self._last_packet_time.pop(ssrc, None) + elif silence_duration >= self.SILENCE_THRESHOLD * 2: + # Stale buffer with no valid user — discard + self._buffers.pop(ssrc, None) + self._last_packet_time.pop(ssrc, None) + + return completed + + # ------------------------------------------------------------------ + # PCM -> WAV conversion (for Whisper STT) + # ------------------------------------------------------------------ + + @staticmethod + def pcm_to_wav(pcm_data: bytes, output_path: str, + src_rate: int = 48000, src_channels: int = 2): + """Convert raw PCM to 16kHz mono WAV via ffmpeg.""" + with tempfile.NamedTemporaryFile(suffix=".pcm", delete=False) as f: + f.write(pcm_data) + pcm_path = f.name + try: + subprocess.run( + [ + "ffmpeg", "-y", "-loglevel", "error", + "-f", "s16le", + "-ar", str(src_rate), + "-ac", str(src_channels), + "-i", pcm_path, + "-ar", "16000", + "-ac", "1", + output_path, + ], + check=True, + timeout=10, + ) + finally: + try: + os.unlink(pcm_path) + except OSError: + pass + + +class DiscordAdapter(BasePlatformAdapter): + """ + Discord bot adapter. + + Handles: + - Receiving messages from servers and DMs + - Sending responses with Discord markdown + - Thread support + - Native slash commands (/ask, /reset, /status, /stop) + - Button-based exec approvals + - Auto-threading for long conversations + - Reaction-based feedback + """ + + # Discord message limits + MAX_MESSAGE_LENGTH = 2000 + _SPLIT_THRESHOLD = 1900 # near the 2000-char split point + + # Auto-disconnect from voice channel after this many seconds of inactivity + VOICE_TIMEOUT = 300 + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.DISCORD) + self._client: Optional[commands.Bot] = None + self._ready_event = asyncio.Event() + self._allowed_user_ids: set = set() # For button approval authorization + # Voice channel state (per-guild) + self._voice_clients: Dict[int, Any] = {} # guild_id -> VoiceClient + # Text batching: merge rapid successive messages (Telegram-style) + self._text_batch_delay_seconds = float(os.getenv("HERMES_DISCORD_TEXT_BATCH_DELAY_SECONDS", "0.6")) + self._text_batch_split_delay_seconds = float(os.getenv("HERMES_DISCORD_TEXT_BATCH_SPLIT_DELAY_SECONDS", "2.0")) + self._pending_text_batches: Dict[str, MessageEvent] = {} + self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {} + self._voice_text_channels: Dict[int, int] = {} # guild_id -> text_channel_id + self._voice_sources: Dict[int, Dict[str, Any]] = {} # guild_id -> linked text channel source metadata + self._voice_timeout_tasks: Dict[int, asyncio.Task] = {} # guild_id -> timeout task + # Phase 2: voice listening + self._voice_receivers: Dict[int, VoiceReceiver] = {} # guild_id -> VoiceReceiver + self._voice_listen_tasks: Dict[int, asyncio.Task] = {} # guild_id -> listen loop + self._voice_input_callback: Optional[Callable] = None # set by run.py + self._on_voice_disconnect: Optional[Callable] = None # set by run.py + # Track threads where the bot has participated so follow-up messages + # in those threads don't require @mention. Persisted to disk so the + # set survives gateway restarts. + self._threads = ThreadParticipationTracker("discord") + # Persistent typing indicator loops per channel (DMs don't reliably + # show the standard typing gateway event for bots) + self._typing_tasks: Dict[str, asyncio.Task] = {} + self._bot_task: Optional[asyncio.Task] = None + self._post_connect_task: Optional[asyncio.Task] = None + # Dedup cache: prevents duplicate bot responses when Discord + # RESUME replays events after reconnects. + self._dedup = MessageDeduplicator() + # Reply threading mode: "off" (no replies), "first" (reply on first + # chunk only, default), "all" (reply-reference on every chunk). + self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first' + + async def connect(self) -> bool: + """Connect to Discord and start receiving events.""" + if not DISCORD_AVAILABLE: + logger.error("[%s] discord.py not installed. Run: pip install discord.py", self.name) + return False + + # Load opus codec for voice channel support + if not discord.opus.is_loaded(): + import ctypes.util + opus_path = ctypes.util.find_library("opus") + # ctypes.util.find_library fails on macOS with Homebrew-installed libs, + # so fall back to known Homebrew paths if needed. + if not opus_path: + import sys + _homebrew_paths = ( + "/opt/homebrew/lib/libopus.dylib", # Apple Silicon + "/usr/local/lib/libopus.dylib", # Intel Mac + ) + if sys.platform == "darwin": + for _hp in _homebrew_paths: + if os.path.isfile(_hp): + opus_path = _hp + break + if opus_path: + try: + discord.opus.load_opus(opus_path) + except Exception: + logger.warning("Opus codec found at %s but failed to load", opus_path) + if not discord.opus.is_loaded(): + logger.warning("Opus codec not found — voice channel playback disabled") + + if not self.config.token: + logger.error("[%s] No bot token configured", self.name) + return False + + try: + if not self._acquire_platform_lock('discord-bot-token', self.config.token, 'Discord bot token'): + return False + + # Parse allowed user entries (may contain usernames or IDs) + allowed_env = os.getenv("DISCORD_ALLOWED_USERS", "") + if allowed_env: + self._allowed_user_ids = { + _clean_discord_id(uid) for uid in allowed_env.split(",") + if uid.strip() + } + + # Set up intents. + # Message Content is required for normal text replies. + # Server Members is only needed when the allowlist contains usernames + # that must be resolved to numeric IDs. Requesting privileged intents + # that aren't enabled in the Discord Developer Portal can prevent the + # bot from coming online at all, so avoid requesting members intent + # unless it is actually necessary. + intents = Intents.default() + intents.message_content = True + intents.dm_messages = True + intents.guild_messages = True + intents.members = any(not entry.isdigit() for entry in self._allowed_user_ids) + intents.voice_states = True + + # Resolve proxy (DISCORD_PROXY > generic env vars > macOS system proxy) + from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_bot + proxy_url = resolve_proxy_url(platform_env_var="DISCORD_PROXY") + if proxy_url: + logger.info("[%s] Using proxy for Discord: %s", self.name, proxy_url) + + # Create bot — proxy= for HTTP, connector= for SOCKS + self._client = commands.Bot( + command_prefix="!", # Not really used, we handle raw messages + intents=intents, + **proxy_kwargs_for_bot(proxy_url), + ) + adapter_self = self # capture for closure + + # Register event handlers + @self._client.event + async def on_ready(): + logger.info("[%s] Connected as %s", adapter_self.name, adapter_self._client.user) + + # Resolve any usernames in the allowed list to numeric IDs + await adapter_self._resolve_allowed_usernames() + adapter_self._ready_event.set() + + if adapter_self._post_connect_task and not adapter_self._post_connect_task.done(): + adapter_self._post_connect_task.cancel() + adapter_self._post_connect_task = asyncio.create_task( + adapter_self._run_post_connect_initialization() + ) + + @self._client.event + async def on_message(message: DiscordMessage): + # Dedup: Discord RESUME replays events after reconnects (#4777) + if adapter_self._dedup.is_duplicate(str(message.id)): + return + + # Always ignore our own messages + if message.author == self._client.user: + return + + # Ignore Discord system messages (thread renames, pins, member joins, etc.) + # Allow both default and reply types — replies have a distinct MessageType. + if message.type not in (discord.MessageType.default, discord.MessageType.reply): + return + + # Check if the message author is in the allowed user list + if not self._is_allowed_user(str(message.author.id)): + return + + # Bot message filtering (DISCORD_ALLOW_BOTS): + # "none" — ignore all other bots (default) + # "mentions" — accept bot messages only when they @mention us + # "all" — accept all bot messages + if getattr(message.author, "bot", False): + allow_bots = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip() + if allow_bots == "none": + return + elif allow_bots == "mentions": + if not self._client.user or self._client.user not in message.mentions: + return + # "all" falls through to handle_message + + # Multi-agent filtering: if the message mentions specific bots + # but NOT this bot, the sender is talking to another agent — + # stay silent. Messages with no bot mentions (general chat) + # still fall through to _handle_message for the existing + # DISCORD_REQUIRE_MENTION check. + # + # This replaces the older DISCORD_IGNORE_NO_MENTION logic + # with bot-aware filtering that works correctly when multiple + # agents share a channel. + if not isinstance(message.channel, discord.DMChannel) and message.mentions: + _self_mentioned = ( + self._client.user is not None + and self._client.user in message.mentions + ) + _other_bots_mentioned = any( + m.bot and m != self._client.user + for m in message.mentions + ) + # If other bots are mentioned but we're not → not for us + if _other_bots_mentioned and not _self_mentioned: + return + # If humans are mentioned but we're not → not for us + # (preserves old DISCORD_IGNORE_NO_MENTION=true behavior) + _ignore_no_mention = os.getenv( + "DISCORD_IGNORE_NO_MENTION", "true" + ).lower() in ("true", "1", "yes") + if _ignore_no_mention and not _self_mentioned and not _other_bots_mentioned: + return + + await self._handle_message(message) + + @self._client.event + async def on_voice_state_update(member, before, after): + """Track voice channel join/leave events.""" + # Only track channels where the bot is connected + bot_guild_ids = set(adapter_self._voice_clients.keys()) + if not bot_guild_ids: + return + guild_id = member.guild.id + if guild_id not in bot_guild_ids: + return + # Ignore the bot itself + if member == adapter_self._client.user: + return + + joined = before.channel is None and after.channel is not None + left = before.channel is not None and after.channel is None + switched = ( + before.channel is not None + and after.channel is not None + and before.channel != after.channel + ) + + if joined or left or switched: + logger.info( + "Voice state: %s (%d) %s (guild %d)", + member.display_name, + member.id, + "joined " + after.channel.name if joined + else "left " + before.channel.name if left + else f"moved {before.channel.name} -> {after.channel.name}", + guild_id, + ) + + # Register slash commands + self._register_slash_commands() + + # Start the bot in background + self._bot_task = asyncio.create_task(self._client.start(self.config.token)) + + # Wait for ready + await asyncio.wait_for(self._ready_event.wait(), timeout=30) + + self._running = True + return True + + except asyncio.TimeoutError: + logger.error("[%s] Timeout waiting for connection to Discord", self.name, exc_info=True) + self._release_platform_lock() + return False + except Exception as e: # pragma: no cover - defensive logging + logger.error("[%s] Failed to connect to Discord: %s", self.name, e, exc_info=True) + self._release_platform_lock() + return False + + async def disconnect(self) -> None: + """Disconnect from Discord.""" + # Clean up all active voice connections before closing the client + for guild_id in list(self._voice_clients.keys()): + try: + await self.leave_voice_channel(guild_id) + except Exception as e: # pragma: no cover - defensive logging + logger.debug("[%s] Error leaving voice channel %s: %s", self.name, guild_id, e) + + if self._client: + try: + await self._client.close() + except Exception as e: # pragma: no cover - defensive logging + logger.warning("[%s] Error during disconnect: %s", self.name, e, exc_info=True) + + if self._post_connect_task and not self._post_connect_task.done(): + self._post_connect_task.cancel() + try: + await self._post_connect_task + except asyncio.CancelledError: + pass + + self._running = False + self._client = None + self._ready_event.clear() + self._post_connect_task = None + + self._release_platform_lock() + + logger.info("[%s] Disconnected", self.name) + + async def _run_post_connect_initialization(self) -> None: + """Finish non-critical startup work after Discord is connected.""" + if not self._client: + return + try: + synced = await asyncio.wait_for(self._client.tree.sync(), timeout=30) + logger.info("[%s] Synced %d slash command(s)", self.name, len(synced)) + except asyncio.TimeoutError: + logger.warning("[%s] Slash command sync timed out after 30s", self.name) + except asyncio.CancelledError: + raise + except Exception as e: # pragma: no cover - defensive logging + logger.warning("[%s] Slash command sync failed: %s", self.name, e, exc_info=True) + + async def _add_reaction(self, message: Any, emoji: str) -> bool: + """Add an emoji reaction to a Discord message.""" + if not message or not hasattr(message, "add_reaction"): + return False + try: + await message.add_reaction(emoji) + return True + except Exception as e: + logger.debug("[%s] add_reaction failed (%s): %s", self.name, emoji, e) + return False + + async def _remove_reaction(self, message: Any, emoji: str) -> bool: + """Remove the bot's own emoji reaction from a Discord message.""" + if not message or not hasattr(message, "remove_reaction") or not self._client or not self._client.user: + return False + try: + await message.remove_reaction(emoji, self._client.user) + return True + except Exception as e: + logger.debug("[%s] remove_reaction failed (%s): %s", self.name, emoji, e) + return False + + def _reactions_enabled(self) -> bool: + """Check if message reactions are enabled via config/env.""" + return os.getenv("DISCORD_REACTIONS", "true").lower() not in ("false", "0", "no") + + async def on_processing_start(self, event: MessageEvent) -> None: + """Add an in-progress reaction for normal Discord message events.""" + if not self._reactions_enabled(): + return + message = event.raw_message + if hasattr(message, "add_reaction"): + await self._add_reaction(message, "👀") + + async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None: + """Swap the in-progress reaction for a final success/failure reaction.""" + if not self._reactions_enabled(): + return + message = event.raw_message + if hasattr(message, "add_reaction"): + await self._remove_reaction(message, "👀") + if outcome == ProcessingOutcome.SUCCESS: + await self._add_reaction(message, "✅") + elif outcome == ProcessingOutcome.FAILURE: + await self._add_reaction(message, "❌") + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> SendResult: + """Send a message to a Discord channel or thread. + + When metadata contains a thread_id, the message is sent to that + thread instead of the parent channel identified by chat_id. + """ + if not self._client: + return SendResult(success=False, error="Not connected") + + try: + # Determine target channel: thread_id in metadata takes precedence. + thread_id = None + if metadata and metadata.get("thread_id"): + thread_id = metadata["thread_id"] + + if thread_id: + # Fetch the thread directly — threads are addressed by their own ID. + channel = self._client.get_channel(int(thread_id)) + if not channel: + channel = await self._client.fetch_channel(int(thread_id)) + if not channel: + return SendResult(success=False, error=f"Thread {thread_id} not found") + else: + # Get the parent channel + channel = self._client.get_channel(int(chat_id)) + if not channel: + channel = await self._client.fetch_channel(int(chat_id)) + if not channel: + return SendResult(success=False, error=f"Channel {chat_id} not found") + + # Format and split message if needed + formatted = self.format_message(content) + chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) + + message_ids = [] + reference = None + + if reply_to and self._reply_to_mode != "off": + try: + ref_msg = await channel.fetch_message(int(reply_to)) + reference = ref_msg + except Exception as e: + logger.debug("Could not fetch reply-to message: %s", e) + + for i, chunk in enumerate(chunks): + if self._reply_to_mode == "all": + chunk_reference = reference + else: # "first" (default) or "off" + chunk_reference = reference if i == 0 else None + try: + msg = await channel.send( + content=chunk, + reference=chunk_reference, + ) + except Exception as e: + err_text = str(e) + if ( + chunk_reference is not None + and "error code: 50035" in err_text + and "Cannot reply to a system message" in err_text + ): + logger.warning( + "[%s] Reply target %s is a Discord system message; retrying send without reply reference", + self.name, + reply_to, + ) + msg = await channel.send( + content=chunk, + reference=None, + ) + else: + raise + message_ids.append(str(msg.id)) + + return SendResult( + success=True, + message_id=message_ids[0] if message_ids else None, + raw_response={"message_ids": message_ids} + ) + + except Exception as e: # pragma: no cover - defensive logging + logger.error("[%s] Failed to send Discord message: %s", self.name, e, exc_info=True) + return SendResult(success=False, error=str(e)) + + async def edit_message( + self, + chat_id: str, + message_id: str, + content: str, + ) -> SendResult: + """Edit a previously sent Discord message.""" + if not self._client: + return SendResult(success=False, error="Not connected") + try: + channel = self._client.get_channel(int(chat_id)) + if not channel: + channel = await self._client.fetch_channel(int(chat_id)) + msg = await channel.fetch_message(int(message_id)) + formatted = self.format_message(content) + if len(formatted) > self.MAX_MESSAGE_LENGTH: + formatted = formatted[:self.MAX_MESSAGE_LENGTH - 3] + "..." + await msg.edit(content=formatted) + return SendResult(success=True, message_id=message_id) + except Exception as e: # pragma: no cover - defensive logging + logger.error("[%s] Failed to edit Discord message %s: %s", self.name, message_id, e, exc_info=True) + return SendResult(success=False, error=str(e)) + + async def _send_file_attachment( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + ) -> SendResult: + """Send a local file as a Discord attachment.""" + if not self._client: + return SendResult(success=False, error="Not connected") + + channel = self._client.get_channel(int(chat_id)) + if not channel: + channel = await self._client.fetch_channel(int(chat_id)) + if not channel: + return SendResult(success=False, error=f"Channel {chat_id} not found") + + filename = file_name or os.path.basename(file_path) + with open(file_path, "rb") as fh: + file = discord.File(fh, filename=filename) + msg = await channel.send(content=caption if caption else None, file=file) + return SendResult(success=True, message_id=str(msg.id)) + + async def play_tts( + self, + chat_id: str, + audio_path: str, + **kwargs, + ) -> SendResult: + """Play auto-TTS audio. + + When the bot is in a voice channel for this chat's guild, play + directly in the VC instead of sending as a file attachment. + """ + for gid, text_ch_id in self._voice_text_channels.items(): + if str(text_ch_id) == str(chat_id) and self.is_in_voice_channel(gid): + logger.info("[%s] Playing TTS in voice channel (guild=%d)", self.name, gid) + success = await self.play_in_voice_channel(gid, audio_path) + return SendResult(success=success) + return await self.send_voice(chat_id=chat_id, audio_path=audio_path, **kwargs) + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + """Send audio as a Discord file attachment.""" + try: + import io + + channel = self._client.get_channel(int(chat_id)) + if not channel: + channel = await self._client.fetch_channel(int(chat_id)) + if not channel: + return SendResult(success=False, error=f"Channel {chat_id} not found") + + if not os.path.exists(audio_path): + return SendResult(success=False, error=f"Audio file not found: {audio_path}") + + filename = os.path.basename(audio_path) + + with open(audio_path, "rb") as f: + file_data = f.read() + + # Try sending as a native voice message via raw API (flags=8192). + try: + import base64 + + duration_secs = 5.0 + try: + from mutagen.oggopus import OggOpus + info = OggOpus(audio_path) + duration_secs = info.info.length + except Exception: + duration_secs = max(1.0, len(file_data) / 2000.0) + + waveform_bytes = bytes([128] * 256) + waveform_b64 = base64.b64encode(waveform_bytes).decode() + + import json as _json + payload = _json.dumps({ + "flags": 8192, + "attachments": [{ + "id": "0", + "filename": "voice-message.ogg", + "duration_secs": round(duration_secs, 2), + "waveform": waveform_b64, + }], + }) + form = [ + {"name": "payload_json", "value": payload}, + { + "name": "files[0]", + "value": file_data, + "filename": "voice-message.ogg", + "content_type": "audio/ogg", + }, + ] + msg_data = await self._client.http.request( + discord.http.Route("POST", "/channels/{channel_id}/messages", channel_id=channel.id), + form=form, + ) + return SendResult(success=True, message_id=str(msg_data["id"])) + except Exception as voice_err: + logger.debug("Voice message flag failed, falling back to file: %s", voice_err) + file = discord.File(io.BytesIO(file_data), filename=filename) + msg = await channel.send(file=file) + return SendResult(success=True, message_id=str(msg.id)) + except Exception as e: # pragma: no cover - defensive logging + logger.error("[%s] Failed to send audio, falling back to base adapter: %s", self.name, e, exc_info=True) + return await super().send_voice(chat_id, audio_path, caption, reply_to, metadata=metadata) + + # ------------------------------------------------------------------ + # Voice channel methods (join / leave / play) + # ------------------------------------------------------------------ + + async def join_voice_channel(self, channel) -> bool: + """Join a Discord voice channel. Returns True on success.""" + if not self._client or not DISCORD_AVAILABLE: + return False + guild_id = channel.guild.id + + # Already connected in this guild? + existing = self._voice_clients.get(guild_id) + if existing and existing.is_connected(): + if existing.channel.id == channel.id: + self._reset_voice_timeout(guild_id) + return True + await existing.move_to(channel) + self._reset_voice_timeout(guild_id) + return True + + vc = await channel.connect() + self._voice_clients[guild_id] = vc + self._reset_voice_timeout(guild_id) + + # Start voice receiver (Phase 2: listen to users) + try: + receiver = VoiceReceiver(vc, allowed_user_ids=self._allowed_user_ids) + receiver.start() + self._voice_receivers[guild_id] = receiver + self._voice_listen_tasks[guild_id] = asyncio.ensure_future( + self._voice_listen_loop(guild_id) + ) + except Exception as e: + logger.warning("Voice receiver failed to start: %s", e) + + return True + + async def leave_voice_channel(self, guild_id: int) -> None: + """Disconnect from the voice channel in a guild.""" + # Stop voice receiver first + receiver = self._voice_receivers.pop(guild_id, None) + if receiver: + receiver.stop() + listen_task = self._voice_listen_tasks.pop(guild_id, None) + if listen_task: + listen_task.cancel() + + vc = self._voice_clients.pop(guild_id, None) + if vc and vc.is_connected(): + await vc.disconnect() + task = self._voice_timeout_tasks.pop(guild_id, None) + if task: + task.cancel() + self._voice_text_channels.pop(guild_id, None) + self._voice_sources.pop(guild_id, None) + + # Maximum seconds to wait for voice playback before giving up + PLAYBACK_TIMEOUT = 120 + + async def play_in_voice_channel(self, guild_id: int, audio_path: str) -> bool: + """Play an audio file in the connected voice channel.""" + vc = self._voice_clients.get(guild_id) + if not vc or not vc.is_connected(): + return False + + # Pause voice receiver while playing (echo prevention) + receiver = self._voice_receivers.get(guild_id) + if receiver: + receiver.pause() + + try: + # Wait for current playback to finish (with timeout) + wait_start = time.monotonic() + while vc.is_playing(): + if time.monotonic() - wait_start > self.PLAYBACK_TIMEOUT: + logger.warning("Timed out waiting for previous playback to finish") + vc.stop() + break + await asyncio.sleep(0.1) + + done = asyncio.Event() + loop = asyncio.get_running_loop() + + def _after(error): + if error: + logger.error("Voice playback error: %s", error) + loop.call_soon_threadsafe(done.set) + + source = discord.FFmpegPCMAudio(audio_path) + source = discord.PCMVolumeTransformer(source, volume=1.0) + vc.play(source, after=_after) + try: + await asyncio.wait_for(done.wait(), timeout=self.PLAYBACK_TIMEOUT) + except asyncio.TimeoutError: + logger.warning("Voice playback timed out after %ds", self.PLAYBACK_TIMEOUT) + vc.stop() + self._reset_voice_timeout(guild_id) + return True + finally: + if receiver: + receiver.resume() + + async def get_user_voice_channel(self, guild_id: int, user_id: str): + """Return the voice channel the user is currently in, or None.""" + if not self._client: + return None + guild = self._client.get_guild(guild_id) + if not guild: + return None + member = guild.get_member(int(user_id)) + if not member or not member.voice: + return None + return member.voice.channel + + def _reset_voice_timeout(self, guild_id: int) -> None: + """Reset the auto-disconnect inactivity timer.""" + task = self._voice_timeout_tasks.pop(guild_id, None) + if task: + task.cancel() + self._voice_timeout_tasks[guild_id] = asyncio.ensure_future( + self._voice_timeout_handler(guild_id) + ) + + async def _voice_timeout_handler(self, guild_id: int) -> None: + """Auto-disconnect after VOICE_TIMEOUT seconds of inactivity.""" + try: + await asyncio.sleep(self.VOICE_TIMEOUT) + except asyncio.CancelledError: + return + text_ch_id = self._voice_text_channels.get(guild_id) + await self.leave_voice_channel(guild_id) + # Notify the runner so it can clean up voice_mode state + if self._on_voice_disconnect and text_ch_id: + try: + self._on_voice_disconnect(str(text_ch_id)) + except Exception: + pass + if text_ch_id and self._client: + ch = self._client.get_channel(text_ch_id) + if ch: + try: + await ch.send("Left voice channel (inactivity timeout).") + except Exception: + pass + + def is_in_voice_channel(self, guild_id: int) -> bool: + """Check if the bot is connected to a voice channel in this guild.""" + vc = self._voice_clients.get(guild_id) + return vc is not None and vc.is_connected() + + def get_voice_channel_info(self, guild_id: int) -> Optional[Dict[str, Any]]: + """Return voice channel awareness info for the given guild. + + Returns None if the bot is not in a voice channel. Otherwise + returns a dict with channel name, member list, count, and + currently-speaking user IDs (from SSRC mapping). + """ + vc = self._voice_clients.get(guild_id) + if not vc or not vc.is_connected(): + return None + + channel = vc.channel + if not channel: + return None + + # Members currently in the voice channel (includes bot) + members_info = [] + bot_user = self._client.user if self._client else None + for m in channel.members: + if bot_user and m.id == bot_user.id: + continue # skip the bot itself + members_info.append({ + "user_id": m.id, + "display_name": m.display_name, + "is_bot": m.bot, + }) + + # Currently speaking users (from SSRC mapping + active buffers) + speaking_user_ids: set = set() + receiver = self._voice_receivers.get(guild_id) + if receiver: + import time as _time + now = _time.monotonic() + with receiver._lock: + for ssrc, last_t in receiver._last_packet_time.items(): + # Consider "speaking" if audio received within last 2 seconds + if now - last_t < 2.0: + uid = receiver._ssrc_to_user.get(ssrc) + if uid: + speaking_user_ids.add(uid) + + # Tag speaking status on members + for info in members_info: + info["is_speaking"] = info["user_id"] in speaking_user_ids + + return { + "channel_name": channel.name, + "member_count": len(members_info), + "members": members_info, + "speaking_count": len(speaking_user_ids), + } + + def get_voice_channel_context(self, guild_id: int) -> str: + """Return a human-readable voice channel context string. + + Suitable for injection into the system/ephemeral prompt so the + agent is always aware of voice channel state. + """ + info = self.get_voice_channel_info(guild_id) + if not info: + return "" + + parts = [f"[Voice channel: #{info['channel_name']} — {info['member_count']} participant(s)]"] + for m in info["members"]: + status = " (speaking)" if m["is_speaking"] else "" + parts.append(f" - {m['display_name']}{status}") + + return "\n".join(parts) + + # ------------------------------------------------------------------ + # Voice listening (Phase 2) + # ------------------------------------------------------------------ + + # UDP keepalive interval in seconds — prevents Discord from dropping + # the UDP route after ~60s of silence. + _KEEPALIVE_INTERVAL = 15 + + async def _voice_listen_loop(self, guild_id: int): + """Periodically check for completed utterances and process them.""" + receiver = self._voice_receivers.get(guild_id) + if not receiver: + return + last_keepalive = time.monotonic() + try: + while receiver._running: + await asyncio.sleep(0.2) + + # Send periodic UDP keepalive to prevent Discord from + # dropping the UDP session after ~60s of silence. + now = time.monotonic() + if now - last_keepalive >= self._KEEPALIVE_INTERVAL: + last_keepalive = now + try: + vc = self._voice_clients.get(guild_id) + if vc and vc.is_connected(): + vc._connection.send_packet(b'\xf8\xff\xfe') + except Exception: + pass + + completed = receiver.check_silence() + for user_id, pcm_data in completed: + if not self._is_allowed_user(str(user_id)): + continue + await self._process_voice_input(guild_id, user_id, pcm_data) + except asyncio.CancelledError: + pass + except Exception as e: + logger.error("Voice listen loop error: %s", e, exc_info=True) + + async def _process_voice_input(self, guild_id: int, user_id: int, pcm_data: bytes): + """Convert PCM -> WAV -> STT -> callback.""" + from tools.voice_mode import is_whisper_hallucination + + tmp_f = tempfile.NamedTemporaryFile(suffix=".wav", prefix="vc_listen_", delete=False) + wav_path = tmp_f.name + tmp_f.close() + try: + await asyncio.to_thread(VoiceReceiver.pcm_to_wav, pcm_data, wav_path) + + from tools.transcription_tools import transcribe_audio + result = await asyncio.to_thread(transcribe_audio, wav_path) + + if not result.get("success"): + return + transcript = result.get("transcript", "").strip() + if not transcript or is_whisper_hallucination(transcript): + return + + logger.info("Voice input from user %d: %s", user_id, transcript[:100]) + + if self._voice_input_callback: + await self._voice_input_callback( + guild_id=guild_id, + user_id=user_id, + transcript=transcript, + ) + except Exception as e: + logger.warning("Voice input processing failed: %s", e, exc_info=True) + finally: + try: + os.unlink(wav_path) + except OSError: + pass + + def _is_allowed_user(self, user_id: str) -> bool: + """Check if user is in DISCORD_ALLOWED_USERS.""" + if not self._allowed_user_ids: + return True + return user_id in self._allowed_user_ids + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a local image file natively as a Discord file attachment.""" + try: + return await self._send_file_attachment(chat_id, image_path, caption) + except FileNotFoundError: + return SendResult(success=False, error=f"Image file not found: {image_path}") + except Exception as e: # pragma: no cover - defensive logging + logger.error("[%s] Failed to send local image, falling back to base adapter: %s", self.name, e, exc_info=True) + return await super().send_image_file(chat_id, image_path, caption, reply_to, metadata=metadata) + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an image natively as a Discord file attachment.""" + if not self._client: + return SendResult(success=False, error="Not connected") + + if not is_safe_url(image_url): + logger.warning("[%s] Blocked unsafe image URL during Discord send_image", self.name) + return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata) + + try: + import aiohttp + + channel = self._client.get_channel(int(chat_id)) + if not channel: + channel = await self._client.fetch_channel(int(chat_id)) + if not channel: + return SendResult(success=False, error=f"Channel {chat_id} not found") + + # Download the image and send as a Discord file attachment + # (Discord renders attachments inline, unlike plain URLs) + from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp + _proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY") + _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) + async with aiohttp.ClientSession(**_sess_kw) as session: + async with session.get(image_url, timeout=aiohttp.ClientTimeout(total=30), **_req_kw) as resp: + if resp.status != 200: + raise Exception(f"Failed to download image: HTTP {resp.status}") + + image_data = await resp.read() + + # Determine filename from URL or content type + content_type = resp.headers.get("content-type", "image/png") + ext = "png" + if "jpeg" in content_type or "jpg" in content_type: + ext = "jpg" + elif "gif" in content_type: + ext = "gif" + elif "webp" in content_type: + ext = "webp" + + import io + file = discord.File(io.BytesIO(image_data), filename=f"image.{ext}") + + msg = await channel.send( + content=caption if caption else None, + file=file, + ) + return SendResult(success=True, message_id=str(msg.id)) + + except ImportError: + logger.warning( + "[%s] aiohttp not installed, falling back to URL. Run: pip install aiohttp", + self.name, + exc_info=True, + ) + return await super().send_image(chat_id, image_url, caption, reply_to) + except Exception as e: # pragma: no cover - defensive logging + logger.error( + "[%s] Failed to send image attachment, falling back to URL: %s", + self.name, + e, + exc_info=True, + ) + return await super().send_image(chat_id, image_url, caption, reply_to) + + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a local video file natively as a Discord attachment.""" + try: + return await self._send_file_attachment(chat_id, video_path, caption) + except FileNotFoundError: + return SendResult(success=False, error=f"Video file not found: {video_path}") + except Exception as e: # pragma: no cover - defensive logging + logger.error("[%s] Failed to send local video, falling back to base adapter: %s", self.name, e, exc_info=True) + return await super().send_video(chat_id, video_path, caption, reply_to, metadata=metadata) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an arbitrary file natively as a Discord attachment.""" + try: + return await self._send_file_attachment(chat_id, file_path, caption, file_name=file_name) + except FileNotFoundError: + return SendResult(success=False, error=f"File not found: {file_path}") + except Exception as e: # pragma: no cover - defensive logging + logger.error("[%s] Failed to send document, falling back to base adapter: %s", self.name, e, exc_info=True) + return await super().send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata) + + async def send_typing(self, chat_id: str, metadata=None) -> None: + """Start a persistent typing indicator for a channel. + + Discord's TYPING_START gateway event is unreliable in DMs for bots. + Instead, start a background loop that hits the typing endpoint every + 8 seconds (typing indicator lasts ~10s). The loop is cancelled when + stop_typing() is called (after the response is sent). + """ + if not self._client: + return + # Don't start a duplicate loop + if chat_id in self._typing_tasks: + return + + async def _typing_loop() -> None: + try: + while True: + try: + route = discord.http.Route( + "POST", "/channels/{channel_id}/typing", + channel_id=chat_id, + ) + await self._client.http.request(route) + except asyncio.CancelledError: + return + except Exception as e: + logger.debug("Discord typing indicator failed for %s: %s", chat_id, e) + return + await asyncio.sleep(8) + except asyncio.CancelledError: + pass + + self._typing_tasks[chat_id] = asyncio.create_task(_typing_loop()) + + async def stop_typing(self, chat_id: str) -> None: + """Stop the persistent typing indicator for a channel.""" + task = self._typing_tasks.pop(chat_id, None) + if task: + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Get information about a Discord channel.""" + if not self._client: + return {"name": "Unknown", "type": "dm"} + + try: + channel = self._client.get_channel(int(chat_id)) + if not channel: + channel = await self._client.fetch_channel(int(chat_id)) + + if not channel: + return {"name": str(chat_id), "type": "dm"} + + # Determine channel type + if isinstance(channel, discord.DMChannel): + chat_type = "dm" + name = channel.recipient.name if channel.recipient else str(chat_id) + elif isinstance(channel, discord.Thread): + chat_type = "thread" + name = channel.name + elif isinstance(channel, discord.TextChannel): + chat_type = "channel" + name = f"#{channel.name}" + if channel.guild: + name = f"{channel.guild.name} / {name}" + else: + chat_type = "channel" + name = getattr(channel, "name", str(chat_id)) + + return { + "name": name, + "type": chat_type, + "guild_id": str(channel.guild.id) if hasattr(channel, "guild") and channel.guild else None, + "guild_name": channel.guild.name if hasattr(channel, "guild") and channel.guild else None, + } + except Exception as e: # pragma: no cover - defensive logging + logger.error("[%s] Failed to get chat info for %s: %s", self.name, chat_id, e, exc_info=True) + return {"name": str(chat_id), "type": "dm", "error": str(e)} + + async def _resolve_allowed_usernames(self) -> None: + """ + Resolve non-numeric entries in DISCORD_ALLOWED_USERS to Discord user IDs. + + Users can specify usernames (e.g. "teknium") or display names instead of + raw numeric IDs. After resolution, the env var and internal set are updated + so authorization checks work with IDs only. + """ + if not self._allowed_user_ids or not self._client: + return + + numeric_ids = set() + to_resolve = set() + + for entry in self._allowed_user_ids: + if entry.isdigit(): + numeric_ids.add(entry) + else: + to_resolve.add(entry.lower()) + + if not to_resolve: + return + + print(f"[{self.name}] Resolving {len(to_resolve)} username(s): {', '.join(to_resolve)}") + resolved_count = 0 + + for guild in self._client.guilds: + # Fetch full member list (requires members intent) + try: + members = guild.members + if len(members) < guild.member_count: + members = [m async for m in guild.fetch_members(limit=None)] + except Exception as e: + logger.warning("Failed to fetch members for guild %s: %s", guild.name, e) + continue + + for member in members: + name_lower = member.name.lower() + display_lower = member.display_name.lower() + global_lower = (member.global_name or "").lower() + + matched = name_lower in to_resolve or display_lower in to_resolve or global_lower in to_resolve + if matched: + uid = str(member.id) + numeric_ids.add(uid) + resolved_count += 1 + matched_name = name_lower if name_lower in to_resolve else ( + display_lower if display_lower in to_resolve else global_lower + ) + to_resolve.discard(matched_name) + print(f"[{self.name}] Resolved '{matched_name}' -> {uid} ({member.name}#{member.discriminator})") + + if not to_resolve: + break + + if to_resolve: + print(f"[{self.name}] Could not resolve usernames: {', '.join(to_resolve)}") + + # Update internal set and env var so gateway auth checks use IDs + self._allowed_user_ids = numeric_ids + os.environ["DISCORD_ALLOWED_USERS"] = ",".join(sorted(numeric_ids)) + if resolved_count: + print(f"[{self.name}] Updated DISCORD_ALLOWED_USERS with {resolved_count} resolved ID(s)") + + def format_message(self, content: str) -> str: + """ + Format message for Discord. + + Discord uses its own markdown variant. + """ + # Discord markdown is fairly standard, no special escaping needed + return content + + async def _run_simple_slash( + self, + interaction: discord.Interaction, + command_text: str, + followup_msg: str | None = None, + ) -> None: + """Common handler for simple slash commands that dispatch a command string. + + Defers the interaction (shows "thinking..."), dispatches the command, + then cleans up the deferred response. If *followup_msg* is provided + the "thinking..." indicator is replaced with that text; otherwise it + is deleted so the channel isn't cluttered. + """ + await interaction.response.defer(ephemeral=True) + event = self._build_slash_event(interaction, command_text) + await self.handle_message(event) + try: + if followup_msg: + await interaction.edit_original_response(content=followup_msg) + else: + await interaction.delete_original_response() + except Exception as e: + logger.debug("Discord interaction cleanup failed: %s", e) + + def _register_slash_commands(self) -> None: + """Register Discord slash commands on the command tree.""" + if not self._client: + return + + tree = self._client.tree + + @tree.command(name="new", description="Start a new conversation") + async def slash_new(interaction: discord.Interaction): + await self._run_simple_slash(interaction, "/reset", "New conversation started~") + + @tree.command(name="reset", description="Reset your Hermes session") + async def slash_reset(interaction: discord.Interaction): + await self._run_simple_slash(interaction, "/reset", "Session reset~") + + @tree.command(name="model", description="Show or change the model") + @discord.app_commands.describe(name="Model name (e.g. anthropic/claude-sonnet-4). Leave empty to see current.") + async def slash_model(interaction: discord.Interaction, name: str = ""): + await self._run_simple_slash(interaction, f"/model {name}".strip()) + + @tree.command(name="reasoning", description="Show or change reasoning effort") + @discord.app_commands.describe(effort="Reasoning effort: none, minimal, low, medium, high, or xhigh.") + async def slash_reasoning(interaction: discord.Interaction, effort: str = ""): + await self._run_simple_slash(interaction, f"/reasoning {effort}".strip()) + + @tree.command(name="personality", description="Set a personality") + @discord.app_commands.describe(name="Personality name. Leave empty to list available.") + async def slash_personality(interaction: discord.Interaction, name: str = ""): + await self._run_simple_slash(interaction, f"/personality {name}".strip()) + + @tree.command(name="retry", description="Retry your last message") + async def slash_retry(interaction: discord.Interaction): + await self._run_simple_slash(interaction, "/retry", "Retrying~") + + @tree.command(name="undo", description="Remove the last exchange") + async def slash_undo(interaction: discord.Interaction): + await self._run_simple_slash(interaction, "/undo") + + @tree.command(name="status", description="Show Hermes session status") + async def slash_status(interaction: discord.Interaction): + await self._run_simple_slash(interaction, "/status", "Status sent~") + + @tree.command(name="sethome", description="Set this chat as the home channel") + async def slash_sethome(interaction: discord.Interaction): + await self._run_simple_slash(interaction, "/sethome") + + @tree.command(name="stop", description="Stop the running Hermes agent") + async def slash_stop(interaction: discord.Interaction): + await self._run_simple_slash(interaction, "/stop", "Stop requested~") + + @tree.command(name="compress", description="Compress conversation context") + async def slash_compress(interaction: discord.Interaction): + await self._run_simple_slash(interaction, "/compress") + + @tree.command(name="title", description="Set or show the session title") + @discord.app_commands.describe(name="Session title. Leave empty to show current.") + async def slash_title(interaction: discord.Interaction, name: str = ""): + await self._run_simple_slash(interaction, f"/title {name}".strip()) + + @tree.command(name="resume", description="Resume a previously-named session") + @discord.app_commands.describe(name="Session name to resume. Leave empty to list sessions.") + async def slash_resume(interaction: discord.Interaction, name: str = ""): + await self._run_simple_slash(interaction, f"/resume {name}".strip()) + + @tree.command(name="usage", description="Show token usage for this session") + async def slash_usage(interaction: discord.Interaction): + await self._run_simple_slash(interaction, "/usage") + + @tree.command(name="provider", description="Show available providers") + async def slash_provider(interaction: discord.Interaction): + await self._run_simple_slash(interaction, "/provider") + + @tree.command(name="help", description="Show available commands") + async def slash_help(interaction: discord.Interaction): + await self._run_simple_slash(interaction, "/help") + + @tree.command(name="insights", description="Show usage insights and analytics") + @discord.app_commands.describe(days="Number of days to analyze (default: 7)") + async def slash_insights(interaction: discord.Interaction, days: int = 7): + await self._run_simple_slash(interaction, f"/insights {days}") + + @tree.command(name="reload-mcp", description="Reload MCP servers from config") + async def slash_reload_mcp(interaction: discord.Interaction): + await self._run_simple_slash(interaction, "/reload-mcp") + + @tree.command(name="voice", description="Toggle voice reply mode") + @discord.app_commands.describe(mode="Voice mode: on, off, tts, channel, leave, or status") + @discord.app_commands.choices(mode=[ + discord.app_commands.Choice(name="channel — join your voice channel", value="channel"), + discord.app_commands.Choice(name="leave — leave voice channel", value="leave"), + discord.app_commands.Choice(name="on — voice reply to voice messages", value="on"), + discord.app_commands.Choice(name="tts — voice reply to all messages", value="tts"), + discord.app_commands.Choice(name="off — text only", value="off"), + discord.app_commands.Choice(name="status — show current mode", value="status"), + ]) + async def slash_voice(interaction: discord.Interaction, mode: str = ""): + await self._run_simple_slash(interaction, f"/voice {mode}".strip()) + + @tree.command(name="update", description="Update Hermes Agent to the latest version") + async def slash_update(interaction: discord.Interaction): + await self._run_simple_slash(interaction, "/update", "Update initiated~") + + @tree.command(name="approve", description="Approve a pending dangerous command") + @discord.app_commands.describe(scope="Optional: 'all', 'session', 'always', 'all session', 'all always'") + async def slash_approve(interaction: discord.Interaction, scope: str = ""): + await self._run_simple_slash(interaction, f"/approve {scope}".strip()) + + @tree.command(name="deny", description="Deny a pending dangerous command") + @discord.app_commands.describe(scope="Optional: 'all' to deny all pending commands") + async def slash_deny(interaction: discord.Interaction, scope: str = ""): + await self._run_simple_slash(interaction, f"/deny {scope}".strip()) + + @tree.command(name="thread", description="Create a new thread and start a Hermes session in it") + @discord.app_commands.describe( + name="Thread name", + message="Optional first message to send to Hermes in the thread", + auto_archive_duration="Auto-archive in minutes (60, 1440, 4320, 10080)", + ) + async def slash_thread( + interaction: discord.Interaction, + name: str, + message: str = "", + auto_archive_duration: int = 1440, + ): + await interaction.response.defer(ephemeral=True) + await self._handle_thread_create_slash(interaction, name, message, auto_archive_duration) + + @tree.command(name="queue", description="Queue a prompt for the next turn (doesn't interrupt)") + @discord.app_commands.describe(prompt="The prompt to queue") + async def slash_queue(interaction: discord.Interaction, prompt: str): + await self._run_simple_slash(interaction, f"/queue {prompt}", "Queued for the next turn.") + + @tree.command(name="background", description="Run a prompt in the background") + @discord.app_commands.describe(prompt="The prompt to run in the background") + async def slash_background(interaction: discord.Interaction, prompt: str): + await self._run_simple_slash(interaction, f"/background {prompt}", "Background task started~") + + @tree.command(name="btw", description="Ephemeral side question using session context") + @discord.app_commands.describe(question="Your side question (no tools, not persisted)") + async def slash_btw(interaction: discord.Interaction, question: str): + await self._run_simple_slash(interaction, f"/btw {question}") + + # Register installed skills as native slash commands (parity with + # Telegram, which uses telegram_menu_commands() in commands.py). + # Discord allows up to 100 application commands globally. + _DISCORD_CMD_LIMIT = 100 + try: + from hermes_cli.commands import discord_skill_commands + + existing_names = {cmd.name for cmd in tree.get_commands()} + remaining_slots = max(0, _DISCORD_CMD_LIMIT - len(existing_names)) + + skill_entries, skipped = discord_skill_commands( + max_slots=remaining_slots, + reserved_names=existing_names, + ) + + for discord_name, description, cmd_key in skill_entries: + # Closure factory to capture cmd_key per iteration + def _make_skill_handler(_key: str): + async def _skill_slash(interaction: discord.Interaction, args: str = ""): + await self._run_simple_slash(interaction, f"{_key} {args}".strip()) + return _skill_slash + + handler = _make_skill_handler(cmd_key) + handler.__name__ = f"skill_{discord_name.replace('-', '_')}" + + cmd = discord.app_commands.Command( + name=discord_name, + description=description, + callback=handler, + ) + discord.app_commands.describe(args="Optional arguments for the skill")(cmd) + tree.add_command(cmd) + + if skipped: + logger.warning( + "[%s] Discord slash command limit reached (%d): %d skill(s) not registered", + self.name, _DISCORD_CMD_LIMIT, skipped, + ) + except Exception as exc: + logger.warning("[%s] Failed to register skill slash commands: %s", self.name, exc) + + def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent: + """Build a MessageEvent from a Discord slash command interaction.""" + is_dm = isinstance(interaction.channel, discord.DMChannel) + is_thread = isinstance(interaction.channel, discord.Thread) + thread_id = None + + if is_dm: + chat_type = "dm" + elif is_thread: + chat_type = "thread" + thread_id = str(interaction.channel_id) + else: + chat_type = "group" + + chat_name = "" + if not is_dm and hasattr(interaction.channel, "name"): + chat_name = interaction.channel.name + if hasattr(interaction.channel, "guild") and interaction.channel.guild: + chat_name = f"{interaction.channel.guild.name} / #{chat_name}" + + # Get channel topic (if available). + # For forum threads, inherit the parent forum's topic. + chat_topic = self._get_effective_topic(interaction.channel, is_thread=is_thread) + + source = self.build_source( + chat_id=str(interaction.channel_id), + chat_name=chat_name, + chat_type=chat_type, + user_id=str(interaction.user.id), + user_name=interaction.user.display_name, + thread_id=thread_id, + chat_topic=chat_topic, + ) + + msg_type = MessageType.COMMAND if text.startswith("/") else MessageType.TEXT + return MessageEvent( + text=text, + message_type=msg_type, + source=source, + raw_message=interaction, + ) + + # ------------------------------------------------------------------ + # Thread creation helpers + # ------------------------------------------------------------------ + + async def _handle_thread_create_slash( + self, + interaction: discord.Interaction, + name: str, + message: str = "", + auto_archive_duration: int = 1440, + ) -> None: + """Create a Discord thread from a slash command and start a session in it.""" + result = await self._create_thread( + interaction, + name=name, + message=message, + auto_archive_duration=auto_archive_duration, + ) + + if not result.get("success"): + error = result.get("error", "unknown error") + await interaction.followup.send(f"Failed to create thread: {error}", ephemeral=True) + return + + thread_id = result.get("thread_id") + thread_name = result.get("thread_name") or name + + # Tell the user where the thread is + link = f"<#{thread_id}>" if thread_id else f"**{thread_name}**" + await interaction.followup.send(f"Created thread {link}", ephemeral=True) + + # Track thread participation so follow-ups don't require @mention + if thread_id: + self._threads.mark(thread_id) + + # If a message was provided, kick off a new Hermes session in the thread + starter = (message or "").strip() + if starter and thread_id: + await self._dispatch_thread_session(interaction, thread_id, thread_name, starter) + + async def _dispatch_thread_session( + self, + interaction: discord.Interaction, + thread_id: str, + thread_name: str, + text: str, + ) -> None: + """Build a MessageEvent pointing at a thread and send it through handle_message.""" + guild_name = "" + if hasattr(interaction, "guild") and interaction.guild: + guild_name = interaction.guild.name + + chat_name = f"{guild_name} / {thread_name}" if guild_name else thread_name + + # Inherit forum topic when the thread was created inside a forum channel. + _chan = getattr(interaction, "channel", None) + chat_topic = self._get_effective_topic(_chan, is_thread=True) if _chan else None + + source = self.build_source( + chat_id=thread_id, + chat_name=chat_name, + chat_type="thread", + user_id=str(interaction.user.id), + user_name=interaction.user.display_name, + thread_id=thread_id, + chat_topic=chat_topic, + ) + + _parent_id = str(getattr(getattr(interaction, "channel", None), "parent_id", "") or "") + _skills = self._resolve_channel_skills(thread_id, _parent_id or None) + event = MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=source, + raw_message=interaction, + auto_skill=_skills, + ) + await self.handle_message(event) + + def _resolve_channel_skills(self, channel_id: str, parent_id: str | None = None) -> list[str] | None: + """Look up auto-skill bindings for a Discord channel/forum thread. + + Config format (in platform extra): + channel_skill_bindings: + - id: "123456" + skills: ["skill-a", "skill-b"] + Also checks parent_id so forum threads inherit the forum's bindings. + """ + bindings = self.config.extra.get("channel_skill_bindings", []) + if not bindings: + return None + ids_to_check = {channel_id} + if parent_id: + ids_to_check.add(parent_id) + for entry in bindings: + entry_id = str(entry.get("id", "")) + if entry_id in ids_to_check: + skills = entry.get("skills") or entry.get("skill") + if isinstance(skills, str): + return [skills] + if isinstance(skills, list) and skills: + return list(dict.fromkeys(skills)) # dedup, preserve order + return None + + def _thread_parent_channel(self, channel: Any) -> Any: + """Return the parent text channel when invoked from a thread.""" + return getattr(channel, "parent", None) or channel + + async def _resolve_interaction_channel(self, interaction: discord.Interaction) -> Optional[Any]: + """Return the interaction channel, fetching it if the payload is partial.""" + channel = getattr(interaction, "channel", None) + if channel is not None: + return channel + if not self._client: + return None + channel_id = getattr(interaction, "channel_id", None) + if channel_id is None: + return None + channel = self._client.get_channel(int(channel_id)) + if channel is not None: + return channel + try: + return await self._client.fetch_channel(int(channel_id)) + except Exception: + return None + + async def _create_thread( + self, + interaction: discord.Interaction, + *, + name: str, + message: str = "", + auto_archive_duration: int = 1440, + ) -> Dict[str, Any]: + """Create a thread in the current Discord channel. + + Tries ``parent_channel.create_thread()`` first. If Discord rejects + that (e.g. permission issues), falls back to sending a seed message + and creating the thread from it. + """ + name = (name or "").strip() + if not name: + return {"error": "Thread name is required."} + + if auto_archive_duration not in VALID_THREAD_AUTO_ARCHIVE_MINUTES: + allowed = ", ".join(str(v) for v in sorted(VALID_THREAD_AUTO_ARCHIVE_MINUTES)) + return {"error": f"auto_archive_duration must be one of: {allowed}."} + + channel = await self._resolve_interaction_channel(interaction) + if channel is None: + return {"error": "Could not resolve the current Discord channel."} + if isinstance(channel, discord.DMChannel): + return {"error": "Discord threads can only be created inside server text channels, not DMs."} + + parent_channel = self._thread_parent_channel(channel) + if parent_channel is None: + return {"error": "Could not determine a parent text channel for the new thread."} + + display_name = getattr(getattr(interaction, "user", None), "display_name", None) or "unknown user" + reason = f"Requested by {display_name} via /thread" + starter_message = (message or "").strip() + + try: + thread = await parent_channel.create_thread( + name=name, + auto_archive_duration=auto_archive_duration, + reason=reason, + ) + if starter_message: + await thread.send(starter_message) + return { + "success": True, + "thread_id": str(thread.id), + "thread_name": getattr(thread, "name", None) or name, + } + except Exception as direct_error: + try: + seed_content = starter_message or f"\U0001f9f5 Thread created by Hermes: **{name}**" + seed_msg = await parent_channel.send(seed_content) + thread = await seed_msg.create_thread( + name=name, + auto_archive_duration=auto_archive_duration, + reason=reason, + ) + return { + "success": True, + "thread_id": str(thread.id), + "thread_name": getattr(thread, "name", None) or name, + } + except Exception as fallback_error: + return { + "error": ( + "Discord rejected direct thread creation and the fallback also failed. " + f"Direct error: {direct_error}. Fallback error: {fallback_error}" + ) + } + + # ------------------------------------------------------------------ + # Auto-thread helpers + # ------------------------------------------------------------------ + + async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]: + """Create a thread from a user message for auto-threading. + + Returns the created thread object, or ``None`` on failure. + """ + # Build a short thread name from the message + content = (message.content or "").strip() + thread_name = content[:80] if content else "Hermes" + if len(content) > 80: + thread_name = thread_name[:77] + "..." + + try: + thread = await message.create_thread(name=thread_name, auto_archive_duration=1440) + return thread + except Exception as e: + logger.warning("[%s] Auto-thread creation failed: %s", self.name, e) + return None + + async def send_exec_approval( + self, chat_id: str, command: str, session_key: str, + description: str = "dangerous command", + metadata: Optional[dict] = None, + ) -> SendResult: + """ + Send a button-based exec approval prompt for a dangerous command. + + The buttons call ``resolve_gateway_approval()`` to unblock the waiting + agent thread — this replaces the text-based ``/approve`` flow on Discord. + """ + if not self._client or not DISCORD_AVAILABLE: + return SendResult(success=False, error="Not connected") + + try: + # Resolve channel — use thread_id from metadata if present + target_id = chat_id + if metadata and metadata.get("thread_id"): + target_id = metadata["thread_id"] + + channel = self._client.get_channel(int(target_id)) + if not channel: + channel = await self._client.fetch_channel(int(target_id)) + + # Discord embed description limit is 4096; show full command up to that + max_desc = 4088 + cmd_display = command if len(command) <= max_desc else command[: max_desc - 3] + "..." + embed = discord.Embed( + title="⚠️ Command Approval Required", + description=f"```\n{cmd_display}\n```", + color=discord.Color.orange(), + ) + embed.add_field(name="Reason", value=description, inline=False) + + view = ExecApprovalView( + session_key=session_key, + allowed_user_ids=self._allowed_user_ids, + ) + + msg = await channel.send(embed=embed, view=view) + return SendResult(success=True, message_id=str(msg.id)) + + except Exception as e: + return SendResult(success=False, error=str(e)) + + async def send_update_prompt( + self, chat_id: str, prompt: str, default: str = "", + session_key: str = "", + ) -> SendResult: + """Send an interactive button-based update prompt (Yes / No). + + Used by the gateway ``/update`` watcher when ``hermes update --gateway`` + needs user input (stash restore, config migration). + """ + if not self._client or not DISCORD_AVAILABLE: + return SendResult(success=False, error="Not connected") + try: + channel = self._client.get_channel(int(chat_id)) + if not channel: + channel = await self._client.fetch_channel(int(chat_id)) + + default_hint = f" (default: {default})" if default else "" + embed = discord.Embed( + title="⚕ Update Needs Your Input", + description=f"{prompt}{default_hint}", + color=discord.Color.gold(), + ) + view = UpdatePromptView( + session_key=session_key, + allowed_user_ids=self._allowed_user_ids, + ) + msg = await channel.send(embed=embed, view=view) + return SendResult(success=True, message_id=str(msg.id)) + except Exception as e: + return SendResult(success=False, error=str(e)) + + async def send_model_picker( + self, + chat_id: str, + providers: list, + current_model: str, + current_provider: str, + session_key: str, + on_model_selected, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an interactive select-menu model picker. + + Two-step drill-down: provider dropdown → model dropdown. + Uses Discord embeds + Select menus via ``ModelPickerView``. + """ + if not self._client or not DISCORD_AVAILABLE: + return SendResult(success=False, error="Not connected") + + try: + # Resolve target channel (use thread_id if present) + target_id = chat_id + if metadata and metadata.get("thread_id"): + target_id = metadata["thread_id"] + + channel = self._client.get_channel(int(target_id)) + if not channel: + channel = await self._client.fetch_channel(int(target_id)) + + try: + from hermes_cli.providers import get_label + provider_label = get_label(current_provider) + except Exception: + provider_label = current_provider + + embed = discord.Embed( + title="⚙ Model Configuration", + description=( + f"Current model: `{current_model or 'unknown'}`\n" + f"Provider: {provider_label}\n\n" + f"Select a provider:" + ), + color=discord.Color.blue(), + ) + + view = ModelPickerView( + providers=providers, + current_model=current_model, + current_provider=current_provider, + session_key=session_key, + on_model_selected=on_model_selected, + allowed_user_ids=self._allowed_user_ids, + ) + + msg = await channel.send(embed=embed, view=view) + return SendResult(success=True, message_id=str(msg.id)) + + except Exception as e: + logger.warning("[%s] send_model_picker failed: %s", self.name, e) + return SendResult(success=False, error=str(e)) + + def _get_parent_channel_id(self, channel: Any) -> Optional[str]: + """Return the parent channel ID for a Discord thread-like channel, if present.""" + parent = getattr(channel, "parent", None) + if parent is not None and getattr(parent, "id", None) is not None: + return str(parent.id) + parent_id = getattr(channel, "parent_id", None) + if parent_id is not None: + return str(parent_id) + return None + + def _is_forum_parent(self, channel: Any) -> bool: + """Best-effort check for whether a Discord channel is a forum channel.""" + if channel is None: + return False + forum_cls = getattr(discord, "ForumChannel", None) + if forum_cls and isinstance(channel, forum_cls): + return True + channel_type = getattr(channel, "type", None) + if channel_type is not None: + type_value = getattr(channel_type, "value", channel_type) + if type_value == 15: + return True + return False + + def _get_effective_topic(self, channel: Any, is_thread: bool = False) -> Optional[str]: + """Return the channel topic, falling back to the parent forum's topic for forum threads.""" + topic = getattr(channel, "topic", None) + if not topic and is_thread: + parent = getattr(channel, "parent", None) + if parent and self._is_forum_parent(parent): + topic = getattr(parent, "topic", None) + return topic + + def _format_thread_chat_name(self, thread: Any) -> str: + """Build a readable chat name for thread-like Discord channels, including forum context when available.""" + thread_name = getattr(thread, "name", None) or str(getattr(thread, "id", "thread")) + parent = getattr(thread, "parent", None) + guild = getattr(thread, "guild", None) or getattr(parent, "guild", None) + guild_name = getattr(guild, "name", None) + parent_name = getattr(parent, "name", None) + + if self._is_forum_parent(parent) and guild_name and parent_name: + return f"{guild_name} / {parent_name} / {thread_name}" + if parent_name and guild_name: + return f"{guild_name} / #{parent_name} / {thread_name}" + if parent_name: + return f"{parent_name} / {thread_name}" + return thread_name + + async def _handle_message(self, message: DiscordMessage) -> None: + """Handle incoming Discord messages.""" + # In server channels (not DMs), require the bot to be @mentioned + # UNLESS the channel is in the free-response list or the message is + # in a thread where the bot has already participated. + # + # Config (all settable via discord.* in config.yaml or DISCORD_* env vars): + # discord.require_mention: Require @mention in server channels (default: true) + # discord.free_response_channels: Channel IDs where bot responds without mention + # discord.ignored_channels: Channel IDs where bot NEVER responds (even when mentioned) + # discord.allowed_channels: If set, bot ONLY responds in these channels (whitelist) + # discord.no_thread_channels: Channel IDs where bot responds directly without creating thread + # discord.auto_thread: Auto-create thread on @mention in channels (default: true) + + thread_id = None + parent_channel_id = None + is_thread = isinstance(message.channel, discord.Thread) + if is_thread: + thread_id = str(message.channel.id) + parent_channel_id = self._get_parent_channel_id(message.channel) + + is_voice_linked_channel = False + if not isinstance(message.channel, discord.DMChannel): + channel_ids = {str(message.channel.id)} + if parent_channel_id: + channel_ids.add(parent_channel_id) + + # Check allowed channels - if set, only respond in these channels + allowed_channels_raw = os.getenv("DISCORD_ALLOWED_CHANNELS", "") + if allowed_channels_raw: + allowed_channels = {ch.strip() for ch in allowed_channels_raw.split(",") if ch.strip()} + if not (channel_ids & allowed_channels): + logger.debug("[%s] Ignoring message in non-allowed channel: %s", self.name, channel_ids) + return + + # Check ignored channels - never respond even when mentioned + ignored_channels_raw = os.getenv("DISCORD_IGNORED_CHANNELS", "") + ignored_channels = {ch.strip() for ch in ignored_channels_raw.split(",") if ch.strip()} + if channel_ids & ignored_channels: + logger.debug("[%s] Ignoring message in ignored channel: %s", self.name, channel_ids) + return + + free_channels_raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "") + free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()} + if parent_channel_id: + channel_ids.add(parent_channel_id) + + require_mention = os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no") + # Voice-linked text channels act as free-response while voice is active. + # Only the exact bound channel gets the exemption, not sibling threads. + voice_linked_ids = {str(ch_id) for ch_id in self._voice_text_channels.values()} + current_channel_id = str(message.channel.id) + is_voice_linked_channel = current_channel_id in voice_linked_ids + is_free_channel = bool(channel_ids & free_channels) or is_voice_linked_channel + + # Skip the mention check if the message is in a thread where + # the bot has previously participated (auto-created or replied in). + in_bot_thread = is_thread and thread_id in self._threads + + if require_mention and not is_free_channel and not in_bot_thread: + if self._client.user not in message.mentions: + return + + if self._client.user and self._client.user in message.mentions: + message.content = message.content.replace(f"<@{self._client.user.id}>", "").strip() + message.content = message.content.replace(f"<@!{self._client.user.id}>", "").strip() + + # Auto-thread: when enabled, automatically create a thread for every + # @mention in a text channel so each conversation is isolated (like Slack). + # Messages already inside threads or DMs are unaffected. + # no_thread_channels: channels where bot responds directly without thread. + auto_threaded_channel = None + if not is_thread and not isinstance(message.channel, discord.DMChannel): + no_thread_channels_raw = os.getenv("DISCORD_NO_THREAD_CHANNELS", "") + no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(",") if ch.strip()} + skip_thread = bool(channel_ids & no_thread_channels) + auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in ("true", "1", "yes") + if auto_thread and not skip_thread and not is_voice_linked_channel: + thread = await self._auto_create_thread(message) + if thread: + is_thread = True + thread_id = str(thread.id) + auto_threaded_channel = thread + self._threads.mark(thread_id) + + # Determine message type + msg_type = MessageType.TEXT + if message.content.startswith("/"): + msg_type = MessageType.COMMAND + elif message.attachments: + # Check attachment types + for att in message.attachments: + if att.content_type: + if att.content_type.startswith("image/"): + msg_type = MessageType.PHOTO + elif att.content_type.startswith("video/"): + msg_type = MessageType.VIDEO + elif att.content_type.startswith("audio/"): + msg_type = MessageType.AUDIO + else: + doc_ext = "" + if att.filename: + _, doc_ext = os.path.splitext(att.filename) + doc_ext = doc_ext.lower() + if doc_ext in SUPPORTED_DOCUMENT_TYPES: + msg_type = MessageType.DOCUMENT + break + + # When auto-threading kicked in, route responses to the new thread + effective_channel = auto_threaded_channel or message.channel + + # Determine chat type + if isinstance(message.channel, discord.DMChannel): + chat_type = "dm" + chat_name = message.author.name + elif is_thread: + chat_type = "thread" + chat_name = self._format_thread_chat_name(effective_channel) + else: + chat_type = "group" + chat_name = getattr(message.channel, "name", str(message.channel.id)) + if hasattr(message.channel, "guild") and message.channel.guild: + chat_name = f"{message.channel.guild.name} / #{chat_name}" + + # Get channel topic (if available - TextChannels have topics, DMs/threads don't). + # For threads whose parent is a forum channel, inherit the parent's topic + # so forum descriptions (e.g. project instructions) appear in the session context. + chat_topic = self._get_effective_topic(message.channel, is_thread=is_thread) + + # Build source + source = self.build_source( + chat_id=str(effective_channel.id), + chat_name=chat_name, + chat_type=chat_type, + user_id=str(message.author.id), + user_name=message.author.display_name, + thread_id=thread_id, + chat_topic=chat_topic, + ) + + # Build media URLs -- download image attachments to local cache so the + # vision tool can access them reliably (Discord CDN URLs can expire). + media_urls = [] + media_types = [] + pending_text_injection: Optional[str] = None + for att in message.attachments: + content_type = att.content_type or "unknown" + if content_type.startswith("image/"): + try: + # Determine extension from content type (image/png -> .png) + ext = "." + content_type.split("/")[-1].split(";")[0] + if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"): + ext = ".jpg" + cached_path = await cache_image_from_url(att.url, ext=ext) + media_urls.append(cached_path) + media_types.append(content_type) + print(f"[Discord] Cached user image: {cached_path}", flush=True) + except Exception as e: + print(f"[Discord] Failed to cache image attachment: {e}", flush=True) + # Fall back to the CDN URL if caching fails + media_urls.append(att.url) + media_types.append(content_type) + elif content_type.startswith("audio/"): + try: + ext = "." + content_type.split("/")[-1].split(";")[0] + if ext not in (".ogg", ".mp3", ".wav", ".webm", ".m4a"): + ext = ".ogg" + cached_path = await cache_audio_from_url(att.url, ext=ext) + media_urls.append(cached_path) + media_types.append(content_type) + print(f"[Discord] Cached user audio: {cached_path}", flush=True) + except Exception as e: + print(f"[Discord] Failed to cache audio attachment: {e}", flush=True) + media_urls.append(att.url) + media_types.append(content_type) + else: + # Document attachments: download, cache, and optionally inject text + ext = "" + if att.filename: + _, ext = os.path.splitext(att.filename) + ext = ext.lower() + if not ext and content_type: + mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()} + ext = mime_to_ext.get(content_type, "") + if ext not in SUPPORTED_DOCUMENT_TYPES: + logger.warning( + "[Discord] Unsupported document type '%s' (%s), skipping", + ext or "unknown", content_type, + ) + else: + MAX_DOC_BYTES = 32 * 1024 * 1024 + if att.size and att.size > MAX_DOC_BYTES: + logger.warning( + "[Discord] Document too large (%s bytes), skipping: %s", + att.size, att.filename, + ) + else: + try: + import aiohttp + from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp + _proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY") + _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) + async with aiohttp.ClientSession(**_sess_kw) as session: + async with session.get( + att.url, + timeout=aiohttp.ClientTimeout(total=30), + **_req_kw, + ) as resp: + if resp.status != 200: + raise Exception(f"HTTP {resp.status}") + raw_bytes = await resp.read() + cached_path = cache_document_from_bytes( + raw_bytes, att.filename or f"document{ext}" + ) + doc_mime = SUPPORTED_DOCUMENT_TYPES[ext] + media_urls.append(cached_path) + media_types.append(doc_mime) + logger.info("[Discord] Cached user document: %s", cached_path) + # Inject text content for plain-text documents (capped at 100 KB) + MAX_TEXT_INJECT_BYTES = 100 * 1024 + if ext in (".md", ".txt", ".log") and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: + try: + text_content = raw_bytes.decode("utf-8") + display_name = att.filename or f"document{ext}" + display_name = re.sub(r'[^\w.\- ]', '_', display_name) + injection = f"[Content of {display_name}]:\n{text_content}" + if pending_text_injection: + pending_text_injection = f"{pending_text_injection}\n\n{injection}" + else: + pending_text_injection = injection + except UnicodeDecodeError: + pass + except Exception as e: + logger.warning( + "[Discord] Failed to cache document %s: %s", + att.filename, e, exc_info=True, + ) + + event_text = message.content + if pending_text_injection: + event_text = f"{pending_text_injection}\n\n{event_text}" if event_text else pending_text_injection + + # Defense-in-depth: prevent empty user messages from entering session + # (can happen when user sends @mention-only with no other text) + if not event_text or not event_text.strip(): + event_text = "(The user sent a message with no text content)" + + _chan = message.channel + _parent_id = str(getattr(_chan, "parent_id", "") or "") + _chan_id = str(getattr(_chan, "id", "")) + _skills = self._resolve_channel_skills(_chan_id, _parent_id or None) + event = MessageEvent( + text=event_text, + message_type=msg_type, + source=source, + raw_message=message, + message_id=str(message.id), + media_urls=media_urls, + media_types=media_types, + reply_to_message_id=str(message.reference.message_id) if message.reference else None, + timestamp=message.created_at, + auto_skill=_skills, + ) + + # Track thread participation so the bot won't require @mention for + # follow-up messages in threads it has already engaged in. + if thread_id: + self._threads.mark(thread_id) + + # Only batch plain text messages — commands, media, etc. dispatch + # immediately since they won't be split by the Discord client. + if msg_type == MessageType.TEXT and self._text_batch_delay_seconds > 0: + self._enqueue_text_event(event) + else: + await self.handle_message(event) + + # ------------------------------------------------------------------ + # Text message aggregation (handles Discord client-side splits) + # ------------------------------------------------------------------ + + def _text_batch_key(self, event: MessageEvent) -> str: + """Session-scoped key for text message batching.""" + from gateway.session import build_session_key + return build_session_key( + event.source, + group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), + thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + ) + + def _enqueue_text_event(self, event: MessageEvent) -> None: + """Buffer a text event and reset the flush timer. + + When Discord splits a long user message at 2000 chars, the chunks + arrive within a few hundred milliseconds. This merges them into + a single event before dispatching. + """ + key = self._text_batch_key(event) + existing = self._pending_text_batches.get(key) + chunk_len = len(event.text or "") + if existing is None: + event._last_chunk_len = chunk_len # type: ignore[attr-defined] + self._pending_text_batches[key] = event + else: + if event.text: + existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text + existing._last_chunk_len = chunk_len # type: ignore[attr-defined] + if event.media_urls: + existing.media_urls.extend(event.media_urls) + existing.media_types.extend(event.media_types) + + prior_task = self._pending_text_batch_tasks.get(key) + if prior_task and not prior_task.done(): + prior_task.cancel() + self._pending_text_batch_tasks[key] = asyncio.create_task( + self._flush_text_batch(key) + ) + + async def _flush_text_batch(self, key: str) -> None: + """Wait for the quiet period then dispatch the aggregated text. + + Uses a longer delay when the latest chunk is near Discord's 2000-char + split point, since a continuation chunk is almost certain. + """ + current_task = asyncio.current_task() + try: + pending = self._pending_text_batches.get(key) + last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0 + if last_len >= self._SPLIT_THRESHOLD: + delay = self._text_batch_split_delay_seconds + else: + delay = self._text_batch_delay_seconds + await asyncio.sleep(delay) + event = self._pending_text_batches.pop(key, None) + if not event: + return + logger.info( + "[Discord] Flushing text batch %s (%d chars)", + key, len(event.text or ""), + ) + await self.handle_message(event) + finally: + if self._pending_text_batch_tasks.get(key) is current_task: + self._pending_text_batch_tasks.pop(key, None) + + +# --------------------------------------------------------------------------- +# Discord UI Components (outside the adapter class) +# --------------------------------------------------------------------------- + +if DISCORD_AVAILABLE: + + class ExecApprovalView(discord.ui.View): + """ + Interactive button view for exec approval of dangerous commands. + + Shows four buttons: Allow Once, Allow Session, Always Allow, Deny. + Clicking a button calls ``resolve_gateway_approval()`` to unblock the + waiting agent thread — the same mechanism as the text ``/approve`` flow. + Only users in the allowed list can click. Times out after 5 minutes. + """ + + def __init__(self, session_key: str, allowed_user_ids: set): + super().__init__(timeout=300) # 5-minute timeout + self.session_key = session_key + self.allowed_user_ids = allowed_user_ids + self.resolved = False + + def _check_auth(self, interaction: discord.Interaction) -> bool: + """Verify the user clicking is authorized.""" + if not self.allowed_user_ids: + return True # No allowlist = anyone can approve + return str(interaction.user.id) in self.allowed_user_ids + + async def _resolve( + self, interaction: discord.Interaction, choice: str, + color: discord.Color, label: str, + ): + """Resolve the approval via the gateway approval queue and update the embed.""" + if self.resolved: + await interaction.response.send_message( + "This approval has already been resolved~", ephemeral=True + ) + return + + if not self._check_auth(interaction): + await interaction.response.send_message( + "You're not authorized to approve commands~", ephemeral=True + ) + return + + self.resolved = True + + # Update the embed with the decision + embed = interaction.message.embeds[0] if interaction.message.embeds else None + if embed: + embed.color = color + embed.set_footer(text=f"{label} by {interaction.user.display_name}") + + # Disable all buttons + for child in self.children: + child.disabled = True + + await interaction.response.edit_message(embed=embed, view=self) + + # Unblock the waiting agent thread via the gateway approval queue + try: + from tools.approval import resolve_gateway_approval + count = resolve_gateway_approval(self.session_key, choice) + logger.info( + "Discord button resolved %d approval(s) for session %s (choice=%s, user=%s)", + count, self.session_key, choice, interaction.user.display_name, + ) + except Exception as exc: + logger.error("Failed to resolve gateway approval from button: %s", exc) + + @discord.ui.button(label="Allow Once", style=discord.ButtonStyle.green) + async def allow_once( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + await self._resolve(interaction, "once", discord.Color.green(), "Approved once") + + @discord.ui.button(label="Allow Session", style=discord.ButtonStyle.grey) + async def allow_session( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + await self._resolve(interaction, "session", discord.Color.blue(), "Approved for session") + + @discord.ui.button(label="Always Allow", style=discord.ButtonStyle.blurple) + async def allow_always( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + await self._resolve(interaction, "always", discord.Color.purple(), "Approved permanently") + + @discord.ui.button(label="Deny", style=discord.ButtonStyle.red) + async def deny( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + await self._resolve(interaction, "deny", discord.Color.red(), "Denied") + + async def on_timeout(self): + """Handle view timeout -- disable buttons and mark as expired.""" + self.resolved = True + for child in self.children: + child.disabled = True + + class UpdatePromptView(discord.ui.View): + """Interactive Yes/No buttons for ``hermes update`` prompts. + + Clicking a button writes the answer to ``.update_response`` so the + detached update process can pick it up. Only authorized users can + click. Times out after 5 minutes (the update process also has a + 5-minute timeout on its side). + """ + + def __init__(self, session_key: str, allowed_user_ids: set): + super().__init__(timeout=300) + self.session_key = session_key + self.allowed_user_ids = allowed_user_ids + self.resolved = False + + def _check_auth(self, interaction: discord.Interaction) -> bool: + if not self.allowed_user_ids: + return True + return str(interaction.user.id) in self.allowed_user_ids + + async def _respond( + self, interaction: discord.Interaction, answer: str, + color: discord.Color, label: str, + ): + if self.resolved: + await interaction.response.send_message( + "Already answered~", ephemeral=True + ) + return + if not self._check_auth(interaction): + await interaction.response.send_message( + "You're not authorized~", ephemeral=True + ) + return + + self.resolved = True + + # Update embed + embed = interaction.message.embeds[0] if interaction.message.embeds else None + if embed: + embed.color = color + embed.set_footer(text=f"{label} by {interaction.user.display_name}") + + for child in self.children: + child.disabled = True + await interaction.response.edit_message(embed=embed, view=self) + + # Write response file + try: + from hermes_constants import get_hermes_home + home = get_hermes_home() + response_path = home / ".update_response" + tmp = response_path.with_suffix(".tmp") + tmp.write_text(answer) + tmp.replace(response_path) + logger.info( + "Discord update prompt answered '%s' by %s", + answer, interaction.user.display_name, + ) + except Exception as exc: + logger.error("Failed to write update response: %s", exc) + + @discord.ui.button(label="Yes", style=discord.ButtonStyle.green, emoji="✓") + async def yes_btn( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + await self._respond(interaction, "y", discord.Color.green(), "Yes") + + @discord.ui.button(label="No", style=discord.ButtonStyle.red, emoji="✗") + async def no_btn( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + await self._respond(interaction, "n", discord.Color.red(), "No") + + async def on_timeout(self): + self.resolved = True + for child in self.children: + child.disabled = True + + class ModelPickerView(discord.ui.View): + """Interactive select-menu view for model switching. + + Two-step drill-down: provider dropdown → model dropdown. + Edits the original message in-place as the user navigates. + Times out after 2 minutes. + """ + + def __init__( + self, + providers: list, + current_model: str, + current_provider: str, + session_key: str, + on_model_selected, + allowed_user_ids: set, + ): + super().__init__(timeout=120) + self.providers = providers + self.current_model = current_model + self.current_provider = current_provider + self.session_key = session_key + self.on_model_selected = on_model_selected + self.allowed_user_ids = allowed_user_ids + self.resolved = False + self._selected_provider: str = "" + + self._build_provider_select() + + def _check_auth(self, interaction: discord.Interaction) -> bool: + if not self.allowed_user_ids: + return True + return str(interaction.user.id) in self.allowed_user_ids + + def _build_provider_select(self): + """Build the provider dropdown menu.""" + self.clear_items() + options = [] + for p in self.providers: + count = p.get("total_models", len(p.get("models", []))) + label = f"{p['name']} ({count} models)" + desc = "current" if p.get("is_current") else None + options.append( + discord.SelectOption( + label=label[:100], + value=p["slug"], + description=desc, + ) + ) + if not options: + return + + select = discord.ui.Select( + placeholder="Choose a provider...", + options=options[:25], + custom_id="model_provider_select", + ) + select.callback = self._on_provider_selected + self.add_item(select) + + cancel_btn = discord.ui.Button( + label="Cancel", style=discord.ButtonStyle.red, custom_id="model_cancel" + ) + cancel_btn.callback = self._on_cancel + self.add_item(cancel_btn) + + def _build_model_select(self, provider_slug: str): + """Build the model dropdown for a specific provider.""" + self.clear_items() + provider = next( + (p for p in self.providers if p["slug"] == provider_slug), None + ) + if not provider: + return + + models = provider.get("models", []) + options = [] + for model_id in models[:25]: + short = model_id.split("/")[-1] if "/" in model_id else model_id + options.append( + discord.SelectOption( + label=short[:100], + value=model_id[:100], + ) + ) + if not options: + return + + select = discord.ui.Select( + placeholder=f"Choose a model from {provider.get('name', provider_slug)}...", + options=options, + custom_id="model_model_select", + ) + select.callback = self._on_model_selected + self.add_item(select) + + back_btn = discord.ui.Button( + label="◀ Back", style=discord.ButtonStyle.grey, custom_id="model_back" + ) + back_btn.callback = self._on_back + self.add_item(back_btn) + + cancel_btn = discord.ui.Button( + label="Cancel", style=discord.ButtonStyle.red, custom_id="model_cancel2" + ) + cancel_btn.callback = self._on_cancel + self.add_item(cancel_btn) + + async def _on_provider_selected(self, interaction: discord.Interaction): + if not self._check_auth(interaction): + await interaction.response.send_message( + "You're not authorized~", ephemeral=True + ) + return + + provider_slug = interaction.data["values"][0] + self._selected_provider = provider_slug + provider = next( + (p for p in self.providers if p["slug"] == provider_slug), None + ) + pname = provider.get("name", provider_slug) if provider else provider_slug + + self._build_model_select(provider_slug) + + total = provider.get("total_models", 0) if provider else 0 + shown = min(len(provider.get("models", [])), 25) if provider else 0 + extra = f"\n*{total - shown} more available — type `/model ` directly*" if total > shown else "" + + await interaction.response.edit_message( + embed=discord.Embed( + title="⚙ Model Configuration", + description=f"Provider: **{pname}**\nSelect a model:{extra}", + color=discord.Color.blue(), + ), + view=self, + ) + + async def _on_model_selected(self, interaction: discord.Interaction): + if self.resolved: + await interaction.response.send_message( + "Already resolved~", ephemeral=True + ) + return + if not self._check_auth(interaction): + await interaction.response.send_message( + "You're not authorized~", ephemeral=True + ) + return + + self.resolved = True + model_id = interaction.data["values"][0] + + try: + result_text = await self.on_model_selected( + str(interaction.channel_id), + model_id, + self._selected_provider, + ) + except Exception as exc: + result_text = f"Error switching model: {exc}" + + self.clear_items() + await interaction.response.edit_message( + embed=discord.Embed( + title="⚙ Model Switched", + description=result_text, + color=discord.Color.green(), + ), + view=self, + ) + + async def _on_back(self, interaction: discord.Interaction): + if not self._check_auth(interaction): + await interaction.response.send_message( + "You're not authorized~", ephemeral=True + ) + return + + self._build_provider_select() + + try: + from hermes_cli.providers import get_label + provider_label = get_label(self.current_provider) + except Exception: + provider_label = self.current_provider + + await interaction.response.edit_message( + embed=discord.Embed( + title="⚙ Model Configuration", + description=( + f"Current model: `{self.current_model or 'unknown'}`\n" + f"Provider: {provider_label}\n\n" + f"Select a provider:" + ), + color=discord.Color.blue(), + ), + view=self, + ) + + async def _on_cancel(self, interaction: discord.Interaction): + self.resolved = True + self.clear_items() + await interaction.response.edit_message( + embed=discord.Embed( + title="⚙ Model Configuration", + description="Model selection cancelled.", + color=discord.Color.greyple(), + ), + view=self, + ) + + async def on_timeout(self): + self.resolved = True + self.clear_items() diff --git a/mindcli/_vendor/gateway/platforms/doc_parser.py b/mindcli/_vendor/gateway/platforms/doc_parser.py new file mode 100644 index 0000000..53c1e14 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/doc_parser.py @@ -0,0 +1,335 @@ +""" +⚠️ DEPRECATED — 请勿修改、复制或引用此文件 ⚠️ + +本文件已被 infra.pipelines.anyfile2md 统一管线取代。 +mindos_sse.py 已完成迁移(2026-04-20),不再调用此模块。 + +新的统一管线位于: + mindOSv2/hermes-overlay/infra/pipelines/anyfile2md.py + mindOSv2/hermes-overlay/infra/atoms/ (6 个原子操作) + +原始文件保留仅供参考,下次部署时将删除。 + +────────────────────────────────────────────────────── +以下为原始 docstring(仅供考古): + +doc_parser.py — AnyFile2MD 通用文件解析模块(已废弃) + +架构约束(SPEC_anyfile2md_doc_parser.md v1.1): + ❌ 不依赖 LLMAgno(V2 遗留)——前代隔离铁律 + ❌ 不调用 LLM(VL 降级除外),不写 DB,不持久化任何状态 + ✅ VL 调用直接走 LiteLLM Gateway(OpenAI SDK) + ✅ 封闭函数(铁律 1):只做格式分发 + 文本提取 + 返回 Markdown + +公开接口: + parse_to_markdown(read_url, filename, ...) → str + is_supported_doc(filename) → bool + SUPPORTED_DOC_EXTS → set + +依赖: + python-docx (≈V2 mammoth —— DOCX 文本提取) + pdfplumber (≈V2 pdf-parse —— PDF 文字提取) + PyMuPDF (fitz) (≈V2 pdf-to-img —— PDF 逐页光栅化,VL 降级用) + openai (VL 调用,hermes 已有) + httpx (OSS 下载/上传,hermes 已有) +""" + +import logging +import os +import tempfile +import time +from pathlib import Path +from typing import Optional + +import httpx + +logger = logging.getLogger(__name__) + +# ─── 格式分类(移植自 V2 normalize-manager.cjs L14-30) ───── + +AUDIO_EXTS = { + ".mp3", ".mp4", ".m4a", ".wav", ".webm", + ".ogg", ".aac", ".flac", ".opus", ".amr", +} +IMAGE_EXTS = { + ".png", ".jpg", ".jpeg", ".bmp", ".gif", + ".webp", ".tiff", ".tif", +} +DOCX_EXTS = {".docx", ".doc"} +PDF_EXTS = {".pdf"} +TEXT_EXTS = {".txt", ".md", ".markdown", ".rst", ".log"} + +# 所有支持的非音频格式(前端 accept 属性用) +SUPPORTED_DOC_EXTS = IMAGE_EXTS | DOCX_EXTS | PDF_EXTS | TEXT_EXTS + + +def is_supported_doc(filename: str) -> bool: + """判断文件是否为支持的文档/图片格式。""" + ext = Path(filename).suffix.lower() + return ext in SUPPORTED_DOC_EXTS + + +# ─── VL 提示词(从 LLMAgno/server.py 复制并固化) ──────────── + +VL_EXTRACT_PROMPT = """请仔细阅读这张图片中的所有内容,并以结构化的纯文本形式输出: + +1. 完整转录图片中的所有文字内容,保留原始排版结构(标题、段落、列表等) +2. 如果图片包含表格,用 Markdown 表格格式输出 +3. 如果图片包含图表(柱状图、折线图、饼图等),用文字描述图表的数据和趋势 +4. 如果图片包含流程图或示意图,用文字描述其结构和逻辑关系 +5. 忽略页眉页脚、页码、水印等装饰性元素 + +直接输出内容,不要加任何前缀说明或总结。""" + + +# ─── 主函数 ───────────────────────────────────────────────── + +async def parse_to_markdown( + read_url: str, + filename: str, + *, + max_pages: int = 30, + vl_model: str = "qwen-vl", +) -> dict: + """ + 下载 OSS 文件 → 按扩展名分流解析 → 返回解析结果。 + + 封闭函数(铁律 1):不写 DB,不持久化状态。 + VL 调用直接走 LiteLLM Gateway(前代隔离铁律)。 + + Args: + read_url: OSS 可读 URL(由 generate_oss_presign 返回) + filename: 原始文件名(用于判断扩展名) + max_pages: PDF 最大处理页数(安全守卫,V2 验证值=30) + vl_model: VL 模型别名(LiteLLM 标准别名) + + Returns: + { + "text": str, # Markdown 格式的文本内容 + "provider": str, # 使用的解析方式 + "vl_pages": int, # VL 识别的页数(用于积分计算) + } + + Raises: + ValueError: 不支持的文件格式 + RuntimeError: 解析失败 + """ + ext = Path(filename).suffix.lower() + + if ext in AUDIO_EXTS: + raise ValueError(f"音频文件请走 /api/audio/transcribe 管线: {ext}") + + if ext not in SUPPORTED_DOC_EXTS: + raise ValueError(f"不支持的文件格式: {ext}") + + # 1. 下载到临时文件 + tmp_path = await _download_to_temp(read_url, filename) + + try: + if ext in TEXT_EXTS: + text = _read_text_file(tmp_path) + return {"text": text, "provider": "text_read", "vl_pages": 0} + + if ext in IMAGE_EXTS: + text = await _vl_extract_image(read_url, filename, vl_model) + return {"text": text, "provider": "vl_image", "vl_pages": 1} + + if ext in DOCX_EXTS: + text = _extract_docx(tmp_path) + return {"text": text, "provider": "python_docx", "vl_pages": 0} + + if ext in PDF_EXTS: + # PDF 双策略降级(移植自 V2 normalize-manager L260-280) + text = _extract_pdf_text(tmp_path) + if len(text.strip()) >= 50: + return {"text": text, "provider": "pdfplumber", "vl_pages": 0} + + logger.info( + "[DocParser] PDF 文字过少 (%d字),降级到 VL 逐页识别", + len(text.strip()), + ) + text, page_count = await _vl_extract_pdf_pages( + tmp_path, filename, max_pages, vl_model, + ) + return {"text": text, "provider": "vl_pdf_pages", "vl_pages": page_count} + + # 兜底:尝试直读 + text = _read_text_file(tmp_path) + return {"text": text, "provider": "text_fallback", "vl_pages": 0} + + finally: + _cleanup_temp(tmp_path) + + +# ─── 各格式解析函数 ────────────────────────────────────────── + +def _extract_docx(file_path: str) -> str: + """DOCX → Markdown 文本。等价于 V2 mammoth.extractRawText()。""" + import docx # python-docx + + doc = docx.Document(file_path) + paragraphs = [p.text for p in doc.paragraphs if p.text.strip()] + return "\n\n".join(paragraphs) + + +def _extract_pdf_text(file_path: str) -> str: + """PDF 文字提取。等价于 V2 pdf-parse。""" + import pdfplumber + + texts = [] + with pdfplumber.open(file_path) as pdf: + for page in pdf.pages: + text = page.extract_text() + if text: + texts.append(text) + return "\n\n".join(texts) + + +async def _vl_extract_image( + image_url: str, + filename: str, + model: str = "qwen-vl", +) -> str: + """单张图片 VL 识别。直接走 LiteLLM Gateway(前代隔离铁律)。""" + import openai + + client = openai.AsyncOpenAI( + base_url=os.getenv("LITELLM_BASE_URL", "http://127.0.0.1:4000/v1"), + api_key=os.getenv("LITELLM_API_KEY", ""), + ) + + start = time.time() + response = await client.chat.completions.create( + model=model, + messages=[{ + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": image_url}}, + {"type": "text", "text": VL_EXTRACT_PROMPT}, + ], + }], + max_tokens=4096, + temperature=0.1, + ) + + text = response.choices[0].message.content or "" + elapsed = int((time.time() - start) * 1000) + logger.info( + "[DocParser] VL ✅ model=%s, text_len=%d, elapsed=%dms, file=%s", + model, len(text), elapsed, filename, + ) + return text + + +async def _vl_extract_pdf_pages( + file_path: str, + filename: str, + max_pages: int, + model: str, +) -> tuple[str, int]: + """扫描型 PDF 逐页 VL 识别。移植自 V2 extractPdfViaVL。 + + Returns: + (markdown_text, page_count) — page_count 用于积分计算 + """ + import fitz # PyMuPDF + + doc = fitz.open(file_path) + total_pages = len(doc) + pages = [] + processed_count = 0 + + for i, page in enumerate(doc): + if i >= max_pages: + logger.warning( + "[DocParser] PDF 页数超限 (%d/%d),截断", max_pages, total_pages, + ) + break + + # 光栅化 → PNG 字节 + pix = page.get_pixmap(dpi=200) + img_bytes = pix.tobytes("png") + + # 每页独立 try-except(V2 验证的韧性策略) + try: + img_url = await _upload_temp_image( + img_bytes, f"{filename}_p{i}.png", + ) + text = await _vl_extract_image(img_url, f"{filename}_p{i}", model) + if text.strip(): + pages.append(f"## 第 {i + 1} 页\n\n{text}") + processed_count += 1 + except Exception as e: + logger.warning( + "[DocParser] PDF 第%d页 VL 失败(跳过): %s", i + 1, e, + ) + + doc.close() + return "\n\n".join(pages), processed_count + + +# ─── 辅助函数 ──────────────────────────────────────────────── + +def _read_text_file(file_path: str) -> str: + """直读文本文件,含二进制安全检查(移植自 V2 normalize-manager L287-299)。""" + with open(file_path, "rb") as f: + raw = f.read() + + # null 字节检测:防止二进制文件误读 + if b"\x00" in raw[:1024]: + raise ValueError("文件包含二进制内容,无法作为文本处理") + + return raw.decode("utf-8", errors="replace") + + +async def _download_to_temp(read_url: str, filename: str) -> str: + """从 OSS URL 下载到临时文件,返回临时路径。""" + suffix = Path(filename).suffix or ".bin" + + async with httpx.AsyncClient(timeout=120) as client: + resp = await client.get(read_url) + resp.raise_for_status() + + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) + tmp.write(resp.content) + tmp.close() + + logger.info( + "[DocParser] 下载完成: %s → %s (%d bytes)", + filename, tmp.name, len(resp.content), + ) + return tmp.name + + +async def _upload_temp_image(img_bytes: bytes, filename: str) -> str: + """上传临时图片到 OSS,返回可读 URL(VL 逐页降级用)。 + + 复用 flash_asr.generate_oss_presign + httpx PUT。 + """ + from flash_asr import generate_oss_presign # type: ignore + + ext = Path(filename).suffix or ".png" + # 使用独立的 OSS 前缀,与音频/文档区分 + presign = generate_oss_presign( + user_id="vl_temp", ext=ext, + prefix="mindos-next/vl-temp", + ) + + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.put( + presign["upload_url"], + content=img_bytes, + headers={"Content-Type": presign["content_type"]}, + ) + resp.raise_for_status() + + logger.debug("[DocParser] 临时图片已上传: %s", presign["oss_key"]) + return presign["read_url"] + + +def _cleanup_temp(tmp_path: str) -> None: + """删除临时文件,fire-and-forget。""" + try: + os.unlink(tmp_path) + except Exception: + pass diff --git a/mindcli/_vendor/gateway/platforms/email.py b/mindcli/_vendor/gateway/platforms/email.py new file mode 100644 index 0000000..d4261cc --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/email.py @@ -0,0 +1,625 @@ +""" +Email platform adapter for the Hermes gateway. + +Allows users to interact with Hermes by sending emails. +Uses IMAP to receive and SMTP to send messages. + +Environment variables: + EMAIL_IMAP_HOST — IMAP server host (e.g., imap.gmail.com) + EMAIL_IMAP_PORT — IMAP server port (default: 993) + EMAIL_SMTP_HOST — SMTP server host (e.g., smtp.gmail.com) + EMAIL_SMTP_PORT — SMTP server port (default: 587) + EMAIL_ADDRESS — Email address for the agent + EMAIL_PASSWORD — Email password or app-specific password + EMAIL_POLL_INTERVAL — Seconds between mailbox checks (default: 15) + EMAIL_ALLOWED_USERS — Comma-separated list of allowed sender addresses +""" + +import asyncio +import email as email_lib +import imaplib +import logging +import os +import re +import smtplib +import ssl +import uuid +from email.header import decode_header +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.base import MIMEBase +from email import encoders +from pathlib import Path +from typing import Any, Dict, List, Optional + +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, + cache_document_from_bytes, + cache_image_from_bytes, +) +from gateway.config import Platform, PlatformConfig + +logger = logging.getLogger(__name__) +# Automated sender patterns — emails from these are silently ignored +_NOREPLY_PATTERNS = ( + "noreply", "no-reply", "no_reply", "donotreply", "do-not-reply", + "mailer-daemon", "postmaster", "bounce", "notifications@", + "automated@", "auto-confirm", "auto-reply", "automailer", +) + +# RFC headers that indicate bulk/automated mail +_AUTOMATED_HEADERS = { + "Auto-Submitted": lambda v: v.lower() != "no", + "Precedence": lambda v: v.lower() in ("bulk", "list", "junk"), + "X-Auto-Response-Suppress": lambda v: bool(v), + "List-Unsubscribe": lambda v: bool(v), +} + +# Gmail-safe max length per email body +MAX_MESSAGE_LENGTH = 50_000 + +# Supported image extensions for inline detection +_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} + +def _is_automated_sender(address: str, headers: dict) -> bool: + """Return True if this email is from an automated/noreply source.""" + addr = address.lower() + if any(pattern in addr for pattern in _NOREPLY_PATTERNS): + return True + for header, check in _AUTOMATED_HEADERS.items(): + value = headers.get(header, "") + if value and check(value): + return True + return False + +def check_email_requirements() -> bool: + """Check if email platform dependencies are available.""" + addr = os.getenv("EMAIL_ADDRESS") + pwd = os.getenv("EMAIL_PASSWORD") + imap = os.getenv("EMAIL_IMAP_HOST") + smtp = os.getenv("EMAIL_SMTP_HOST") + if not all([addr, pwd, imap, smtp]): + return False + return True + + +def _decode_header_value(raw: str) -> str: + """Decode an RFC 2047 encoded email header into a plain string.""" + parts = decode_header(raw) + decoded = [] + for part, charset in parts: + if isinstance(part, bytes): + decoded.append(part.decode(charset or "utf-8", errors="replace")) + else: + decoded.append(part) + return " ".join(decoded) + + +def _extract_text_body(msg: email_lib.message.Message) -> str: + """Extract the plain-text body from a potentially multipart email.""" + if msg.is_multipart(): + for part in msg.walk(): + content_type = part.get_content_type() + disposition = str(part.get("Content-Disposition", "")) + # Skip attachments + if "attachment" in disposition: + continue + if content_type == "text/plain": + payload = part.get_payload(decode=True) + if payload: + charset = part.get_content_charset() or "utf-8" + return payload.decode(charset, errors="replace") + # Fallback: try text/html and strip tags + for part in msg.walk(): + content_type = part.get_content_type() + disposition = str(part.get("Content-Disposition", "")) + if "attachment" in disposition: + continue + if content_type == "text/html": + payload = part.get_payload(decode=True) + if payload: + charset = part.get_content_charset() or "utf-8" + html = payload.decode(charset, errors="replace") + return _strip_html(html) + return "" + else: + payload = msg.get_payload(decode=True) + if payload: + charset = msg.get_content_charset() or "utf-8" + text = payload.decode(charset, errors="replace") + if msg.get_content_type() == "text/html": + return _strip_html(text) + return text + return "" + + +def _strip_html(html: str) -> str: + """Naive HTML tag stripper for fallback text extraction.""" + text = re.sub(r"", "\n", html, flags=re.IGNORECASE) + text = re.sub(r"]*>", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"

", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"<[^>]+>", "", text) + text = re.sub(r" ", " ", text) + text = re.sub(r"&", "&", text) + text = re.sub(r"<", "<", text) + text = re.sub(r">", ">", text) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + +def _extract_email_address(raw: str) -> str: + """Extract bare email address from 'Name ' format.""" + match = re.search(r"<([^>]+)>", raw) + if match: + return match.group(1).strip().lower() + return raw.strip().lower() + + +def _extract_attachments( + msg: email_lib.message.Message, + skip_attachments: bool = False, +) -> List[Dict[str, Any]]: + """Extract attachment metadata and cache files locally. + + When *skip_attachments* is True, all attachment/inline parts are ignored + (useful for malware protection or bandwidth savings). + """ + attachments = [] + if not msg.is_multipart(): + return attachments + + for part in msg.walk(): + disposition = str(part.get("Content-Disposition", "")) + if skip_attachments and ("attachment" in disposition or "inline" in disposition): + continue + if "attachment" not in disposition and "inline" not in disposition: + continue + # Skip text/plain and text/html body parts + content_type = part.get_content_type() + if content_type in ("text/plain", "text/html") and "attachment" not in disposition: + continue + + filename = part.get_filename() + if filename: + filename = _decode_header_value(filename) + else: + ext = part.get_content_subtype() or "bin" + filename = f"attachment.{ext}" + + payload = part.get_payload(decode=True) + if not payload: + continue + + ext = Path(filename).suffix.lower() + if ext in _IMAGE_EXTS: + try: + cached_path = cache_image_from_bytes(payload, ext) + except ValueError: + logger.debug("Skipping non-image attachment %s (invalid magic bytes)", filename) + continue + attachments.append({ + "path": cached_path, + "filename": filename, + "type": "image", + "media_type": content_type, + }) + else: + cached_path = cache_document_from_bytes(payload, filename) + attachments.append({ + "path": cached_path, + "filename": filename, + "type": "document", + "media_type": content_type, + }) + + return attachments + + +class EmailAdapter(BasePlatformAdapter): + """Email gateway adapter using IMAP (receive) and SMTP (send).""" + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.EMAIL) + + self._address = os.getenv("EMAIL_ADDRESS", "") + self._password = os.getenv("EMAIL_PASSWORD", "") + self._imap_host = os.getenv("EMAIL_IMAP_HOST", "") + self._imap_port = int(os.getenv("EMAIL_IMAP_PORT", "993")) + self._smtp_host = os.getenv("EMAIL_SMTP_HOST", "") + self._smtp_port = int(os.getenv("EMAIL_SMTP_PORT", "587")) + self._poll_interval = int(os.getenv("EMAIL_POLL_INTERVAL", "15")) + + # Skip attachments — configured via config.yaml: + # platforms: + # email: + # skip_attachments: true + extra = config.extra or {} + self._skip_attachments = extra.get("skip_attachments", False) + + # Track message IDs we've already processed to avoid duplicates + self._seen_uids: set = set() + self._seen_uids_max: int = 2000 # cap to prevent unbounded memory growth + self._poll_task: Optional[asyncio.Task] = None + + # Map chat_id (sender email) -> last subject + message-id for threading + self._thread_context: Dict[str, Dict[str, str]] = {} + + logger.info("[Email] Adapter initialized for %s", self._address) + + def _trim_seen_uids(self) -> None: + """Keep only the most recent UIDs to prevent unbounded memory growth. + + IMAP UIDs are monotonically increasing integers. When the set grows + beyond the cap, we keep only the highest half — old UIDs are safe to + drop because new messages always have higher UIDs and IMAP's UNSEEN + flag prevents re-delivery regardless. + """ + if len(self._seen_uids) <= self._seen_uids_max: + return + try: + # UIDs are bytes like b'1234' — sort numerically and keep top half + sorted_uids = sorted(self._seen_uids, key=lambda u: int(u)) + keep = self._seen_uids_max // 2 + self._seen_uids = set(sorted_uids[-keep:]) + logger.debug("[Email] Trimmed seen UIDs to %d entries", len(self._seen_uids)) + except (ValueError, TypeError): + # Fallback: just clear old entries if sort fails + self._seen_uids = set(list(self._seen_uids)[-self._seen_uids_max // 2:]) + + async def connect(self) -> bool: + """Connect to the IMAP server and start polling for new messages.""" + try: + # Test IMAP connection + imap = imaplib.IMAP4_SSL(self._imap_host, self._imap_port, timeout=30) + imap.login(self._address, self._password) + # Mark all existing messages as seen so we only process new ones + imap.select("INBOX") + status, data = imap.uid("search", None, "ALL") + if status == "OK" and data and data[0]: + for uid in data[0].split(): + self._seen_uids.add(uid) + # Keep only the most recent UIDs to prevent unbounded growth + self._trim_seen_uids() + imap.logout() + logger.info("[Email] IMAP connection test passed. %d existing messages skipped.", len(self._seen_uids)) + except Exception as e: + logger.error("[Email] IMAP connection failed: %s", e) + return False + + try: + # Test SMTP connection + smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30) + smtp.starttls(context=ssl.create_default_context()) + smtp.login(self._address, self._password) + smtp.quit() + logger.info("[Email] SMTP connection test passed.") + except Exception as e: + logger.error("[Email] SMTP connection failed: %s", e) + return False + + self._running = True + self._poll_task = asyncio.create_task(self._poll_loop()) + print(f"[Email] Connected as {self._address}") + return True + + async def disconnect(self) -> None: + """Stop polling and disconnect.""" + self._running = False + if self._poll_task: + self._poll_task.cancel() + try: + await self._poll_task + except asyncio.CancelledError: + pass + self._poll_task = None + logger.info("[Email] Disconnected.") + + async def _poll_loop(self) -> None: + """Poll IMAP for new messages at regular intervals.""" + while self._running: + try: + await self._check_inbox() + except asyncio.CancelledError: + break + except Exception as e: + logger.error("[Email] Poll error: %s", e) + await asyncio.sleep(self._poll_interval) + + async def _check_inbox(self) -> None: + """Check INBOX for unseen messages and dispatch them.""" + # Run IMAP operations in a thread to avoid blocking the event loop + loop = asyncio.get_running_loop() + messages = await loop.run_in_executor(None, self._fetch_new_messages) + for msg_data in messages: + await self._dispatch_message(msg_data) + + def _fetch_new_messages(self) -> List[Dict[str, Any]]: + """Fetch new (unseen) messages from IMAP. Runs in executor thread.""" + results = [] + try: + imap = imaplib.IMAP4_SSL(self._imap_host, self._imap_port, timeout=30) + try: + imap.login(self._address, self._password) + imap.select("INBOX") + + status, data = imap.uid("search", None, "UNSEEN") + if status != "OK" or not data or not data[0]: + return results + + for uid in data[0].split(): + if uid in self._seen_uids: + continue + self._seen_uids.add(uid) + # Trim periodically to prevent unbounded memory growth + if len(self._seen_uids) > self._seen_uids_max: + self._trim_seen_uids() + + status, msg_data = imap.uid("fetch", uid, "(RFC822)") + if status != "OK": + continue + + raw_email = msg_data[0][1] + msg = email_lib.message_from_bytes(raw_email) + + sender_raw = msg.get("From", "") + sender_addr = _extract_email_address(sender_raw) + sender_name = _decode_header_value(sender_raw) + # Remove email from name if present + if "<" in sender_name: + sender_name = sender_name.split("<")[0].strip().strip('"') + + subject = _decode_header_value(msg.get("Subject", "(no subject)")) + message_id = msg.get("Message-ID", "") + in_reply_to = msg.get("In-Reply-To", "") + # Skip automated/noreply senders before any processing + msg_headers = dict(msg.items()) + if _is_automated_sender(sender_addr, msg_headers): + logger.debug("[Email] Skipping automated sender: %s", sender_addr) + continue + body = _extract_text_body(msg) + attachments = _extract_attachments(msg, skip_attachments=self._skip_attachments) + + results.append({ + "uid": uid, + "sender_addr": sender_addr, + "sender_name": sender_name, + "subject": subject, + "message_id": message_id, + "in_reply_to": in_reply_to, + "body": body, + "attachments": attachments, + "date": msg.get("Date", ""), + }) + finally: + try: + imap.logout() + except Exception: + pass + except Exception as e: + logger.error("[Email] IMAP fetch error: %s", e) + return results + + async def _dispatch_message(self, msg_data: Dict[str, Any]) -> None: + """Convert a fetched email into a MessageEvent and dispatch it.""" + sender_addr = msg_data["sender_addr"] + + # Skip self-messages + if sender_addr == self._address.lower(): + return + + # Never reply to automated senders + if _is_automated_sender(sender_addr, {}): + logger.debug("[Email] Dropping automated sender at dispatch: %s", sender_addr) + return + + subject = msg_data["subject"] + body = msg_data["body"].strip() + attachments = msg_data["attachments"] + + # Build message text: include subject as context + text = body + if subject and not subject.startswith("Re:"): + text = f"[Subject: {subject}]\n\n{body}" + + # Determine message type and media + media_urls = [] + media_types = [] + msg_type = MessageType.TEXT + + for att in attachments: + media_urls.append(att["path"]) + media_types.append(att["media_type"]) + if att["type"] == "image": + msg_type = MessageType.PHOTO + + # Store thread context for reply threading + self._thread_context[sender_addr] = { + "subject": subject, + "message_id": msg_data["message_id"], + } + + source = self.build_source( + chat_id=sender_addr, + chat_name=msg_data["sender_name"] or sender_addr, + chat_type="dm", + user_id=sender_addr, + user_name=msg_data["sender_name"] or sender_addr, + ) + + event = MessageEvent( + text=text or "(empty email)", + message_type=msg_type, + source=source, + message_id=msg_data["message_id"], + media_urls=media_urls, + media_types=media_types, + reply_to_message_id=msg_data["in_reply_to"] or None, + ) + + logger.info("[Email] New message from %s: %s", sender_addr, subject) + await self.handle_message(event) + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an email reply to the given address.""" + try: + loop = asyncio.get_running_loop() + message_id = await loop.run_in_executor( + None, self._send_email, chat_id, content, reply_to + ) + return SendResult(success=True, message_id=message_id) + except Exception as e: + logger.error("[Email] Send failed to %s: %s", chat_id, e) + return SendResult(success=False, error=str(e)) + + def _send_email( + self, + to_addr: str, + body: str, + reply_to_msg_id: Optional[str] = None, + ) -> str: + """Send an email via SMTP. Runs in executor thread.""" + msg = MIMEMultipart() + msg["From"] = self._address + msg["To"] = to_addr + + # Thread context for reply + ctx = self._thread_context.get(to_addr, {}) + subject = ctx.get("subject", "Hermes Agent") + if not subject.startswith("Re:"): + subject = f"Re: {subject}" + msg["Subject"] = subject + + # Threading headers + original_msg_id = reply_to_msg_id or ctx.get("message_id") + if original_msg_id: + msg["In-Reply-To"] = original_msg_id + msg["References"] = original_msg_id + + msg_id = f"" + msg["Message-ID"] = msg_id + + msg.attach(MIMEText(body, "plain", "utf-8")) + + smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30) + try: + smtp.starttls(context=ssl.create_default_context()) + smtp.login(self._address, self._password) + smtp.send_message(msg) + finally: + try: + smtp.quit() + except Exception: + smtp.close() + + logger.info("[Email] Sent reply to %s (subject: %s)", to_addr, subject) + return msg_id + + async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None: + """Email has no typing indicator — no-op.""" + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + ) -> SendResult: + """Send an image URL as part of an email body.""" + text = caption or "" + text += f"\n\nImage: {image_url}" + return await self.send(chat_id, text.strip(), reply_to) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + ) -> SendResult: + """Send a file as an email attachment.""" + try: + loop = asyncio.get_running_loop() + message_id = await loop.run_in_executor( + None, + self._send_email_with_attachment, + chat_id, + caption or "", + file_path, + file_name, + ) + return SendResult(success=True, message_id=message_id) + except Exception as e: + logger.error("[Email] Send document failed: %s", e) + return SendResult(success=False, error=str(e)) + + def _send_email_with_attachment( + self, + to_addr: str, + body: str, + file_path: str, + file_name: Optional[str] = None, + ) -> str: + """Send an email with a file attachment via SMTP.""" + msg = MIMEMultipart() + msg["From"] = self._address + msg["To"] = to_addr + + ctx = self._thread_context.get(to_addr, {}) + subject = ctx.get("subject", "Hermes Agent") + if not subject.startswith("Re:"): + subject = f"Re: {subject}" + msg["Subject"] = subject + + original_msg_id = ctx.get("message_id") + if original_msg_id: + msg["In-Reply-To"] = original_msg_id + msg["References"] = original_msg_id + + msg_id = f"" + msg["Message-ID"] = msg_id + + if body: + msg.attach(MIMEText(body, "plain", "utf-8")) + + # Attach file + p = Path(file_path) + fname = file_name or p.name + with open(p, "rb") as f: + part = MIMEBase("application", "octet-stream") + part.set_payload(f.read()) + encoders.encode_base64(part) + part.add_header("Content-Disposition", f"attachment; filename={fname}") + msg.attach(part) + + smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30) + try: + smtp.starttls(context=ssl.create_default_context()) + smtp.login(self._address, self._password) + smtp.send_message(msg) + finally: + try: + smtp.quit() + except Exception: + smtp.close() + + return msg_id + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Return basic info about the email chat.""" + ctx = self._thread_context.get(chat_id, {}) + return { + "name": chat_id, + "type": "dm", + "chat_id": chat_id, + "subject": ctx.get("subject", ""), + } diff --git a/mindcli/_vendor/gateway/platforms/feishu.py b/mindcli/_vendor/gateway/platforms/feishu.py new file mode 100644 index 0000000..fdfdd78 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/feishu.py @@ -0,0 +1,3950 @@ +""" +Feishu/Lark platform adapter. + +Supports: +- WebSocket long connection and Webhook transport +- Direct-message and group @mention-gated text receive/send +- Inbound image/file/audio/media caching +- Gateway allowlist integration via FEISHU_ALLOWED_USERS +- Persistent dedup state across restarts +- Per-chat serial message processing (matches openclaw createChatQueue) +- Persistent ACK emoji reaction on inbound messages +- Reaction events routed as synthetic text events (matches openclaw) +- Interactive card button-click events routed as synthetic COMMAND events +- Webhook anomaly tracking (matches openclaw createWebhookAnomalyTracker) +- Verification token validation as second auth layer (matches openclaw) +""" + +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import itertools +import json +import logging +import mimetypes +import os +import re +import threading +import time +import uuid +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Dict, List, Optional +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +# aiohttp/websockets are independent optional deps — import outside lark_oapi +# so they remain available for tests and webhook mode even if lark_oapi is missing. +try: + import aiohttp + from aiohttp import web +except ImportError: + aiohttp = None # type: ignore[assignment] + web = None # type: ignore[assignment] + +try: + import websockets +except ImportError: + websockets = None # type: ignore[assignment] + +try: + import lark_oapi as lark + from lark_oapi.api.application.v6 import GetApplicationRequest + from lark_oapi.api.im.v1 import ( + CreateFileRequest, + CreateFileRequestBody, + CreateImageRequest, + CreateImageRequestBody, + CreateMessageRequest, + CreateMessageRequestBody, + GetChatRequest, + GetMessageRequest, + GetMessageResourceRequest, + P2ImMessageMessageReadV1, + ReplyMessageRequest, + ReplyMessageRequestBody, + UpdateMessageRequest, + UpdateMessageRequestBody, + ) + from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN + from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse + from lark_oapi.event.dispatcher_handler import EventDispatcherHandler + from lark_oapi.ws import Client as FeishuWSClient + + FEISHU_AVAILABLE = True +except ImportError: + FEISHU_AVAILABLE = False + lark = None # type: ignore[assignment] + P2CardActionTriggerResponse = None # type: ignore[assignment] + EventDispatcherHandler = None # type: ignore[assignment] + FeishuWSClient = None # type: ignore[assignment] + FEISHU_DOMAIN = None # type: ignore[assignment] + LARK_DOMAIN = None # type: ignore[assignment] + +FEISHU_WEBSOCKET_AVAILABLE = websockets is not None +FEISHU_WEBHOOK_AVAILABLE = aiohttp is not None + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, + SUPPORTED_DOCUMENT_TYPES, + cache_document_from_bytes, + cache_image_from_url, + cache_audio_from_bytes, + cache_image_from_bytes, +) +from gateway.status import acquire_scoped_lock, release_scoped_lock +from hermes_constants import get_hermes_home + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Regex patterns +# --------------------------------------------------------------------------- + +_MARKDOWN_HINT_RE = re.compile( + r"(^#{1,6}\s)|(^\s*[-*]\s)|(^\s*\d+\.\s)|(^\s*---+\s*$)|(```)|(`[^`\n]+`)|(\*\*[^*\n].+?\*\*)|(~~[^~\n].+?~~)|(.+?)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)]+\))|(^>\s)", + re.MULTILINE, +) +_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") +_MENTION_RE = re.compile(r"@_user_\d+") +_MULTISPACE_RE = re.compile(r"[ \t]{2,}") +_POST_CONTENT_INVALID_RE = re.compile(r"content format of the post type is incorrect", re.IGNORECASE) +# --------------------------------------------------------------------------- +# Media type sets and upload constants +# --------------------------------------------------------------------------- + +_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"} +_AUDIO_EXTENSIONS = {".ogg", ".mp3", ".wav", ".m4a", ".aac", ".flac", ".opus", ".webm"} +_VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".3gp"} +_DOCUMENT_MIME_TO_EXT = {mime: ext for ext, mime in SUPPORTED_DOCUMENT_TYPES.items()} +_FEISHU_IMAGE_UPLOAD_TYPE = "message" +_FEISHU_FILE_UPLOAD_TYPE = "stream" +_FEISHU_OPUS_UPLOAD_EXTENSIONS = {".ogg", ".opus"} +_FEISHU_MEDIA_UPLOAD_EXTENSIONS = {".mp4", ".mov", ".avi", ".m4v"} +_FEISHU_DOC_UPLOAD_TYPES = { + ".pdf": "pdf", + ".doc": "doc", + ".docx": "doc", + ".xls": "xls", + ".xlsx": "xls", + ".ppt": "ppt", + ".pptx": "ppt", +} +# --------------------------------------------------------------------------- +# Connection, retry and batching tuning +# --------------------------------------------------------------------------- + +_MAX_TEXT_INJECT_BYTES = 100 * 1024 +_FEISHU_CONNECT_ATTEMPTS = 3 +_FEISHU_SEND_ATTEMPTS = 3 +_FEISHU_APP_LOCK_SCOPE = "feishu-app-id" +_DEFAULT_TEXT_BATCH_DELAY_SECONDS = 0.6 +_DEFAULT_TEXT_BATCH_MAX_MESSAGES = 8 +_DEFAULT_TEXT_BATCH_MAX_CHARS = 4000 +_DEFAULT_MEDIA_BATCH_DELAY_SECONDS = 0.8 +_DEFAULT_DEDUP_CACHE_SIZE = 2048 +_DEFAULT_WEBHOOK_HOST = "127.0.0.1" +_DEFAULT_WEBHOOK_PORT = 8765 +_DEFAULT_WEBHOOK_PATH = "/feishu/webhook" +# --------------------------------------------------------------------------- +# TTL, rate-limit and webhook security constants +# --------------------------------------------------------------------------- + +_FEISHU_DEDUP_TTL_SECONDS = 24 * 60 * 60 # 24 hours — matches openclaw +_FEISHU_SENDER_NAME_TTL_SECONDS = 10 * 60 # 10 minutes sender-name cache +_FEISHU_WEBHOOK_MAX_BODY_BYTES = 1 * 1024 * 1024 # 1 MB body limit +_FEISHU_WEBHOOK_RATE_WINDOW_SECONDS = 60 # sliding window for rate limiter +_FEISHU_WEBHOOK_RATE_LIMIT_MAX = 120 # max requests per window per IP — matches openclaw +_FEISHU_WEBHOOK_RATE_MAX_KEYS = 4096 # max tracked keys (prevents unbounded growth) +_FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS = 30 # max seconds to read request body +_FEISHU_WEBHOOK_ANOMALY_THRESHOLD = 25 # consecutive error responses before WARNING log +_FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS = 6 * 60 * 60 # anomaly tracker TTL (6 hours) — matches openclaw +_FEISHU_CARD_ACTION_DEDUP_TTL_SECONDS = 15 * 60 # card action token dedup window (15 min) +_FEISHU_BOT_MSG_TRACK_SIZE = 512 # LRU size for tracking sent message IDs +_FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003}) # reply target withdrawn/missing → create fallback +_FEISHU_ACK_EMOJI = "OK" + +# QR onboarding constants +_ONBOARD_ACCOUNTS_URLS = { + "feishu": "https://accounts.feishu.cn", + "lark": "https://accounts.larksuite.com", +} +_ONBOARD_OPEN_URLS = { + "feishu": "https://open.feishu.cn", + "lark": "https://open.larksuite.com", +} +_REGISTRATION_PATH = "/oauth/v1/app/registration" +_ONBOARD_REQUEST_TIMEOUT_S = 10 + +# --------------------------------------------------------------------------- +# Fallback display strings +# --------------------------------------------------------------------------- + +FALLBACK_POST_TEXT = "[Rich text message]" +FALLBACK_FORWARD_TEXT = "[Merged forward message]" +FALLBACK_SHARE_CHAT_TEXT = "[Shared chat]" +FALLBACK_INTERACTIVE_TEXT = "[Interactive message]" +FALLBACK_IMAGE_TEXT = "[Image]" +FALLBACK_ATTACHMENT_TEXT = "[Attachment]" +# --------------------------------------------------------------------------- +# Post/card parsing helpers +# --------------------------------------------------------------------------- + +_PREFERRED_LOCALES = ("zh_cn", "en_us") +_MARKDOWN_SPECIAL_CHARS_RE = re.compile(r"([\\`*_{}\[\]()#+\-!|>~])") +_MENTION_PLACEHOLDER_RE = re.compile(r"@_user_\d+") +_WHITESPACE_RE = re.compile(r"\s+") +_SUPPORTED_CARD_TEXT_KEYS = ( + "title", + "text", + "content", + "label", + "value", + "name", + "summary", + "subtitle", + "description", + "placeholder", + "hint", +) +_SKIP_TEXT_KEYS = { + "tag", + "type", + "msg_type", + "message_type", + "chat_id", + "open_chat_id", + "share_chat_id", + "file_key", + "image_key", + "user_id", + "open_id", + "union_id", + "url", + "href", + "link", + "token", + "template", + "locale", +} + + +@dataclass(frozen=True) +class FeishuPostMediaRef: + file_key: str + file_name: str = "" + resource_type: str = "file" + + +@dataclass(frozen=True) +class FeishuPostParseResult: + text_content: str + image_keys: List[str] = field(default_factory=list) + media_refs: List[FeishuPostMediaRef] = field(default_factory=list) + mentioned_ids: List[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class FeishuNormalizedMessage: + raw_type: str + text_content: str + preferred_message_type: str = "text" + image_keys: List[str] = field(default_factory=list) + media_refs: List[FeishuPostMediaRef] = field(default_factory=list) + mentioned_ids: List[str] = field(default_factory=list) + relation_kind: str = "plain" + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class FeishuAdapterSettings: + app_id: str + app_secret: str + domain_name: str + connection_mode: str + encrypt_key: str + verification_token: str + group_policy: str + allowed_group_users: frozenset[str] + bot_open_id: str + bot_user_id: str + bot_name: str + dedup_cache_size: int + text_batch_delay_seconds: float + text_batch_split_delay_seconds: float + text_batch_max_messages: int + text_batch_max_chars: int + media_batch_delay_seconds: float + webhook_host: str + webhook_port: int + webhook_path: str + ws_reconnect_nonce: int = 30 + ws_reconnect_interval: int = 120 + ws_ping_interval: Optional[int] = None + ws_ping_timeout: Optional[int] = None + admins: frozenset[str] = frozenset() + default_group_policy: str = "" + group_rules: Dict[str, FeishuGroupRule] = field(default_factory=dict) + + +@dataclass +class FeishuGroupRule: + """Per-group policy rule for controlling which users may interact with the bot.""" + + policy: str # "open" | "allowlist" | "blacklist" | "admin_only" | "disabled" + allowlist: set[str] = field(default_factory=set) + blacklist: set[str] = field(default_factory=set) + + +@dataclass +class FeishuBatchState: + events: Dict[str, MessageEvent] = field(default_factory=dict) + tasks: Dict[str, asyncio.Task] = field(default_factory=dict) + counts: Dict[str, int] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Markdown rendering helpers +# --------------------------------------------------------------------------- + + +def _escape_markdown_text(text: str) -> str: + return _MARKDOWN_SPECIAL_CHARS_RE.sub(r"\\\1", text) + + +def _to_boolean(value: Any) -> bool: + return value is True or value == 1 or value == "true" + + +def _is_style_enabled(style: Dict[str, Any] | None, key: str) -> bool: + if not style: + return False + return _to_boolean(style.get(key)) + + +def _wrap_inline_code(text: str) -> str: + max_run = max([0, *[len(run) for run in re.findall(r"`+", text)]]) + fence = "`" * (max_run + 1) + body = f" {text} " if text.startswith("`") or text.endswith("`") else text + return f"{fence}{body}{fence}" + + +def _sanitize_fence_language(language: str) -> str: + return language.strip().replace("\n", " ").replace("\r", " ") + + +def _render_text_element(element: Dict[str, Any]) -> str: + text = str(element.get("text", "") or "") + style = element.get("style") + style_dict = style if isinstance(style, dict) else None + + if _is_style_enabled(style_dict, "code"): + return _wrap_inline_code(text) + + rendered = _escape_markdown_text(text) + if not rendered: + return "" + if _is_style_enabled(style_dict, "bold"): + rendered = f"**{rendered}**" + if _is_style_enabled(style_dict, "italic"): + rendered = f"*{rendered}*" + if _is_style_enabled(style_dict, "underline"): + rendered = f"{rendered}" + if _is_style_enabled(style_dict, "strikethrough"): + rendered = f"~~{rendered}~~" + return rendered + + +def _render_code_block_element(element: Dict[str, Any]) -> str: + language = _sanitize_fence_language( + str(element.get("language", "") or "") or str(element.get("lang", "") or "") + ) + code = ( + str(element.get("text", "") or "") or str(element.get("content", "") or "") + ).replace("\r\n", "\n") + trailing_newline = "" if code.endswith("\n") else "\n" + return f"```{language}\n{code}{trailing_newline}```" + + +def _strip_markdown_to_plain_text(text: str) -> str: + """Strip markdown formatting to plain text for Feishu text fallbacks. + + Delegates common markdown stripping to the shared helper and adds + Feishu-specific patterns (blockquotes, strikethrough, underline tags, + horizontal rules, \\r\\n normalisation). + """ + from gateway.platforms.helpers import strip_markdown + plain = text.replace("\r\n", "\n") + plain = _MARKDOWN_LINK_RE.sub(lambda m: f"{m.group(1)} ({m.group(2).strip()})", plain) + plain = re.sub(r"^>\s?", "", plain, flags=re.MULTILINE) + plain = re.sub(r"^\s*---+\s*$", "---", plain, flags=re.MULTILINE) + plain = re.sub(r"~~([^~\n]+)~~", r"\1", plain) + plain = re.sub(r"([\s\S]*?)", r"\1", plain) + plain = strip_markdown(plain) + return plain + + +def _coerce_int(value: Any, default: Optional[int] = None, min_value: int = 0) -> Optional[int]: + """Coerce value to int with optional default and minimum constraint.""" + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed >= min_value else default + + +def _coerce_required_int(value: Any, default: int, min_value: int = 0) -> int: + parsed = _coerce_int(value, default=default, min_value=min_value) + return default if parsed is None else parsed + + +# --------------------------------------------------------------------------- +# Post payload builders and parsers +# --------------------------------------------------------------------------- + + +def _build_markdown_post_payload(content: str) -> str: + return json.dumps( + { + "zh_cn": { + "content": [ + [ + { + "tag": "md", + "text": content, + } + ] + ], + } + }, + ensure_ascii=False, + ) + + +def parse_feishu_post_payload(payload: Any) -> FeishuPostParseResult: + resolved = _resolve_post_payload(payload) + if not resolved: + return FeishuPostParseResult(text_content=FALLBACK_POST_TEXT) + + image_keys: List[str] = [] + media_refs: List[FeishuPostMediaRef] = [] + mentioned_ids: List[str] = [] + parts: List[str] = [] + + title = _normalize_feishu_text(str(resolved.get("title", "")).strip()) + if title: + parts.append(title) + + for row in resolved.get("content", []) or []: + if not isinstance(row, list): + continue + row_text = _normalize_feishu_text( + "".join(_render_post_element(item, image_keys, media_refs, mentioned_ids) for item in row) + ) + if row_text: + parts.append(row_text) + + return FeishuPostParseResult( + text_content="\n".join(parts).strip() or FALLBACK_POST_TEXT, + image_keys=image_keys, + media_refs=media_refs, + mentioned_ids=mentioned_ids, + ) + + +def _resolve_post_payload(payload: Any) -> Dict[str, Any]: + direct = _to_post_payload(payload) + if direct: + return direct + if not isinstance(payload, dict): + return {} + + wrapped = payload.get("post") + wrapped_direct = _resolve_locale_payload(wrapped) + if wrapped_direct: + return wrapped_direct + return _resolve_locale_payload(payload) + + +def _resolve_locale_payload(payload: Any) -> Dict[str, Any]: + direct = _to_post_payload(payload) + if direct: + return direct + if not isinstance(payload, dict): + return {} + + for key in _PREFERRED_LOCALES: + candidate = _to_post_payload(payload.get(key)) + if candidate: + return candidate + for value in payload.values(): + candidate = _to_post_payload(value) + if candidate: + return candidate + return {} + + +def _to_post_payload(candidate: Any) -> Dict[str, Any]: + if not isinstance(candidate, dict): + return {} + content = candidate.get("content") + if not isinstance(content, list): + return {} + return { + "title": str(candidate.get("title", "") or ""), + "content": content, + } + + +def _render_post_element( + element: Any, + image_keys: List[str], + media_refs: List[FeishuPostMediaRef], + mentioned_ids: List[str], +) -> str: + if isinstance(element, str): + return element + if not isinstance(element, dict): + return "" + + tag = str(element.get("tag", "")).strip().lower() + if tag == "text": + return _render_text_element(element) + if tag == "a": + href = str(element.get("href", "")).strip() + label = str(element.get("text", href) or "").strip() + if not label: + return "" + escaped_label = _escape_markdown_text(label) + return f"[{escaped_label}]({href})" if href else escaped_label + if tag == "at": + mentioned_id = ( + str(element.get("open_id", "")).strip() + or str(element.get("user_id", "")).strip() + ) + if mentioned_id and mentioned_id not in mentioned_ids: + mentioned_ids.append(mentioned_id) + display_name = ( + str(element.get("user_name", "")).strip() + or str(element.get("name", "")).strip() + or str(element.get("text", "")).strip() + or mentioned_id + ) + return f"@{_escape_markdown_text(display_name)}" if display_name else "@" + if tag in {"img", "image"}: + image_key = str(element.get("image_key", "")).strip() + if image_key and image_key not in image_keys: + image_keys.append(image_key) + alt = str(element.get("text", "")).strip() or str(element.get("alt", "")).strip() + return f"[Image: {alt}]" if alt else "[Image]" + if tag in {"media", "file", "audio", "video"}: + file_key = str(element.get("file_key", "")).strip() + file_name = ( + str(element.get("file_name", "")).strip() + or str(element.get("title", "")).strip() + or str(element.get("text", "")).strip() + ) + if file_key: + media_refs.append( + FeishuPostMediaRef( + file_key=file_key, + file_name=file_name, + resource_type=tag if tag in {"audio", "video"} else "file", + ) + ) + return f"[Attachment: {file_name}]" if file_name else "[Attachment]" + if tag in {"emotion", "emoji"}: + label = str(element.get("text", "")).strip() or str(element.get("emoji_type", "")).strip() + return f":{_escape_markdown_text(label)}:" if label else "[Emoji]" + if tag == "br": + return "\n" + if tag in {"hr", "divider"}: + return "\n\n---\n\n" + if tag == "code": + code = str(element.get("text", "") or "") or str(element.get("content", "") or "") + return _wrap_inline_code(code) if code else "" + if tag in {"code_block", "pre"}: + return _render_code_block_element(element) + + nested_parts: List[str] = [] + for key in ("text", "title", "content", "children", "elements"): + value = element.get(key) + extracted = _render_nested_post(value, image_keys, media_refs, mentioned_ids) + if extracted: + nested_parts.append(extracted) + return " ".join(part for part in nested_parts if part) + + +def _render_nested_post( + value: Any, + image_keys: List[str], + media_refs: List[FeishuPostMediaRef], + mentioned_ids: List[str], +) -> str: + if isinstance(value, str): + return _escape_markdown_text(value) + if isinstance(value, list): + return " ".join( + part + for item in value + for part in [_render_nested_post(item, image_keys, media_refs, mentioned_ids)] + if part + ) + if isinstance(value, dict): + direct = _render_post_element(value, image_keys, media_refs, mentioned_ids) + if direct: + return direct + return " ".join( + part + for item in value.values() + for part in [_render_nested_post(item, image_keys, media_refs, mentioned_ids)] + if part + ) + return "" + + +# --------------------------------------------------------------------------- +# Message normalization +# --------------------------------------------------------------------------- + + +def normalize_feishu_message(*, message_type: str, raw_content: str) -> FeishuNormalizedMessage: + normalized_type = str(message_type or "").strip().lower() + payload = _load_feishu_payload(raw_content) + + if normalized_type == "text": + return FeishuNormalizedMessage( + raw_type=normalized_type, + text_content=_normalize_feishu_text(str(payload.get("text", "") or "")), + ) + if normalized_type == "post": + parsed_post = parse_feishu_post_payload(payload) + return FeishuNormalizedMessage( + raw_type=normalized_type, + text_content=parsed_post.text_content, + image_keys=list(parsed_post.image_keys), + media_refs=list(parsed_post.media_refs), + mentioned_ids=list(parsed_post.mentioned_ids), + relation_kind="post", + ) + if normalized_type == "image": + image_key = str(payload.get("image_key", "") or "").strip() + alt_text = _normalize_feishu_text( + str(payload.get("text", "") or "") + or str(payload.get("alt", "") or "") + or FALLBACK_IMAGE_TEXT + ) + return FeishuNormalizedMessage( + raw_type=normalized_type, + text_content=alt_text if alt_text != FALLBACK_IMAGE_TEXT else "", + preferred_message_type="photo", + image_keys=[image_key] if image_key else [], + relation_kind="image", + ) + if normalized_type in {"file", "audio", "media"}: + media_ref = _build_media_ref_from_payload(payload, resource_type=normalized_type) + placeholder = _attachment_placeholder(media_ref.file_name) + return FeishuNormalizedMessage( + raw_type=normalized_type, + text_content="", + preferred_message_type="audio" if normalized_type == "audio" else "document", + media_refs=[media_ref] if media_ref.file_key else [], + relation_kind=normalized_type, + metadata={"placeholder_text": placeholder}, + ) + if normalized_type == "merge_forward": + return _normalize_merge_forward_message(payload) + if normalized_type == "share_chat": + return _normalize_share_chat_message(payload) + if normalized_type in {"interactive", "card"}: + return _normalize_interactive_message(normalized_type, payload) + + return FeishuNormalizedMessage(raw_type=normalized_type, text_content="") + + +def _load_feishu_payload(raw_content: str) -> Dict[str, Any]: + try: + parsed = json.loads(raw_content) if raw_content else {} + except json.JSONDecodeError: + return {"text": raw_content} + return parsed if isinstance(parsed, dict) else {"content": parsed} + + +def _normalize_merge_forward_message(payload: Dict[str, Any]) -> FeishuNormalizedMessage: + title = _first_non_empty_text( + payload.get("title"), + payload.get("summary"), + payload.get("preview"), + _find_first_text(payload, keys=("title", "summary", "preview", "description")), + ) + entries = _collect_forward_entries(payload) + lines: List[str] = [] + if title: + lines.append(title) + lines.extend(entries[:8]) + text_content = "\n".join(lines).strip() or FALLBACK_FORWARD_TEXT + return FeishuNormalizedMessage( + raw_type="merge_forward", + text_content=text_content, + relation_kind="merge_forward", + metadata={"entry_count": len(entries), "title": title}, + ) + + +def _normalize_share_chat_message(payload: Dict[str, Any]) -> FeishuNormalizedMessage: + chat_name = _first_non_empty_text( + payload.get("chat_name"), + payload.get("name"), + payload.get("title"), + _find_first_text(payload, keys=("chat_name", "name", "title")), + ) + share_id = _first_non_empty_text( + payload.get("chat_id"), + payload.get("open_chat_id"), + payload.get("share_chat_id"), + ) + lines = [] + if chat_name: + lines.append(f"Shared chat: {chat_name}") + else: + lines.append(FALLBACK_SHARE_CHAT_TEXT) + if share_id: + lines.append(f"Chat ID: {share_id}") + text_content = "\n".join(lines) + return FeishuNormalizedMessage( + raw_type="share_chat", + text_content=text_content, + relation_kind="share_chat", + metadata={"chat_id": share_id, "chat_name": chat_name}, + ) + + +def _normalize_interactive_message(message_type: str, payload: Dict[str, Any]) -> FeishuNormalizedMessage: + card_payload = payload.get("card") if isinstance(payload.get("card"), dict) else payload + title = _first_non_empty_text( + _find_header_title(card_payload), + payload.get("title"), + _find_first_text(card_payload, keys=("title", "summary", "subtitle")), + ) + body_lines = _collect_card_lines(card_payload) + actions = _collect_action_labels(card_payload) + + lines: List[str] = [] + if title: + lines.append(title) + for line in body_lines: + if line != title: + lines.append(line) + if actions: + lines.append(f"Actions: {', '.join(actions)}") + + text_content = "\n".join(lines[:12]).strip() or FALLBACK_INTERACTIVE_TEXT + return FeishuNormalizedMessage( + raw_type=message_type, + text_content=text_content, + relation_kind="interactive", + metadata={"title": title, "actions": actions}, + ) + + +# --------------------------------------------------------------------------- +# Content extraction utilities (card / forward / text walking) +# --------------------------------------------------------------------------- + + +def _collect_forward_entries(payload: Dict[str, Any]) -> List[str]: + candidates: List[Any] = [] + for key in ("messages", "items", "message_list", "records", "content"): + value = payload.get(key) + if isinstance(value, list): + candidates.extend(value) + entries: List[str] = [] + for item in candidates: + if not isinstance(item, dict): + text = _normalize_feishu_text(str(item or "")) + if text: + entries.append(f"- {text}") + continue + sender = _first_non_empty_text( + item.get("sender_name"), + item.get("user_name"), + item.get("sender"), + item.get("name"), + ) + nested_type = str(item.get("message_type", "") or item.get("msg_type", "")).strip().lower() + if nested_type == "post": + body = parse_feishu_post_payload(item.get("content") or item).text_content + else: + body = _first_non_empty_text( + item.get("text"), + item.get("summary"), + item.get("preview"), + item.get("content"), + _find_first_text(item, keys=("text", "content", "summary", "preview", "title")), + ) + body = _normalize_feishu_text(body) + if sender and body: + entries.append(f"- {sender}: {body}") + elif body: + entries.append(f"- {body}") + return _unique_lines(entries) + + +def _collect_card_lines(payload: Any) -> List[str]: + lines = _collect_text_segments(payload, in_rich_block=False) + normalized = [_normalize_feishu_text(line) for line in lines] + return _unique_lines([line for line in normalized if line]) + + +def _collect_action_labels(payload: Any) -> List[str]: + labels: List[str] = [] + for item in _walk_nodes(payload): + if not isinstance(item, dict): + continue + tag = str(item.get("tag", "") or item.get("type", "")).strip().lower() + if tag not in {"button", "select_static", "overflow", "date_picker", "picker"}: + continue + label = _first_non_empty_text( + item.get("text"), + item.get("name"), + item.get("value"), + _find_first_text(item, keys=("text", "content", "name", "value")), + ) + if label: + labels.append(label) + return _unique_lines(labels) + + +def _collect_text_segments(value: Any, *, in_rich_block: bool) -> List[str]: + if isinstance(value, str): + return [_normalize_feishu_text(value)] if in_rich_block else [] + if isinstance(value, list): + segments: List[str] = [] + for item in value: + segments.extend(_collect_text_segments(item, in_rich_block=in_rich_block)) + return segments + if not isinstance(value, dict): + return [] + + tag = str(value.get("tag", "") or value.get("type", "")).strip().lower() + next_in_rich_block = in_rich_block or tag in { + "plain_text", + "lark_md", + "markdown", + "note", + "div", + "column_set", + "column", + "action", + "button", + "select_static", + "date_picker", + } + + segments: List[str] = [] + for key in _SUPPORTED_CARD_TEXT_KEYS: + item = value.get(key) + if isinstance(item, str) and next_in_rich_block: + normalized = _normalize_feishu_text(item) + if normalized: + segments.append(normalized) + + for key, item in value.items(): + if key in _SKIP_TEXT_KEYS: + continue + segments.extend(_collect_text_segments(item, in_rich_block=next_in_rich_block)) + return segments + + +def _build_media_ref_from_payload(payload: Dict[str, Any], *, resource_type: str) -> FeishuPostMediaRef: + file_key = str(payload.get("file_key", "") or "").strip() + file_name = _first_non_empty_text( + payload.get("file_name"), + payload.get("title"), + payload.get("text"), + ) + effective_type = resource_type if resource_type in {"audio", "video"} else "file" + return FeishuPostMediaRef(file_key=file_key, file_name=file_name, resource_type=effective_type) + + +def _attachment_placeholder(file_name: str) -> str: + normalized_name = _normalize_feishu_text(file_name) + return f"[Attachment: {normalized_name}]" if normalized_name else FALLBACK_ATTACHMENT_TEXT + + +def _find_header_title(payload: Any) -> str: + if not isinstance(payload, dict): + return "" + header = payload.get("header") + if not isinstance(header, dict): + return "" + title = header.get("title") + if isinstance(title, dict): + return _first_non_empty_text(title.get("content"), title.get("text"), title.get("name")) + return _normalize_feishu_text(str(title or "")) + + +def _find_first_text(payload: Any, *, keys: tuple[str, ...]) -> str: + for node in _walk_nodes(payload): + if not isinstance(node, dict): + continue + for key in keys: + value = node.get(key) + if isinstance(value, str): + normalized = _normalize_feishu_text(value) + if normalized: + return normalized + return "" + + +def _walk_nodes(value: Any): + if isinstance(value, dict): + yield value + for item in value.values(): + yield from _walk_nodes(item) + elif isinstance(value, list): + for item in value: + yield from _walk_nodes(item) + + +def _first_non_empty_text(*values: Any) -> str: + for value in values: + if isinstance(value, str): + normalized = _normalize_feishu_text(value) + if normalized: + return normalized + elif value is not None and not isinstance(value, (dict, list)): + normalized = _normalize_feishu_text(str(value)) + if normalized: + return normalized + return "" + + +# --------------------------------------------------------------------------- +# General text utilities +# --------------------------------------------------------------------------- + + +def _normalize_feishu_text(text: str) -> str: + cleaned = _MENTION_PLACEHOLDER_RE.sub(" ", text or "") + cleaned = cleaned.replace("\r\n", "\n").replace("\r", "\n") + cleaned = "\n".join(_WHITESPACE_RE.sub(" ", line).strip() for line in cleaned.split("\n")) + cleaned = "\n".join(line for line in cleaned.split("\n") if line) + cleaned = _MULTISPACE_RE.sub(" ", cleaned) + return cleaned.strip() + + +def _unique_lines(lines: List[str]) -> List[str]: + seen: set[str] = set() + unique: List[str] = [] + for line in lines: + if not line or line in seen: + continue + seen.add(line) + unique.append(line) + return unique + + +def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None: + """Run the official Lark WS client in its own thread-local event loop.""" + import lark_oapi.ws.client as ws_client_module + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + ws_client_module.loop = loop + adapter._ws_thread_loop = loop + + original_connect = ws_client_module.websockets.connect + original_configure = getattr(ws_client, "_configure", None) + + def _apply_runtime_ws_overrides() -> None: + try: + setattr(ws_client, "_reconnect_nonce", adapter._ws_reconnect_nonce) + setattr(ws_client, "_reconnect_interval", adapter._ws_reconnect_interval) + if adapter._ws_ping_interval is not None: + setattr(ws_client, "_ping_interval", adapter._ws_ping_interval) + except Exception: + logger.debug("[Feishu] Failed to apply websocket runtime overrides", exc_info=True) + + async def _connect_with_overrides(*args: Any, **kwargs: Any) -> Any: + if adapter._ws_ping_interval is not None and "ping_interval" not in kwargs: + kwargs["ping_interval"] = adapter._ws_ping_interval + if adapter._ws_ping_timeout is not None and "ping_timeout" not in kwargs: + kwargs["ping_timeout"] = adapter._ws_ping_timeout + return await original_connect(*args, **kwargs) + + def _configure_with_overrides(conf: Any) -> Any: + if original_configure is None: + raise RuntimeError("Feishu _configure_with_overrides called but original_configure is None") + result = original_configure(conf) + _apply_runtime_ws_overrides() + return result + + ws_client_module.websockets.connect = _connect_with_overrides + if original_configure is not None: + setattr(ws_client, "_configure", _configure_with_overrides) + _apply_runtime_ws_overrides() + try: + ws_client.start() + except Exception: + pass + finally: + ws_client_module.websockets.connect = original_connect + if original_configure is not None: + setattr(ws_client, "_configure", original_configure) + pending = [t for t in asyncio.all_tasks(loop) if not t.done()] + for task in pending: + task.cancel() + if pending: + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + try: + loop.stop() + except Exception: + pass + try: + loop.close() + except Exception: + pass + adapter._ws_thread_loop = None + + +def check_feishu_requirements() -> bool: + """Check if Feishu/Lark dependencies are available.""" + return FEISHU_AVAILABLE + + +class FeishuAdapter(BasePlatformAdapter): + """Feishu/Lark bot adapter.""" + + MAX_MESSAGE_LENGTH = 8000 + # Threshold for detecting Feishu client-side message splits. + # When a chunk is near the ~4096-char practical limit, a continuation + # is almost certain. + _SPLIT_THRESHOLD = 4000 + + # ========================================================================= + # Lifecycle — init / settings / connect / disconnect + # ========================================================================= + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.FEISHU) + + self._settings = self._load_settings(config.extra or {}) + self._apply_settings(self._settings) + self._client: Optional[Any] = None + self._ws_client: Optional[Any] = None + self._ws_future: Optional[asyncio.Future] = None + self._ws_thread_loop: Optional[asyncio.AbstractEventLoop] = None + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._webhook_runner: Optional[Any] = None + self._webhook_site: Optional[Any] = None + self._event_handler: Optional[Any] = None + self._seen_message_ids: Dict[str, float] = {} # message_id → seen_at (time.time()) + self._seen_message_order: List[str] = [] + self._dedup_state_path = get_hermes_home() / "feishu_seen_message_ids.json" + self._dedup_lock = threading.Lock() + self._sender_name_cache: Dict[str, tuple[str, float]] = {} # sender_id → (name, expire_at) + self._webhook_rate_counts: Dict[str, tuple[int, float]] = {} # rate_key → (count, window_start) + self._webhook_anomaly_counts: Dict[str, tuple[int, str, float]] = {} # ip → (count, last_status, first_seen) + self._card_action_tokens: Dict[str, float] = {} # token → first_seen_time + self._chat_locks: Dict[str, asyncio.Lock] = {} # chat_id → lock (per-chat serial processing) + self._sent_message_ids_to_chat: Dict[str, str] = {} # message_id → chat_id (for reaction routing) + self._sent_message_id_order: List[str] = [] # LRU order for _sent_message_ids_to_chat + self._chat_info_cache: Dict[str, Dict[str, Any]] = {} + self._message_text_cache: Dict[str, Optional[str]] = {} + self._app_lock_identity: Optional[str] = None + self._text_batch_state = FeishuBatchState() + self._pending_text_batches = self._text_batch_state.events + self._pending_text_batch_tasks = self._text_batch_state.tasks + self._pending_text_batch_counts = self._text_batch_state.counts + self._media_batch_state = FeishuBatchState() + self._pending_media_batches = self._media_batch_state.events + self._pending_media_batch_tasks = self._media_batch_state.tasks + # Exec approval button state (approval_id → {session_key, message_id, chat_id}) + self._approval_state: Dict[int, Dict[str, str]] = {} + self._approval_counter = itertools.count(1) + self._load_seen_message_ids() + + @staticmethod + def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: + # Parse per-group rules from config + raw_group_rules = extra.get("group_rules", {}) + group_rules: Dict[str, FeishuGroupRule] = {} + if isinstance(raw_group_rules, dict): + for chat_id, rule_cfg in raw_group_rules.items(): + if not isinstance(rule_cfg, dict): + continue + group_rules[str(chat_id)] = FeishuGroupRule( + policy=str(rule_cfg.get("policy", "open")).strip().lower(), + allowlist=set(str(u).strip() for u in rule_cfg.get("allowlist", []) if str(u).strip()), + blacklist=set(str(u).strip() for u in rule_cfg.get("blacklist", []) if str(u).strip()), + ) + + # Bot-level admins + raw_admins = extra.get("admins", []) + admins = frozenset(str(u).strip() for u in raw_admins if str(u).strip()) + + # Default group policy (for groups not in group_rules) + default_group_policy = str(extra.get("default_group_policy", "")).strip().lower() + + return FeishuAdapterSettings( + app_id=str(extra.get("app_id") or os.getenv("FEISHU_APP_ID", "")).strip(), + app_secret=str(extra.get("app_secret") or os.getenv("FEISHU_APP_SECRET", "")).strip(), + domain_name=str(extra.get("domain") or os.getenv("FEISHU_DOMAIN", "feishu")).strip().lower(), + connection_mode=str( + extra.get("connection_mode") or os.getenv("FEISHU_CONNECTION_MODE", "websocket") + ).strip().lower(), + encrypt_key=os.getenv("FEISHU_ENCRYPT_KEY", "").strip(), + verification_token=os.getenv("FEISHU_VERIFICATION_TOKEN", "").strip(), + group_policy=os.getenv("FEISHU_GROUP_POLICY", "allowlist").strip().lower(), + allowed_group_users=frozenset( + item.strip() + for item in os.getenv("FEISHU_ALLOWED_USERS", "").split(",") + if item.strip() + ), + bot_open_id=os.getenv("FEISHU_BOT_OPEN_ID", "").strip(), + bot_user_id=os.getenv("FEISHU_BOT_USER_ID", "").strip(), + bot_name=os.getenv("FEISHU_BOT_NAME", "").strip(), + dedup_cache_size=max( + 32, + int(os.getenv("HERMES_FEISHU_DEDUP_CACHE_SIZE", str(_DEFAULT_DEDUP_CACHE_SIZE))), + ), + text_batch_delay_seconds=float( + os.getenv("HERMES_FEISHU_TEXT_BATCH_DELAY_SECONDS", str(_DEFAULT_TEXT_BATCH_DELAY_SECONDS)) + ), + text_batch_split_delay_seconds=float( + os.getenv("HERMES_FEISHU_TEXT_BATCH_SPLIT_DELAY_SECONDS", "2.0") + ), + text_batch_max_messages=max( + 1, + int(os.getenv("HERMES_FEISHU_TEXT_BATCH_MAX_MESSAGES", str(_DEFAULT_TEXT_BATCH_MAX_MESSAGES))), + ), + text_batch_max_chars=max( + 1, + int(os.getenv("HERMES_FEISHU_TEXT_BATCH_MAX_CHARS", str(_DEFAULT_TEXT_BATCH_MAX_CHARS))), + ), + media_batch_delay_seconds=float( + os.getenv("HERMES_FEISHU_MEDIA_BATCH_DELAY_SECONDS", str(_DEFAULT_MEDIA_BATCH_DELAY_SECONDS)) + ), + webhook_host=str( + extra.get("webhook_host") or os.getenv("FEISHU_WEBHOOK_HOST", _DEFAULT_WEBHOOK_HOST) + ).strip(), + webhook_port=int( + extra.get("webhook_port") or os.getenv("FEISHU_WEBHOOK_PORT", str(_DEFAULT_WEBHOOK_PORT)) + ), + webhook_path=( + str(extra.get("webhook_path") or os.getenv("FEISHU_WEBHOOK_PATH", _DEFAULT_WEBHOOK_PATH)).strip() + or _DEFAULT_WEBHOOK_PATH + ), + ws_reconnect_nonce=_coerce_required_int(extra.get("ws_reconnect_nonce"), default=30, min_value=0), + ws_reconnect_interval=_coerce_required_int(extra.get("ws_reconnect_interval"), default=120, min_value=1), + ws_ping_interval=_coerce_int(extra.get("ws_ping_interval"), default=None, min_value=1), + ws_ping_timeout=_coerce_int(extra.get("ws_ping_timeout"), default=None, min_value=1), + admins=admins, + default_group_policy=default_group_policy, + group_rules=group_rules, + ) + + def _apply_settings(self, settings: FeishuAdapterSettings) -> None: + self._app_id = settings.app_id + self._app_secret = settings.app_secret + self._domain_name = settings.domain_name + self._connection_mode = settings.connection_mode + self._encrypt_key = settings.encrypt_key + self._verification_token = settings.verification_token + self._group_policy = settings.group_policy + self._allowed_group_users = set(settings.allowed_group_users) + self._admins = set(settings.admins) + self._default_group_policy = settings.default_group_policy or settings.group_policy + self._group_rules = settings.group_rules + self._bot_open_id = settings.bot_open_id + self._bot_user_id = settings.bot_user_id + self._bot_name = settings.bot_name + self._dedup_cache_size = settings.dedup_cache_size + self._text_batch_delay_seconds = settings.text_batch_delay_seconds + self._text_batch_split_delay_seconds = settings.text_batch_split_delay_seconds + self._text_batch_max_messages = settings.text_batch_max_messages + self._text_batch_max_chars = settings.text_batch_max_chars + self._media_batch_delay_seconds = settings.media_batch_delay_seconds + self._webhook_host = settings.webhook_host + self._webhook_port = settings.webhook_port + self._webhook_path = settings.webhook_path + self._ws_reconnect_nonce = settings.ws_reconnect_nonce + self._ws_reconnect_interval = settings.ws_reconnect_interval + self._ws_ping_interval = settings.ws_ping_interval + self._ws_ping_timeout = settings.ws_ping_timeout + + def _build_event_handler(self) -> Any: + if EventDispatcherHandler is None: + return None + return ( + EventDispatcherHandler.builder( + self._encrypt_key, + self._verification_token, + ) + .register_p2_im_message_message_read_v1(self._on_message_read_event) + .register_p2_im_message_receive_v1(self._on_message_event) + .register_p2_im_message_reaction_created_v1( + lambda data: self._on_reaction_event("im.message.reaction.created_v1", data) + ) + .register_p2_im_message_reaction_deleted_v1( + lambda data: self._on_reaction_event("im.message.reaction.deleted_v1", data) + ) + .register_p2_card_action_trigger(self._on_card_action_trigger) + .register_p2_im_chat_member_bot_added_v1(self._on_bot_added_to_chat) + .register_p2_im_chat_member_bot_deleted_v1(self._on_bot_removed_from_chat) + .build() + ) + + async def connect(self) -> bool: + """Connect to Feishu/Lark.""" + if not FEISHU_AVAILABLE: + logger.error("[Feishu] lark-oapi not installed") + return False + if not self._app_id or not self._app_secret: + logger.error("[Feishu] FEISHU_APP_ID or FEISHU_APP_SECRET not set") + return False + if self._connection_mode not in {"websocket", "webhook"}: + logger.error( + "[Feishu] Unsupported FEISHU_CONNECTION_MODE=%s. Supported modes: websocket, webhook.", + self._connection_mode, + ) + return False + + try: + self._app_lock_identity = self._app_id + acquired, existing = acquire_scoped_lock( + _FEISHU_APP_LOCK_SCOPE, + self._app_lock_identity, + metadata={"platform": self.platform.value}, + ) + if not acquired: + owner_pid = existing.get("pid") if isinstance(existing, dict) else None + message = ( + "Another local Hermes gateway is already using this Feishu app_id" + + (f" (PID {owner_pid})." if owner_pid else ".") + + " Stop the other gateway before starting a second Feishu websocket client." + ) + logger.error("[Feishu] %s", message) + self._set_fatal_error("feishu_app_lock", message, retryable=False) + return False + + self._loop = asyncio.get_running_loop() + await self._connect_with_retry() + self._mark_connected() + logger.info("[Feishu] Connected in %s mode (%s)", self._connection_mode, self._domain_name) + return True + except Exception as exc: + await self._release_app_lock() + message = f"Feishu startup failed: {exc}" + self._set_fatal_error("feishu_connect_error", message, retryable=True) + logger.error("[Feishu] Failed to connect: %s", exc, exc_info=True) + return False + + async def disconnect(self) -> None: + """Disconnect from Feishu/Lark.""" + self._running = False + await self._cancel_pending_tasks(self._pending_text_batch_tasks) + await self._cancel_pending_tasks(self._pending_media_batch_tasks) + self._reset_batch_buffers() + self._disable_websocket_auto_reconnect() + await self._stop_webhook_server() + + ws_thread_loop = self._ws_thread_loop + if ws_thread_loop is not None and not ws_thread_loop.is_closed(): + logger.debug("[Feishu] Cancelling websocket thread tasks and stopping loop") + + def cancel_all_tasks() -> None: + tasks = [t for t in asyncio.all_tasks(ws_thread_loop) if not t.done()] + logger.debug("[Feishu] Found %d pending tasks in websocket thread", len(tasks)) + for task in tasks: + task.cancel() + ws_thread_loop.call_later(0.1, ws_thread_loop.stop) + + ws_thread_loop.call_soon_threadsafe(cancel_all_tasks) + + ws_future = self._ws_future + if ws_future is not None: + try: + logger.debug("[Feishu] Waiting for websocket thread to exit (timeout=10s)") + await asyncio.wait_for(asyncio.shield(ws_future), timeout=10.0) + logger.debug("[Feishu] Websocket thread exited cleanly") + except asyncio.TimeoutError: + logger.warning("[Feishu] Websocket thread did not exit within 10s - may be stuck") + except asyncio.CancelledError: + logger.debug("[Feishu] Websocket thread cancelled during disconnect") + except Exception as exc: + logger.debug("[Feishu] Websocket thread exited with error: %s", exc, exc_info=True) + + self._ws_future = None + self._ws_thread_loop = None + self._loop = None + self._event_handler = None + self._persist_seen_message_ids() + await self._release_app_lock() + + self._mark_disconnected() + logger.info("[Feishu] Disconnected") + + async def _cancel_pending_tasks(self, tasks: Dict[str, asyncio.Task]) -> None: + pending = [task for task in tasks.values() if task and not task.done()] + for task in pending: + task.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + tasks.clear() + + def _reset_batch_buffers(self) -> None: + self._pending_text_batches.clear() + self._pending_text_batch_counts.clear() + self._pending_media_batches.clear() + + def _disable_websocket_auto_reconnect(self) -> None: + if self._ws_client is None: + return + try: + setattr(self._ws_client, "_auto_reconnect", False) + except Exception: + pass + finally: + self._ws_client = None + + async def _stop_webhook_server(self) -> None: + if self._webhook_runner is None: + return + try: + await self._webhook_runner.cleanup() + finally: + self._webhook_runner = None + self._webhook_site = None + + # ========================================================================= + # Outbound — send / edit / send_image / send_voice / … + # ========================================================================= + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a Feishu message.""" + if not self._client: + return SendResult(success=False, error="Not connected") + + formatted = self.format_message(content) + chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) + last_response = None + + try: + for chunk in chunks: + msg_type, payload = self._build_outbound_payload(chunk) + try: + response = await self._feishu_send_with_retry( + chat_id=chat_id, + msg_type=msg_type, + payload=payload, + reply_to=reply_to, + metadata=metadata, + ) + except Exception as exc: + if msg_type != "post" or not _POST_CONTENT_INVALID_RE.search(str(exc)): + raise + logger.warning("[Feishu] Invalid post payload rejected by API; falling back to plain text") + response = await self._feishu_send_with_retry( + chat_id=chat_id, + msg_type="text", + payload=json.dumps({"text": _strip_markdown_to_plain_text(chunk)}, ensure_ascii=False), + reply_to=reply_to, + metadata=metadata, + ) + if ( + msg_type == "post" + and not self._response_succeeded(response) + and _POST_CONTENT_INVALID_RE.search(str(getattr(response, "msg", "") or "")) + ): + logger.warning("[Feishu] Post payload rejected by API response; falling back to plain text") + response = await self._feishu_send_with_retry( + chat_id=chat_id, + msg_type="text", + payload=json.dumps({"text": _strip_markdown_to_plain_text(chunk)}, ensure_ascii=False), + reply_to=reply_to, + metadata=metadata, + ) + last_response = response + + return self._finalize_send_result(last_response, "send failed") + except Exception as exc: + logger.error("[Feishu] Send error: %s", exc, exc_info=True) + return SendResult(success=False, error=str(exc)) + + async def edit_message( + self, + chat_id: str, + message_id: str, + content: str, + ) -> SendResult: + """Edit a previously sent Feishu text/post message.""" + if not self._client: + return SendResult(success=False, error="Not connected") + + try: + msg_type, payload = self._build_outbound_payload(content) + body = self._build_update_message_body(msg_type=msg_type, content=payload) + request = self._build_update_message_request(message_id=message_id, request_body=body) + response = await asyncio.to_thread(self._client.im.v1.message.update, request) + result = self._finalize_send_result(response, "update failed") + if not result.success and msg_type == "post" and _POST_CONTENT_INVALID_RE.search(result.error or ""): + logger.warning("[Feishu] Invalid post update payload rejected by API; falling back to plain text") + fallback_body = self._build_update_message_body( + msg_type="text", + content=json.dumps({"text": _strip_markdown_to_plain_text(content)}, ensure_ascii=False), + ) + fallback_request = self._build_update_message_request(message_id=message_id, request_body=fallback_body) + fallback_response = await asyncio.to_thread(self._client.im.v1.message.update, fallback_request) + result = self._finalize_send_result(fallback_response, "update failed") + if result.success: + result.message_id = message_id + return result + except Exception as exc: + logger.error("[Feishu] Failed to edit message %s: %s", message_id, exc, exc_info=True) + return SendResult(success=False, error=str(exc)) + + async def send_exec_approval( + self, chat_id: str, command: str, session_key: str, + description: str = "dangerous command", + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an interactive card with approval buttons. + + The buttons carry ``hermes_action`` in their value dict so that + ``_handle_card_action_event`` can intercept them and call + ``resolve_gateway_approval()`` to unblock the waiting agent thread. + """ + if not self._client: + return SendResult(success=False, error="Not connected") + + try: + approval_id = next(self._approval_counter) + cmd_preview = command[:3000] + "..." if len(command) > 3000 else command + + def _btn(label: str, action_name: str, btn_type: str = "default") -> dict: + return { + "tag": "button", + "text": {"tag": "plain_text", "content": label}, + "type": btn_type, + "value": {"hermes_action": action_name, "approval_id": approval_id}, + } + + card = { + "config": {"wide_screen_mode": True}, + "header": { + "title": {"content": "⚠️ Command Approval Required", "tag": "plain_text"}, + "template": "orange", + }, + "elements": [ + { + "tag": "markdown", + "content": f"```\n{cmd_preview}\n```\n**Reason:** {description}", + }, + { + "tag": "action", + "actions": [ + _btn("✅ Allow Once", "approve_once", "primary"), + _btn("✅ Session", "approve_session"), + _btn("✅ Always", "approve_always"), + _btn("❌ Deny", "deny", "danger"), + ], + }, + ], + } + + payload = json.dumps(card, ensure_ascii=False) + response = await self._feishu_send_with_retry( + chat_id=chat_id, + msg_type="interactive", + payload=payload, + reply_to=None, + metadata=metadata, + ) + + result = self._finalize_send_result(response, "send_exec_approval failed") + if result.success: + self._approval_state[approval_id] = { + "session_key": session_key, + "message_id": result.message_id or "", + "chat_id": chat_id, + } + return result + except Exception as exc: + logger.warning("[Feishu] send_exec_approval failed: %s", exc) + return SendResult(success=False, error=str(exc)) + + async def _update_approval_card( + self, message_id: str, label: str, user_name: str, choice: str, + ) -> None: + """Replace the approval card with a resolved status card.""" + if not self._client or not message_id: + return + icon = "❌" if choice == "deny" else "✅" + card = { + "config": {"wide_screen_mode": True}, + "header": { + "title": {"content": f"{icon} {label}", "tag": "plain_text"}, + "template": "red" if choice == "deny" else "green", + }, + "elements": [ + { + "tag": "markdown", + "content": f"{icon} **{label}** by {user_name}", + }, + ], + } + try: + payload = json.dumps(card, ensure_ascii=False) + body = self._build_update_message_body(msg_type="interactive", content=payload) + request = self._build_update_message_request(message_id=message_id, request_body=body) + await asyncio.to_thread(self._client.im.v1.message.update, request) + except Exception as exc: + logger.warning("[Feishu] Failed to update approval card %s: %s", message_id, exc) + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + """Send audio to Feishu as a file attachment plus optional caption.""" + return await self._send_uploaded_file_message( + chat_id=chat_id, + file_path=audio_path, + reply_to=reply_to, + metadata=metadata, + caption=caption, + outbound_message_type="audio", + ) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + """Send a document/file attachment to Feishu.""" + return await self._send_uploaded_file_message( + chat_id=chat_id, + file_path=file_path, + reply_to=reply_to, + metadata=metadata, + caption=caption, + file_name=file_name, + ) + + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + """Send a video file to Feishu.""" + return await self._send_uploaded_file_message( + chat_id=chat_id, + file_path=video_path, + reply_to=reply_to, + metadata=metadata, + caption=caption, + outbound_message_type="media", + ) + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + """Send a local image file to Feishu.""" + if not self._client: + return SendResult(success=False, error="Not connected") + if not os.path.exists(image_path): + return SendResult(success=False, error=f"Image file not found: {image_path}") + + try: + import io as _io + with open(image_path, "rb") as f: + image_bytes = f.read() + # Wrap in BytesIO so lark SDK's MultipartEncoder can read .name and .tell() + image_file = _io.BytesIO(image_bytes) + image_file.name = os.path.basename(image_path) + body = self._build_image_upload_body( + image_type=_FEISHU_IMAGE_UPLOAD_TYPE, + image=image_file, + ) + request = self._build_image_upload_request(body) + upload_response = await asyncio.to_thread(self._client.im.v1.image.create, request) + image_key = self._extract_response_field(upload_response, "image_key") + if not image_key: + return self._response_error_result( + upload_response, + default_message="image upload failed", + override_error="Feishu image upload missing image_key", + ) + + if caption: + post_payload = self._build_media_post_payload( + caption=caption, + media_tag={"tag": "img", "image_key": image_key}, + ) + message_response = await self._feishu_send_with_retry( + chat_id=chat_id, + msg_type="post", + payload=post_payload, + reply_to=reply_to, + metadata=metadata, + ) + else: + message_response = await self._feishu_send_with_retry( + chat_id=chat_id, + msg_type="image", + payload=json.dumps({"image_key": image_key}, ensure_ascii=False), + reply_to=reply_to, + metadata=metadata, + ) + return self._finalize_send_result(message_response, "image send failed") + except Exception as exc: + logger.error("[Feishu] Failed to send image %s: %s", image_path, exc, exc_info=True) + return SendResult(success=False, error=str(exc)) + + async def send_typing(self, chat_id: str, metadata=None) -> None: + """Feishu bot API does not expose a typing indicator.""" + return None + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Download a remote image then send it through the native Feishu image flow.""" + try: + image_path = await self._download_remote_image(image_url) + except Exception as exc: + logger.error("[Feishu] Failed to download image %s: %s", image_url, exc, exc_info=True) + return await super().send_image( + chat_id=chat_id, + image_url=image_url, + caption=caption, + reply_to=reply_to, + metadata=metadata, + ) + return await self.send_image_file( + chat_id=chat_id, + image_path=image_path, + caption=caption, + reply_to=reply_to, + metadata=metadata, + ) + + async def send_animation( + self, + chat_id: str, + animation_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Feishu has no native GIF bubble; degrade to a downloadable file.""" + try: + file_path, file_name = await self._download_remote_document( + animation_url, + default_ext=".gif", + preferred_name="animation.gif", + ) + except Exception as exc: + logger.error("[Feishu] Failed to download animation %s: %s", animation_url, exc, exc_info=True) + return await super().send_animation( + chat_id=chat_id, + animation_url=animation_url, + caption=caption, + reply_to=reply_to, + metadata=metadata, + ) + degraded_caption = f"[GIF downgraded to file]\n{caption}" if caption else "[GIF downgraded to file]" + return await self.send_document( + chat_id=chat_id, + file_path=file_path, + file_name=file_name, + caption=degraded_caption, + reply_to=reply_to, + metadata=metadata, + ) + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Return real chat metadata from Feishu when available.""" + fallback = { + "chat_id": chat_id, + "name": chat_id, + "type": "dm", + } + if not self._client: + return fallback + + cached = self._chat_info_cache.get(chat_id) + if cached is not None: + return dict(cached) + + try: + request = self._build_get_chat_request(chat_id) + response = await asyncio.to_thread(self._client.im.v1.chat.get, request) + if not response or getattr(response, "success", lambda: False)() is False: + code = getattr(response, "code", "unknown") + msg = getattr(response, "msg", "chat lookup failed") + logger.warning("[Feishu] Failed to get chat info for %s: [%s] %s", chat_id, code, msg) + return fallback + + data = getattr(response, "data", None) + raw_chat_type = str(getattr(data, "chat_type", "") or "").strip().lower() + info = { + "chat_id": chat_id, + "name": str(getattr(data, "name", None) or chat_id), + "type": self._map_chat_type(raw_chat_type), + "raw_type": raw_chat_type or None, + } + self._chat_info_cache[chat_id] = info + return dict(info) + except Exception: + logger.warning("[Feishu] Failed to get chat info for %s", chat_id, exc_info=True) + return fallback + + def format_message(self, content: str) -> str: + """Feishu text messages are plain text by default.""" + return content.strip() + + # ========================================================================= + # Inbound event handlers + # ========================================================================= + + def _on_message_event(self, data: Any) -> None: + """Normalize Feishu inbound events into MessageEvent.""" + loop = self._loop + if loop is None or bool(getattr(loop, "is_closed", lambda: False)()): + logger.warning("[Feishu] Dropping inbound message before adapter loop is ready") + return + future = asyncio.run_coroutine_threadsafe( + self._handle_message_event_data(data), + loop, + ) + future.add_done_callback(self._log_background_failure) + + async def _handle_message_event_data(self, data: Any) -> None: + """Shared inbound message handling for websocket and webhook transports.""" + event = getattr(data, "event", None) + message = getattr(event, "message", None) + sender = getattr(event, "sender", None) + sender_id = getattr(sender, "sender_id", None) + if not message or not sender_id: + logger.debug("[Feishu] Dropping malformed inbound event: missing message or sender_id") + return + + message_id = getattr(message, "message_id", None) + if not message_id or self._is_duplicate(message_id): + logger.debug("[Feishu] Dropping duplicate/missing message_id: %s", message_id) + return + if getattr(sender, "sender_type", "") == "bot": + logger.debug("[Feishu] Dropping bot-originated event: %s", message_id) + return + + chat_type = getattr(message, "chat_type", "p2p") + chat_id = getattr(message, "chat_id", "") or "" + if chat_type != "p2p" and not self._should_accept_group_message(message, sender_id, chat_id): + logger.debug("[Feishu] Dropping group message that failed mention/policy gate: %s", message_id) + return + await self._process_inbound_message( + data=data, + message=message, + sender_id=sender_id, + chat_type=chat_type, + message_id=message_id, + ) + + def _on_message_read_event(self, data: P2ImMessageMessageReadV1) -> None: + """Ignore read-receipt events that Hermes does not act on.""" + event = getattr(data, "event", None) + message = getattr(event, "message", None) + message_id = getattr(message, "message_id", None) or "" + logger.debug("[Feishu] Ignoring message_read event: %s", message_id) + + def _on_bot_added_to_chat(self, data: Any) -> None: + """Handle bot being added to a group chat.""" + event = getattr(data, "event", None) + chat_id = str(getattr(event, "chat_id", "") or "") + logger.info("[Feishu] Bot added to chat: %s", chat_id) + self._chat_info_cache.pop(chat_id, None) + + def _on_bot_removed_from_chat(self, data: Any) -> None: + """Handle bot being removed from a group chat.""" + event = getattr(data, "event", None) + chat_id = str(getattr(event, "chat_id", "") or "") + logger.info("[Feishu] Bot removed from chat: %s", chat_id) + self._chat_info_cache.pop(chat_id, None) + + def _on_reaction_event(self, event_type: str, data: Any) -> None: + """Route user reactions on bot messages as synthetic text events.""" + event = getattr(data, "event", None) + message_id = str(getattr(event, "message_id", "") or "") + operator_type = str(getattr(event, "operator_type", "") or "") + reaction_type_obj = getattr(event, "reaction_type", None) + emoji_type = str(getattr(reaction_type_obj, "emoji_type", "") or "") + action = "added" if "created" in event_type else "removed" + logger.debug( + "[Feishu] Reaction %s on message %s (operator_type=%s, emoji=%s)", + action, + message_id, + operator_type, + emoji_type, + ) + # Only process reactions from real users. Ignore app/bot-generated reactions + # and Hermes' own ACK emoji to avoid feedback loops. + loop = self._loop + if ( + operator_type in {"bot", "app"} + or emoji_type == _FEISHU_ACK_EMOJI + or not message_id + or loop is None + or bool(getattr(loop, "is_closed", lambda: False)()) + ): + return + future = asyncio.run_coroutine_threadsafe( + self._handle_reaction_event(event_type, data), + loop, + ) + future.add_done_callback(self._log_background_failure) + + def _on_card_action_trigger(self, data: Any) -> Any: + """Schedule Feishu card actions on the adapter loop and acknowledge immediately.""" + loop = self._loop + if loop is None or bool(getattr(loop, "is_closed", lambda: False)()): + logger.warning("[Feishu] Dropping card action before adapter loop is ready") + else: + future = asyncio.run_coroutine_threadsafe( + self._handle_card_action_event(data), + loop, + ) + future.add_done_callback(self._log_background_failure) + if P2CardActionTriggerResponse is None: + return None + return P2CardActionTriggerResponse() + + async def _handle_reaction_event(self, event_type: str, data: Any) -> None: + """Fetch the reacted-to message; if it was sent by this bot, emit a synthetic text event.""" + if not self._client: + return + event = getattr(data, "event", None) + message_id = str(getattr(event, "message_id", "") or "") + if not message_id: + return + + # Fetch the target message to verify it was sent by us and to obtain chat context. + try: + request = self._build_get_message_request(message_id) + response = await asyncio.to_thread(self._client.im.v1.message.get, request) + if not response or not getattr(response, "success", lambda: False)(): + return + items = getattr(getattr(response, "data", None), "items", None) or [] + msg = items[0] if items else None + if not msg: + return + sender = getattr(msg, "sender", None) + sender_type = str(getattr(sender, "sender_type", "") or "").lower() + if sender_type != "app": + return # only route reactions on our own bot messages + chat_id = str(getattr(msg, "chat_id", "") or "") + chat_type_raw = str(getattr(msg, "chat_type", "p2p") or "p2p") + if not chat_id: + return + except Exception: + logger.debug("[Feishu] Failed to fetch message for reaction routing", exc_info=True) + return + + user_id_obj = getattr(event, "user_id", None) + reaction_type_obj = getattr(event, "reaction_type", None) + emoji_type = str(getattr(reaction_type_obj, "emoji_type", "") or "UNKNOWN") + action = "added" if "created" in event_type else "removed" + synthetic_text = f"reaction:{action}:{emoji_type}" + + sender_profile = await self._resolve_sender_profile(user_id_obj) + chat_info = await self.get_chat_info(chat_id) + source = self.build_source( + chat_id=chat_id, + chat_name=chat_info.get("name") or chat_id or "Feishu Chat", + chat_type=self._resolve_source_chat_type(chat_info=chat_info, event_chat_type=chat_type_raw), + user_id=sender_profile["user_id"], + user_name=sender_profile["user_name"], + thread_id=None, + user_id_alt=sender_profile["user_id_alt"], + ) + synthetic_event = MessageEvent( + text=synthetic_text, + message_type=MessageType.TEXT, + source=source, + raw_message=data, + message_id=message_id, + timestamp=datetime.now(), + ) + logger.info("[Feishu] Routing reaction %s:%s on bot message %s as synthetic event", action, emoji_type, message_id) + await self._handle_message_with_guards(synthetic_event) + + def _is_card_action_duplicate(self, token: str) -> bool: + """Return True if this card action token was already processed within the dedup window.""" + now = time.time() + # Prune expired tokens lazily each call. + expired = [t for t, ts in self._card_action_tokens.items() if now - ts > _FEISHU_CARD_ACTION_DEDUP_TTL_SECONDS] + for t in expired: + del self._card_action_tokens[t] + if token in self._card_action_tokens: + return True + self._card_action_tokens[token] = now + return False + + async def _handle_card_action_event(self, data: Any) -> None: + """Route Feishu interactive card button clicks as synthetic COMMAND events.""" + event = getattr(data, "event", None) + token = str(getattr(event, "token", "") or "") + if token and self._is_card_action_duplicate(token): + logger.debug("[Feishu] Dropping duplicate card action token: %s", token) + return + + context = getattr(event, "context", None) + chat_id = str(getattr(context, "open_chat_id", "") or "") + operator = getattr(event, "operator", None) + open_id = str(getattr(operator, "open_id", "") or "") + if not chat_id or not open_id: + logger.debug("[Feishu] Card action missing chat_id or operator open_id, dropping") + return + + action = getattr(event, "action", None) + action_tag = str(getattr(action, "tag", "") or "button") + action_value = getattr(action, "value", {}) or {} + + # --- Exec approval button intercept --- + hermes_action = action_value.get("hermes_action") if isinstance(action_value, dict) else None + if hermes_action: + approval_id = action_value.get("approval_id") + state = self._approval_state.pop(approval_id, None) + if not state: + logger.debug("[Feishu] Approval %s already resolved or unknown", approval_id) + return + + choice_map = { + "approve_once": "once", + "approve_session": "session", + "approve_always": "always", + "deny": "deny", + } + choice = choice_map.get(hermes_action, "deny") + + label_map = { + "once": "Approved once", + "session": "Approved for session", + "always": "Approved permanently", + "deny": "Denied", + } + label = label_map.get(choice, "Resolved") + + # Resolve sender name for the status card + sender_id = SimpleNamespace(open_id=open_id, user_id=None, union_id=None) + sender_profile = await self._resolve_sender_profile(sender_id) + user_name = sender_profile.get("user_name") or open_id + + # Resolve the approval — unblocks the agent thread + try: + from tools.approval import resolve_gateway_approval + count = resolve_gateway_approval(state["session_key"], choice) + logger.info( + "Feishu button resolved %d approval(s) for session %s (choice=%s, user=%s)", + count, state["session_key"], choice, user_name, + ) + except Exception as exc: + logger.error("Failed to resolve gateway approval from Feishu button: %s", exc) + + # Update the card to show the decision + await self._update_approval_card(state.get("message_id", ""), label, user_name, choice) + return + + synthetic_text = f"/card {action_tag}" + if action_value: + try: + synthetic_text += f" {json.dumps(action_value, ensure_ascii=False)}" + except Exception: + pass + + sender_id = SimpleNamespace(open_id=open_id, user_id=None, union_id=None) + sender_profile = await self._resolve_sender_profile(sender_id) + chat_info = await self.get_chat_info(chat_id) + source = self.build_source( + chat_id=chat_id, + chat_name=chat_info.get("name") or chat_id or "Feishu Chat", + chat_type=self._resolve_source_chat_type(chat_info=chat_info, event_chat_type="group"), + user_id=sender_profile["user_id"], + user_name=sender_profile["user_name"], + thread_id=None, + user_id_alt=sender_profile["user_id_alt"], + ) + synthetic_event = MessageEvent( + text=synthetic_text, + message_type=MessageType.COMMAND, + source=source, + raw_message=data, + message_id=token or str(uuid.uuid4()), + timestamp=datetime.now(), + ) + logger.info("[Feishu] Routing card action %r from %s in %s as synthetic command", action_tag, open_id, chat_id) + await self._handle_message_with_guards(synthetic_event) + + # ========================================================================= + # Per-chat serialization and typing indicator + # ========================================================================= + + def _get_chat_lock(self, chat_id: str) -> asyncio.Lock: + """Return (creating if needed) the per-chat asyncio.Lock for serial message processing.""" + lock = self._chat_locks.get(chat_id) + if lock is None: + lock = asyncio.Lock() + self._chat_locks[chat_id] = lock + return lock + + async def _handle_message_with_guards(self, event: MessageEvent) -> None: + """Dispatch a single event through the agent pipeline with per-chat serialization + and a persistent ACK emoji reaction before processing starts. + + - Per-chat lock: ensures messages in the same chat are processed one at a time + (matches openclaw's createChatQueue serial queue behaviour). + - ACK indicator: adds a CHECK reaction to the triggering message before handing + off to the agent and leaves it in place as a receipt marker. + """ + chat_id = getattr(event.source, "chat_id", "") or "" if event.source else "" + chat_lock = self._get_chat_lock(chat_id) + async with chat_lock: + message_id = event.message_id + if message_id: + await self._add_ack_reaction(message_id) + await self.handle_message(event) + + async def _add_ack_reaction(self, message_id: str) -> Optional[str]: + """Add a persistent ACK emoji reaction to signal the message was received.""" + if not self._client or not message_id: + return None + try: + from lark_oapi.api.im.v1 import ( # lazy import — keeps optional dep optional + CreateMessageReactionRequest, + CreateMessageReactionRequestBody, + ) + body = ( + CreateMessageReactionRequestBody.builder() + .reaction_type({"emoji_type": _FEISHU_ACK_EMOJI}) + .build() + ) + request = ( + CreateMessageReactionRequest.builder() + .message_id(message_id) + .request_body(body) + .build() + ) + response = await asyncio.to_thread(self._client.im.v1.message_reaction.create, request) + if response and getattr(response, "success", lambda: False)(): + data = getattr(response, "data", None) + return getattr(data, "reaction_id", None) + logger.warning( + "[Feishu] Failed to add ack reaction to %s: code=%s msg=%s", + message_id, + getattr(response, "code", None), + getattr(response, "msg", None), + ) + except Exception: + logger.warning("[Feishu] Failed to add ack reaction to %s", message_id, exc_info=True) + return None + + # ========================================================================= + # Webhook server and security + # ========================================================================= + + def _record_webhook_anomaly(self, remote_ip: str, status: str) -> None: + """Increment the anomaly counter for remote_ip and emit a WARNING every threshold hits. + + Mirrors openclaw's createWebhookAnomalyTracker: TTL 6 hours, log every 25 consecutive + error responses from the same IP. + """ + now = time.time() + entry = self._webhook_anomaly_counts.get(remote_ip) + if entry is not None: + count, _last_status, first_seen = entry + if now - first_seen < _FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS: + count += 1 + if count % _FEISHU_WEBHOOK_ANOMALY_THRESHOLD == 0: + logger.warning( + "[Feishu] Webhook anomaly: %d consecutive error responses (%s) from %s " + "over the last %.0fs", + count, + status, + remote_ip, + now - first_seen, + ) + self._webhook_anomaly_counts[remote_ip] = (count, status, first_seen) + return + # Either first occurrence or TTL expired — start fresh. + self._webhook_anomaly_counts[remote_ip] = (1, status, now) + + def _clear_webhook_anomaly(self, remote_ip: str) -> None: + """Reset the anomaly counter for remote_ip after a successful request.""" + self._webhook_anomaly_counts.pop(remote_ip, None) + + # ========================================================================= + # Inbound processing pipeline + # ========================================================================= + + async def _process_inbound_message( + self, + *, + data: Any, + message: Any, + sender_id: Any, + chat_type: str, + message_id: str, + ) -> None: + text, inbound_type, media_urls, media_types = await self._extract_message_content(message) + if inbound_type == MessageType.TEXT and not text and not media_urls: + logger.debug("[Feishu] Ignoring unsupported or empty message type: %s", getattr(message, "message_type", "")) + return + + if inbound_type == MessageType.TEXT and text.startswith("/"): + inbound_type = MessageType.COMMAND + + reply_to_message_id = ( + getattr(message, "parent_id", None) + or getattr(message, "upper_message_id", None) + or None + ) + reply_to_text = await self._fetch_message_text(reply_to_message_id) if reply_to_message_id else None + + logger.info( + "[Feishu] Inbound %s message received: id=%s type=%s chat_id=%s text=%r media=%d", + "dm" if chat_type == "p2p" else "group", + message_id, + inbound_type.value, + getattr(message, "chat_id", "") or "", + text[:120], + len(media_urls), + ) + + chat_id = getattr(message, "chat_id", "") or "" + chat_info = await self.get_chat_info(chat_id) + sender_profile = await self._resolve_sender_profile(sender_id) + source = self.build_source( + chat_id=chat_id, + chat_name=chat_info.get("name") or chat_id or "Feishu Chat", + chat_type=self._resolve_source_chat_type(chat_info=chat_info, event_chat_type=chat_type), + user_id=sender_profile["user_id"], + user_name=sender_profile["user_name"], + thread_id=getattr(message, "thread_id", None) or None, + user_id_alt=sender_profile["user_id_alt"], + ) + normalized = MessageEvent( + text=text, + message_type=inbound_type, + source=source, + raw_message=data, + message_id=message_id, + media_urls=media_urls, + media_types=media_types, + reply_to_message_id=reply_to_message_id, + reply_to_text=reply_to_text, + timestamp=datetime.now(), + ) + await self._dispatch_inbound_event(normalized) + + async def _dispatch_inbound_event(self, event: MessageEvent) -> None: + """Apply Feishu-specific burst protection before entering the base adapter.""" + if event.message_type == MessageType.TEXT and not event.is_command(): + await self._enqueue_text_event(event) + return + if self._should_batch_media_event(event): + await self._enqueue_media_event(event) + return + await self._handle_message_with_guards(event) + + # ========================================================================= + # Media batching + # ========================================================================= + + def _should_batch_media_event(self, event: MessageEvent) -> bool: + return bool( + event.media_urls + and event.message_type in {MessageType.PHOTO, MessageType.VIDEO, MessageType.DOCUMENT, MessageType.AUDIO} + ) + + def _media_batch_key(self, event: MessageEvent) -> str: + from gateway.session import build_session_key + + session_key = build_session_key( + event.source, + group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), + thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + ) + return f"{session_key}:media:{event.message_type.value}" + + @staticmethod + def _media_batch_is_compatible(existing: MessageEvent, incoming: MessageEvent) -> bool: + return ( + existing.message_type == incoming.message_type + and existing.reply_to_message_id == incoming.reply_to_message_id + and existing.reply_to_text == incoming.reply_to_text + and existing.source.thread_id == incoming.source.thread_id + ) + + async def _enqueue_media_event(self, event: MessageEvent) -> None: + key = self._media_batch_key(event) + existing = self._pending_media_batches.get(key) + if existing is None: + self._pending_media_batches[key] = event + self._schedule_media_batch_flush(key) + return + if not self._media_batch_is_compatible(existing, event): + await self._flush_media_batch_now(key) + self._pending_media_batches[key] = event + self._schedule_media_batch_flush(key) + return + existing.media_urls.extend(event.media_urls) + existing.media_types.extend(event.media_types) + if event.text: + existing.text = self._merge_caption(existing.text, event.text) + existing.timestamp = event.timestamp + if event.message_id: + existing.message_id = event.message_id + self._schedule_media_batch_flush(key) + + def _schedule_media_batch_flush(self, key: str) -> None: + self._reschedule_batch_task( + self._pending_media_batch_tasks, + key, + self._flush_media_batch, + ) + + async def _flush_media_batch(self, key: str) -> None: + current_task = asyncio.current_task() + try: + await asyncio.sleep(self._media_batch_delay_seconds) + await self._flush_media_batch_now(key) + finally: + if self._pending_media_batch_tasks.get(key) is current_task: + self._pending_media_batch_tasks.pop(key, None) + + async def _flush_media_batch_now(self, key: str) -> None: + event = self._pending_media_batches.pop(key, None) + if not event: + return + logger.info( + "[Feishu] Flushing media batch %s with %d attachment(s)", + key, + len(event.media_urls), + ) + await self._handle_message_with_guards(event) + + async def _download_remote_image(self, image_url: str) -> str: + ext = self._guess_remote_extension(image_url, default=".jpg") + return await cache_image_from_url(image_url, ext=ext) + + async def _download_remote_document( + self, + file_url: str, + *, + default_ext: str, + preferred_name: str, + ) -> tuple[str, str]: + from tools.url_safety import is_safe_url + if not is_safe_url(file_url): + raise ValueError(f"Blocked unsafe URL (SSRF protection): {file_url[:80]}") + + import httpx + + async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: + response = await client.get( + file_url, + headers={ + "User-Agent": "Mozilla/5.0 (compatible; HermesAgent/1.0)", + "Accept": "*/*", + }, + ) + response.raise_for_status() + filename = self._derive_remote_filename( + file_url, + content_type=str(response.headers.get("Content-Type", "")), + default_name=preferred_name, + default_ext=default_ext, + ) + cached_path = cache_document_from_bytes(response.content, filename) + return cached_path, filename + + @staticmethod + def _guess_remote_extension(url: str, *, default: str) -> str: + ext = Path((url or "").split("?", 1)[0]).suffix.lower() + return ext if ext in (_IMAGE_EXTENSIONS | _AUDIO_EXTENSIONS | _VIDEO_EXTENSIONS | set(SUPPORTED_DOCUMENT_TYPES)) else default + + @staticmethod + def _derive_remote_filename(file_url: str, *, content_type: str, default_name: str, default_ext: str) -> str: + candidate = Path((file_url or "").split("?", 1)[0]).name or default_name + ext = Path(candidate).suffix.lower() + if not ext: + guessed = mimetypes.guess_extension((content_type or "").split(";", 1)[0].strip().lower() or "") or default_ext + candidate = f"{candidate}{guessed}" + return candidate + + @staticmethod + def _namespace_from_mapping(value: Any) -> Any: + if isinstance(value, dict): + return SimpleNamespace(**{key: FeishuAdapter._namespace_from_mapping(item) for key, item in value.items()}) + if isinstance(value, list): + return [FeishuAdapter._namespace_from_mapping(item) for item in value] + return value + + async def _handle_webhook_request(self, request: Any) -> Any: + remote_ip = (getattr(request, "remote", None) or "unknown") + + # Rate limiting — composite key: app_id:path:remote_ip (matches openclaw key structure). + rate_key = f"{self._app_id}:{self._webhook_path}:{remote_ip}" + if not self._check_webhook_rate_limit(rate_key): + logger.warning("[Feishu] Webhook rate limit exceeded for %s", remote_ip) + self._record_webhook_anomaly(remote_ip, "429") + return web.Response(status=429, text="Too Many Requests") + + # Content-Type guard — Feishu always sends application/json. + headers = getattr(request, "headers", {}) or {} + content_type = str(headers.get("Content-Type", "") or "").split(";")[0].strip().lower() + if content_type and content_type != "application/json": + logger.warning("[Feishu] Webhook rejected: unexpected Content-Type %r from %s", content_type, remote_ip) + self._record_webhook_anomaly(remote_ip, "415") + return web.Response(status=415, text="Unsupported Media Type") + + # Body size guard — reject early via Content-Length when present. + content_length = getattr(request, "content_length", None) + if content_length is not None and content_length > _FEISHU_WEBHOOK_MAX_BODY_BYTES: + logger.warning("[Feishu] Webhook body too large (%d bytes) from %s", content_length, remote_ip) + self._record_webhook_anomaly(remote_ip, "413") + return web.Response(status=413, text="Request body too large") + + try: + body_bytes: bytes = await asyncio.wait_for( + request.read(), + timeout=_FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError: + logger.warning("[Feishu] Webhook body read timed out after %ds from %s", _FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS, remote_ip) + self._record_webhook_anomaly(remote_ip, "408") + return web.Response(status=408, text="Request Timeout") + except Exception: + self._record_webhook_anomaly(remote_ip, "400") + return web.json_response({"code": 400, "msg": "failed to read body"}, status=400) + + if len(body_bytes) > _FEISHU_WEBHOOK_MAX_BODY_BYTES: + logger.warning("[Feishu] Webhook body exceeds limit (%d bytes) from %s", len(body_bytes), remote_ip) + self._record_webhook_anomaly(remote_ip, "413") + return web.Response(status=413, text="Request body too large") + + try: + payload = json.loads(body_bytes.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + self._record_webhook_anomaly(remote_ip, "400") + return web.json_response({"code": 400, "msg": "invalid json"}, status=400) + + # URL verification challenge — respond before other checks so that Feishu's + # subscription setup works even before encrypt_key is wired. + if payload.get("type") == "url_verification": + return web.json_response({"challenge": payload.get("challenge", "")}) + + # Verification token check — second layer of defence beyond signature (matches openclaw). + if self._verification_token: + header = payload.get("header") or {} + incoming_token = str(header.get("token") or payload.get("token") or "") + if not incoming_token or not hmac.compare_digest(incoming_token, self._verification_token): + logger.warning("[Feishu] Webhook rejected: invalid verification token from %s", remote_ip) + self._record_webhook_anomaly(remote_ip, "401-token") + return web.Response(status=401, text="Invalid verification token") + + # Timing-safe signature verification (only enforced when encrypt_key is set). + if self._encrypt_key and not self._is_webhook_signature_valid(request.headers, body_bytes): + logger.warning("[Feishu] Webhook rejected: invalid signature from %s", remote_ip) + self._record_webhook_anomaly(remote_ip, "401-sig") + return web.Response(status=401, text="Invalid signature") + + if payload.get("encrypt"): + logger.error("[Feishu] Encrypted webhook payloads are not supported by Hermes webhook mode") + self._record_webhook_anomaly(remote_ip, "400-encrypted") + return web.json_response({"code": 400, "msg": "encrypted webhook payloads are not supported"}, status=400) + + self._clear_webhook_anomaly(remote_ip) + + event_type = str((payload.get("header") or {}).get("event_type") or "") + data = self._namespace_from_mapping(payload) + if event_type == "im.message.receive_v1": + self._on_message_event(data) + elif event_type == "im.message.message_read_v1": + self._on_message_read_event(data) + elif event_type == "im.chat.member.bot.added_v1": + self._on_bot_added_to_chat(data) + elif event_type == "im.chat.member.bot.deleted_v1": + self._on_bot_removed_from_chat(data) + elif event_type in ("im.message.reaction.created_v1", "im.message.reaction.deleted_v1"): + self._on_reaction_event(event_type, data) + elif event_type == "card.action.trigger": + self._on_card_action_trigger(data) + else: + logger.debug("[Feishu] Ignoring webhook event type: %s", event_type or "unknown") + return web.json_response({"code": 0, "msg": "ok"}) + + def _is_webhook_signature_valid(self, headers: Any, body_bytes: bytes) -> bool: + """Verify Feishu webhook signature using timing-safe comparison. + + Feishu signature algorithm: + SHA256(timestamp + nonce + encrypt_key + body_string) + Headers checked: x-lark-request-timestamp, x-lark-request-nonce, x-lark-signature. + """ + timestamp = str(headers.get("x-lark-request-timestamp", "") or "") + nonce = str(headers.get("x-lark-request-nonce", "") or "") + signature = str(headers.get("x-lark-signature", "") or "") + if not timestamp or not nonce or not signature: + return False + try: + body_str = body_bytes.decode("utf-8", errors="replace") + content = f"{timestamp}{nonce}{self._encrypt_key}{body_str}" + computed = hashlib.sha256(content.encode("utf-8")).hexdigest() + return hmac.compare_digest(computed, signature) + except Exception: + logger.debug("[Feishu] Signature verification raised an exception", exc_info=True) + return False + + def _check_webhook_rate_limit(self, rate_key: str) -> bool: + """Return False when the composite rate_key has exceeded _FEISHU_WEBHOOK_RATE_LIMIT_MAX. + + The rate_key is composed as "{app_id}:{path}:{remote_ip}" — matching openclaw's key + structure so the limit is scoped to a specific (account, endpoint, IP) triple rather + than a bare IP, which causes fewer false-positive denials in multi-tenant setups. + + The tracking dict is capped at _FEISHU_WEBHOOK_RATE_MAX_KEYS entries to prevent unbounded + memory growth. Stale (expired) entries are pruned when the cap is reached. + """ + now = time.time() + # Fast path: existing entry within the current window. + entry = self._webhook_rate_counts.get(rate_key) + if entry is not None: + count, window_start = entry + if now - window_start < _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS: + if count >= _FEISHU_WEBHOOK_RATE_LIMIT_MAX: + return False + self._webhook_rate_counts[rate_key] = (count + 1, window_start) + return True + # New window for an existing key, or a brand-new key — prune stale entries first. + if len(self._webhook_rate_counts) >= _FEISHU_WEBHOOK_RATE_MAX_KEYS: + stale_keys = [ + k for k, (_, ws) in self._webhook_rate_counts.items() + if now - ws >= _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS + ] + for k in stale_keys: + del self._webhook_rate_counts[k] + # If still at capacity after pruning, allow through without tracking. + if rate_key not in self._webhook_rate_counts and len(self._webhook_rate_counts) >= _FEISHU_WEBHOOK_RATE_MAX_KEYS: + return True + self._webhook_rate_counts[rate_key] = (1, now) + return True + + # ========================================================================= + # Text batching + # ========================================================================= + + def _text_batch_key(self, event: MessageEvent) -> str: + """Return the session-scoped key used for Feishu text aggregation.""" + from gateway.session import build_session_key + + return build_session_key( + event.source, + group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), + thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + ) + + @staticmethod + def _text_batch_is_compatible(existing: MessageEvent, incoming: MessageEvent) -> bool: + """Only merge text events when reply/thread context is identical.""" + return ( + existing.reply_to_message_id == incoming.reply_to_message_id + and existing.reply_to_text == incoming.reply_to_text + and existing.source.thread_id == incoming.source.thread_id + ) + + async def _enqueue_text_event(self, event: MessageEvent) -> None: + """Debounce rapid Feishu text bursts into a single MessageEvent.""" + key = self._text_batch_key(event) + chunk_len = len(event.text or "") + existing = self._pending_text_batches.get(key) + if existing is None: + event._last_chunk_len = chunk_len # type: ignore[attr-defined] + self._pending_text_batches[key] = event + self._pending_text_batch_counts[key] = 1 + self._schedule_text_batch_flush(key) + return + + if not self._text_batch_is_compatible(existing, event): + await self._flush_text_batch_now(key) + self._pending_text_batches[key] = event + self._pending_text_batch_counts[key] = 1 + self._schedule_text_batch_flush(key) + return + + existing_count = self._pending_text_batch_counts.get(key, 1) + next_count = existing_count + 1 + appended_text = event.text or "" + next_text = f"{existing.text}\n{appended_text}" if existing.text and appended_text else (existing.text or appended_text) + if next_count > self._text_batch_max_messages or len(next_text) > self._text_batch_max_chars: + await self._flush_text_batch_now(key) + self._pending_text_batches[key] = event + self._pending_text_batch_counts[key] = 1 + self._schedule_text_batch_flush(key) + return + + existing.text = next_text + existing._last_chunk_len = chunk_len # type: ignore[attr-defined] + existing.timestamp = event.timestamp + if event.message_id: + existing.message_id = event.message_id + self._pending_text_batch_counts[key] = next_count + self._schedule_text_batch_flush(key) + + def _schedule_text_batch_flush(self, key: str) -> None: + """Reset the debounce timer for a pending Feishu text batch.""" + self._reschedule_batch_task( + self._pending_text_batch_tasks, + key, + self._flush_text_batch, + ) + + @staticmethod + def _reschedule_batch_task( + task_map: Dict[str, asyncio.Task], + key: str, + flush_fn: Any, + ) -> None: + prior_task = task_map.get(key) + if prior_task and not prior_task.done(): + prior_task.cancel() + task_map[key] = asyncio.create_task(flush_fn(key)) + + async def _flush_text_batch(self, key: str) -> None: + """Flush a pending text batch after the quiet period. + + Uses a longer delay when the latest chunk is near Feishu's ~4096-char + split point, since a continuation chunk is almost certain. + """ + current_task = asyncio.current_task() + try: + # Adaptive delay: if the latest chunk is near the split threshold, + # a continuation is almost certain — wait longer. + pending = self._pending_text_batches.get(key) + last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0 + if last_len >= self._SPLIT_THRESHOLD: + delay = self._text_batch_split_delay_seconds + else: + delay = self._text_batch_delay_seconds + await asyncio.sleep(delay) + await self._flush_text_batch_now(key) + finally: + if self._pending_text_batch_tasks.get(key) is current_task: + self._pending_text_batch_tasks.pop(key, None) + + async def _flush_text_batch_now(self, key: str) -> None: + """Dispatch the current text batch immediately.""" + event = self._pending_text_batches.pop(key, None) + self._pending_text_batch_counts.pop(key, None) + if not event: + return + logger.info( + "[Feishu] Flushing text batch %s (%d chars)", + key, + len(event.text or ""), + ) + await self._handle_message_with_guards(event) + + # ========================================================================= + # Message content extraction and resource download + # ========================================================================= + + async def _extract_message_content(self, message: Any) -> tuple[str, MessageType, List[str], List[str]]: + """Extract text and cached media from a normalized Feishu message.""" + raw_content = getattr(message, "content", "") or "" + raw_type = getattr(message, "message_type", "") or "" + message_id = str(getattr(message, "message_id", "") or "") + logger.info("[Feishu] Received raw message type=%s message_id=%s", raw_type, message_id) + + normalized = normalize_feishu_message(message_type=raw_type, raw_content=raw_content) + media_urls, media_types = await self._download_feishu_message_resources( + message_id=message_id, + normalized=normalized, + ) + inbound_type = self._resolve_normalized_message_type(normalized, media_types) + text = normalized.text_content + + if ( + inbound_type in {MessageType.DOCUMENT, MessageType.AUDIO, MessageType.VIDEO, MessageType.PHOTO} + and len(media_urls) == 1 + and normalized.preferred_message_type in {"document", "audio"} + ): + injected = await self._maybe_extract_text_document(media_urls[0], media_types[0]) + if injected: + text = injected + + return text, inbound_type, media_urls, media_types + + async def _download_feishu_message_resources( + self, + *, + message_id: str, + normalized: FeishuNormalizedMessage, + ) -> tuple[List[str], List[str]]: + media_urls: List[str] = [] + media_types: List[str] = [] + + for image_key in normalized.image_keys: + cached_path, media_type = await self._download_feishu_image( + message_id=message_id, + image_key=image_key, + ) + if cached_path: + media_urls.append(cached_path) + media_types.append(media_type) + + for media_ref in normalized.media_refs: + cached_path, media_type = await self._download_feishu_message_resource( + message_id=message_id, + file_key=media_ref.file_key, + resource_type=media_ref.resource_type, + fallback_filename=media_ref.file_name, + ) + if cached_path: + media_urls.append(cached_path) + media_types.append(media_type) + + return media_urls, media_types + + @staticmethod + def _resolve_media_message_type(media_type: str, *, default: MessageType) -> MessageType: + normalized = (media_type or "").lower() + if normalized.startswith("image/"): + return MessageType.PHOTO + if normalized.startswith("audio/"): + return MessageType.AUDIO + if normalized.startswith("video/"): + return MessageType.VIDEO + return default + + def _resolve_normalized_message_type( + self, + normalized: FeishuNormalizedMessage, + media_types: List[str], + ) -> MessageType: + preferred = normalized.preferred_message_type + if preferred == "photo": + return self._resolve_media_message_type(media_types[0] if media_types else "", default=MessageType.PHOTO) + if preferred == "audio": + return self._resolve_media_message_type(media_types[0] if media_types else "", default=MessageType.AUDIO) + if preferred == "document": + return self._resolve_media_message_type(media_types[0] if media_types else "", default=MessageType.DOCUMENT) + return MessageType.TEXT + + async def _maybe_extract_text_document(self, cached_path: str, media_type: str) -> str: + if not cached_path or not media_type.startswith("text/"): + return "" + try: + if os.path.getsize(cached_path) > _MAX_TEXT_INJECT_BYTES: + return "" + ext = Path(cached_path).suffix.lower() + if ext not in {".txt", ".md"} and media_type not in {"text/plain", "text/markdown"}: + return "" + content = Path(cached_path).read_text(encoding="utf-8") + display_name = self._display_name_from_cached_path(cached_path) + return f"[Content of {display_name}]:\n{content}" + except (OSError, UnicodeDecodeError): + logger.warning("[Feishu] Failed to inject text document content from %s", cached_path, exc_info=True) + return "" + + async def _download_feishu_image(self, *, message_id: str, image_key: str) -> tuple[str, str]: + if not self._client or not message_id: + return "", "" + try: + request = self._build_message_resource_request( + message_id=message_id, + file_key=image_key, + resource_type="image", + ) + response = await asyncio.to_thread(self._client.im.v1.message_resource.get, request) + if not response or not response.success(): + logger.warning( + "[Feishu] Failed to download image %s: %s %s", + image_key, + getattr(response, "code", "unknown"), + getattr(response, "msg", "request failed"), + ) + return "", "" + raw_bytes = self._read_binary_response(response) + if not raw_bytes: + return "", "" + content_type = self._get_response_header(response, "Content-Type") + filename = getattr(response, "file_name", None) or f"{image_key}.jpg" + ext = self._guess_extension(filename, content_type, ".jpg", allowed=_IMAGE_EXTENSIONS) + cached_path = cache_image_from_bytes(raw_bytes, ext=ext) + media_type = self._normalize_media_type(content_type, default=self._default_image_media_type(ext)) + return cached_path, media_type + except Exception: + logger.warning("[Feishu] Failed to cache image resource %s", image_key, exc_info=True) + return "", "" + + async def _download_feishu_message_resource( + self, + *, + message_id: str, + file_key: str, + resource_type: str, + fallback_filename: str, + ) -> tuple[str, str]: + if not self._client or not message_id: + return "", "" + + request_types = [resource_type] + if resource_type in {"audio", "media"}: + request_types.append("file") + + for request_type in request_types: + try: + request = self._build_message_resource_request( + message_id=message_id, + file_key=file_key, + resource_type=request_type, + ) + response = await asyncio.to_thread(self._client.im.v1.message_resource.get, request) + if not response or not response.success(): + logger.debug( + "[Feishu] Resource download failed for %s/%s via type=%s: %s %s", + message_id, + file_key, + request_type, + getattr(response, "code", "unknown"), + getattr(response, "msg", "request failed"), + ) + continue + + raw_bytes = self._read_binary_response(response) + if not raw_bytes: + continue + content_type = self._get_response_header(response, "Content-Type") + response_filename = getattr(response, "file_name", None) or "" + filename = response_filename or fallback_filename or f"{request_type}_{file_key}" + media_type = self._normalize_media_type( + content_type, + default=self._guess_media_type_from_filename(filename), + ) + + if media_type.startswith("image/"): + ext = self._guess_extension(filename, content_type, ".jpg", allowed=_IMAGE_EXTENSIONS) + cached_path = cache_image_from_bytes(raw_bytes, ext=ext) + logger.info("[Feishu] Cached message image resource at %s", cached_path) + return cached_path, media_type or self._default_image_media_type(ext) + + if request_type == "audio" or media_type.startswith("audio/"): + ext = self._guess_extension(filename, content_type, ".ogg", allowed=_AUDIO_EXTENSIONS) + cached_path = cache_audio_from_bytes(raw_bytes, ext=ext) + logger.info("[Feishu] Cached message audio resource at %s", cached_path) + return cached_path, (media_type or f"audio/{ext.lstrip('.') or 'ogg'}") + + if media_type.startswith("video/"): + if not Path(filename).suffix: + filename = f"{filename}.mp4" + cached_path = cache_document_from_bytes(raw_bytes, filename) + logger.info("[Feishu] Cached message video resource at %s", cached_path) + return cached_path, media_type + + if not Path(filename).suffix and media_type in _DOCUMENT_MIME_TO_EXT: + filename = f"{filename}{_DOCUMENT_MIME_TO_EXT[media_type]}" + cached_path = cache_document_from_bytes(raw_bytes, filename) + logger.info("[Feishu] Cached message document resource at %s", cached_path) + return cached_path, (media_type or self._guess_document_media_type(filename)) + except Exception: + logger.warning( + "[Feishu] Failed to cache message resource %s/%s", + message_id, + file_key, + exc_info=True, + ) + return "", "" + + # ========================================================================= + # Static helpers — extension / media-type guessing + # ========================================================================= + + @staticmethod + def _read_binary_response(response: Any) -> bytes: + file_obj = getattr(response, "file", None) + if file_obj is None: + return b"" + if hasattr(file_obj, "getvalue"): + return bytes(file_obj.getvalue()) + return bytes(file_obj.read()) + + @staticmethod + def _get_response_header(response: Any, name: str) -> str: + raw = getattr(response, "raw", None) + headers = getattr(raw, "headers", {}) or {} + return str(headers.get(name, headers.get(name.lower(), "")) or "").split(";", 1)[0].strip().lower() + + @staticmethod + def _guess_extension(filename: str, content_type: str, default: str, *, allowed: set[str]) -> str: + ext = Path(filename or "").suffix.lower() + if ext in allowed: + return ext + guessed = mimetypes.guess_extension((content_type or "").split(";", 1)[0].strip().lower() or "") + if guessed in allowed: + return guessed + return default + + @staticmethod + def _normalize_media_type(content_type: str, *, default: str) -> str: + normalized = (content_type or "").split(";", 1)[0].strip().lower() + return normalized or default + + @staticmethod + def _guess_document_media_type(filename: str) -> str: + ext = Path(filename or "").suffix.lower() + return SUPPORTED_DOCUMENT_TYPES.get(ext, mimetypes.guess_type(filename or "")[0] or "application/octet-stream") + + @staticmethod + def _display_name_from_cached_path(path: str) -> str: + basename = os.path.basename(path) + parts = basename.split("_", 2) + display_name = parts[2] if len(parts) >= 3 else basename + return re.sub(r"[^\w.\- ]", "_", display_name) + + @staticmethod + def _guess_media_type_from_filename(filename: str) -> str: + guessed = (mimetypes.guess_type(filename or "")[0] or "").lower() + if guessed: + return guessed + ext = Path(filename or "").suffix.lower() + if ext in _VIDEO_EXTENSIONS: + return f"video/{ext.lstrip('.')}" + if ext in _AUDIO_EXTENSIONS: + return f"audio/{ext.lstrip('.')}" + if ext in _IMAGE_EXTENSIONS: + return FeishuAdapter._default_image_media_type(ext) + return "" + + @staticmethod + def _map_chat_type(raw_chat_type: str) -> str: + normalized = (raw_chat_type or "").strip().lower() + if normalized == "p2p": + return "dm" + if "topic" in normalized or "thread" in normalized or "forum" in normalized: + return "forum" + if normalized == "group": + return "group" + return "dm" + + @staticmethod + def _resolve_source_chat_type(*, chat_info: Dict[str, Any], event_chat_type: str) -> str: + resolved = str(chat_info.get("type") or "").strip().lower() + if resolved in {"group", "forum"}: + return resolved + if event_chat_type == "p2p": + return "dm" + return "group" + + async def _resolve_sender_profile(self, sender_id: Any) -> Dict[str, Optional[str]]: + open_id = getattr(sender_id, "open_id", None) or None + user_id = getattr(sender_id, "user_id", None) or None + union_id = getattr(sender_id, "union_id", None) or None + primary_id = open_id or user_id + display_name = await self._resolve_sender_name_from_api(primary_id or union_id) + return { + "user_id": primary_id, + "user_name": display_name, + "user_id_alt": union_id, + } + + async def _resolve_sender_name_from_api(self, sender_id: Optional[str]) -> Optional[str]: + """Fetch the sender's display name from the Feishu contact API with a 10-minute cache. + + ID-type detection mirrors openclaw: ou_ → open_id, on_ → union_id, else user_id. + Failures are silently suppressed; the message pipeline must not block on name resolution. + """ + if not sender_id or not self._client: + return None + trimmed = sender_id.strip() + if not trimmed: + return None + now = time.time() + cached = self._sender_name_cache.get(trimmed) + if cached is not None: + name, expire_at = cached + if now < expire_at: + return name + try: + from lark_oapi.api.contact.v3 import GetUserRequest # lazy import + if trimmed.startswith("ou_"): + id_type = "open_id" + elif trimmed.startswith("on_"): + id_type = "union_id" + else: + id_type = "user_id" + request = GetUserRequest.builder().user_id(trimmed).user_id_type(id_type).build() + response = await asyncio.to_thread(self._client.contact.v3.user.get, request) + if not response or not response.success(): + return None + user = getattr(getattr(response, "data", None), "user", None) + name = ( + getattr(user, "name", None) + or getattr(user, "display_name", None) + or getattr(user, "nickname", None) + or getattr(user, "en_name", None) + ) + if name and isinstance(name, str): + name = name.strip() + if name: + self._sender_name_cache[trimmed] = (name, now + _FEISHU_SENDER_NAME_TTL_SECONDS) + return name + except Exception: + logger.debug("[Feishu] Failed to resolve sender name for %s", sender_id, exc_info=True) + return None + + async def _fetch_message_text(self, message_id: str) -> Optional[str]: + if not self._client or not message_id: + return None + if message_id in self._message_text_cache: + return self._message_text_cache[message_id] + try: + request = self._build_get_message_request(message_id) + response = await asyncio.to_thread(self._client.im.v1.message.get, request) + if not response or getattr(response, "success", lambda: False)() is False: + code = getattr(response, "code", "unknown") + msg = getattr(response, "msg", "message lookup failed") + logger.warning("[Feishu] Failed to fetch parent message %s: [%s] %s", message_id, code, msg) + return None + items = getattr(getattr(response, "data", None), "items", None) or [] + parent = items[0] if items else None + body = getattr(parent, "body", None) + msg_type = getattr(parent, "msg_type", "") or "" + raw_content = getattr(body, "content", "") or "" + text = self._extract_text_from_raw_content(msg_type=msg_type, raw_content=raw_content) + self._message_text_cache[message_id] = text + return text + except Exception: + logger.warning("[Feishu] Failed to fetch parent message %s", message_id, exc_info=True) + return None + + def _extract_text_from_raw_content(self, *, msg_type: str, raw_content: str) -> Optional[str]: + normalized = normalize_feishu_message(message_type=msg_type, raw_content=raw_content) + if normalized.text_content: + return normalized.text_content + placeholder = normalized.metadata.get("placeholder_text") if isinstance(normalized.metadata, dict) else None + return str(placeholder).strip() or None + + @staticmethod + def _default_image_media_type(ext: str) -> str: + normalized_ext = (ext or "").lower() + if normalized_ext in {".jpg", ".jpeg"}: + return "image/jpeg" + return f"image/{normalized_ext.lstrip('.') or 'jpeg'}" + + @staticmethod + def _log_background_failure(future: Any) -> None: + try: + future.result() + except Exception: + logger.exception("[Feishu] Background inbound processing failed") + + # ========================================================================= + # Group policy and mention gating + # ========================================================================= + + def _allow_group_message(self, sender_id: Any, chat_id: str = "") -> bool: + """Per-group policy gate for non-DM traffic.""" + sender_open_id = getattr(sender_id, "open_id", None) + sender_user_id = getattr(sender_id, "user_id", None) + sender_ids = {sender_open_id, sender_user_id} - {None} + + if sender_ids and self._admins and (sender_ids & self._admins): + return True + + rule = self._group_rules.get(chat_id) if chat_id else None + if rule: + policy = rule.policy + allowlist = rule.allowlist + blacklist = rule.blacklist + else: + policy = self._default_group_policy or self._group_policy + allowlist = self._allowed_group_users + blacklist = set() + + if policy == "disabled": + return False + if policy == "open": + return True + if policy == "admin_only": + return False + if policy == "allowlist": + return bool(sender_ids and (sender_ids & allowlist)) + if policy == "blacklist": + return bool(sender_ids and not (sender_ids & blacklist)) + + return bool(sender_ids and (sender_ids & self._allowed_group_users)) + + def _should_accept_group_message(self, message: Any, sender_id: Any, chat_id: str = "") -> bool: + """Require an explicit @mention before group messages enter the agent.""" + if not self._allow_group_message(sender_id, chat_id): + return False + # @_all is Feishu's @everyone placeholder — always route to the bot. + raw_content = getattr(message, "content", "") or "" + if "@_all" in raw_content: + return True + mentions = getattr(message, "mentions", None) or [] + if mentions: + return self._message_mentions_bot(mentions) + normalized = normalize_feishu_message( + message_type=getattr(message, "message_type", "") or "", + raw_content=raw_content, + ) + if normalized.mentioned_ids: + return self._post_mentions_bot(normalized.mentioned_ids) + return False + + def _message_mentions_bot(self, mentions: List[Any]) -> bool: + """Check whether any mention targets the configured or inferred bot identity.""" + for mention in mentions: + mention_id = getattr(mention, "id", None) + mention_open_id = getattr(mention_id, "open_id", None) + mention_user_id = getattr(mention_id, "user_id", None) + mention_name = (getattr(mention, "name", None) or "").strip() + + if self._bot_open_id and mention_open_id == self._bot_open_id: + return True + if self._bot_user_id and mention_user_id == self._bot_user_id: + return True + if self._bot_name and mention_name == self._bot_name: + return True + + return False + + def _post_mentions_bot(self, mentioned_ids: List[str]) -> bool: + if not mentioned_ids: + return False + if self._bot_open_id and self._bot_open_id in mentioned_ids: + return True + if self._bot_user_id and self._bot_user_id in mentioned_ids: + return True + return False + + async def _hydrate_bot_identity(self) -> None: + """Best-effort discovery of bot identity for precise group mention gating.""" + if not self._client: + return + if any((self._bot_open_id, self._bot_user_id, self._bot_name)): + return + try: + request = self._build_get_application_request(app_id=self._app_id, lang="en_us") + response = await asyncio.to_thread(self._client.application.v6.application.get, request) + if not response or not response.success(): + code = getattr(response, "code", None) + if code == 99991672: + logger.warning( + "[Feishu] Unable to hydrate bot identity from application info. " + "Grant admin:app.info:readonly or application:application:self_manage " + "so group @mention gating can resolve the bot name precisely." + ) + return + app = getattr(getattr(response, "data", None), "app", None) + app_name = (getattr(app, "app_name", None) or "").strip() + if app_name: + self._bot_name = app_name + except Exception: + logger.debug("[Feishu] Failed to hydrate bot identity", exc_info=True) + + # ========================================================================= + # Deduplication — seen message ID cache (persistent) + # ========================================================================= + + def _load_seen_message_ids(self) -> None: + try: + payload = json.loads(self._dedup_state_path.read_text(encoding="utf-8")) + except FileNotFoundError: + return + except (OSError, json.JSONDecodeError): + logger.warning("[Feishu] Failed to load persisted dedup state from %s", self._dedup_state_path, exc_info=True) + return + seen_data = payload.get("message_ids", {}) if isinstance(payload, dict) else {} + now = time.time() + ttl = _FEISHU_DEDUP_TTL_SECONDS + # Backward-compat: old format stored a plain list of IDs (no timestamps). + if isinstance(seen_data, list): + entries: Dict[str, float] = {str(item).strip(): 0.0 for item in seen_data if str(item).strip()} + elif isinstance(seen_data, dict): + entries = {k: float(v) for k, v in seen_data.items() if isinstance(k, str) and k.strip()} + else: + return + # Filter out TTL-expired entries (entries saved with ts=0.0 are treated as immortal + # for one migration cycle to avoid nuking old data on first upgrade). + valid: Dict[str, float] = { + msg_id: ts for msg_id, ts in entries.items() + if ts == 0.0 or ttl <= 0 or now - ts < ttl + } + # Apply size cap; keep the most recently seen IDs. + sorted_ids = sorted(valid, key=lambda k: valid[k], reverse=True)[:self._dedup_cache_size] + self._seen_message_order = list(reversed(sorted_ids)) + self._seen_message_ids = {k: valid[k] for k in sorted_ids} + + def _persist_seen_message_ids(self) -> None: + try: + self._dedup_state_path.parent.mkdir(parents=True, exist_ok=True) + recent = self._seen_message_order[-self._dedup_cache_size:] + # Save as {msg_id: timestamp} so TTL filtering works across restarts. + payload = {"message_ids": {k: self._seen_message_ids[k] for k in recent if k in self._seen_message_ids}} + self._dedup_state_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + except OSError: + logger.warning("[Feishu] Failed to persist dedup state to %s", self._dedup_state_path, exc_info=True) + + def _is_duplicate(self, message_id: str) -> bool: + now = time.time() + ttl = _FEISHU_DEDUP_TTL_SECONDS + with self._dedup_lock: + seen_at = self._seen_message_ids.get(message_id) + if seen_at is not None and (ttl <= 0 or now - seen_at < ttl): + return True + # Record with current wall-clock timestamp so TTL works across restarts. + self._seen_message_ids[message_id] = now + self._seen_message_order.append(message_id) + while len(self._seen_message_order) > self._dedup_cache_size: + stale = self._seen_message_order.pop(0) + self._seen_message_ids.pop(stale, None) + self._persist_seen_message_ids() + return False + + # ========================================================================= + # Outbound payload construction and send pipeline + # ========================================================================= + + def _build_outbound_payload(self, content: str) -> tuple[str, str]: + if _MARKDOWN_HINT_RE.search(content): + return "post", _build_markdown_post_payload(content) + text_payload = {"text": content} + return "text", json.dumps(text_payload, ensure_ascii=False) + + async def _send_uploaded_file_message( + self, + *, + chat_id: str, + file_path: str, + reply_to: Optional[str], + metadata: Optional[Dict[str, Any]], + caption: Optional[str] = None, + file_name: Optional[str] = None, + outbound_message_type: str = "file", + ) -> SendResult: + if not self._client: + return SendResult(success=False, error="Not connected") + if not os.path.exists(file_path): + return SendResult(success=False, error=f"File not found: {file_path}") + + display_name = file_name or os.path.basename(file_path) + upload_file_type, resolved_message_type = self._resolve_outbound_file_routing( + file_path=display_name, + requested_message_type=outbound_message_type, + ) + try: + with open(file_path, "rb") as file_obj: + body = self._build_file_upload_body( + file_type=upload_file_type, + file_name=display_name, + file=file_obj, + ) + request = self._build_file_upload_request(body) + upload_response = await asyncio.to_thread(self._client.im.v1.file.create, request) + file_key = self._extract_response_field(upload_response, "file_key") + if not file_key: + return self._response_error_result( + upload_response, + default_message="file upload failed", + override_error="Feishu file upload missing file_key", + ) + + if caption: + media_tag = { + "tag": "media", + "file_key": file_key, + "file_name": display_name, + } + message_response = await self._feishu_send_with_retry( + chat_id=chat_id, + msg_type="post", + payload=self._build_media_post_payload(caption=caption, media_tag=media_tag), + reply_to=reply_to, + metadata=metadata, + ) + else: + message_response = await self._feishu_send_with_retry( + chat_id=chat_id, + msg_type=resolved_message_type, + payload=json.dumps({"file_key": file_key}, ensure_ascii=False), + reply_to=reply_to, + metadata=metadata, + ) + return self._finalize_send_result(message_response, "file send failed") + except Exception as exc: + logger.error("[Feishu] Failed to send file %s: %s", file_path, exc, exc_info=True) + return SendResult(success=False, error=str(exc)) + + async def _send_raw_message( + self, + *, + chat_id: str, + msg_type: str, + payload: str, + reply_to: Optional[str], + metadata: Optional[Dict[str, Any]], + ) -> Any: + reply_in_thread = bool((metadata or {}).get("thread_id")) + if reply_to: + body = self._build_reply_message_body( + content=payload, + msg_type=msg_type, + reply_in_thread=reply_in_thread, + uuid_value=str(uuid.uuid4()), + ) + request = self._build_reply_message_request(reply_to, body) + return await asyncio.to_thread(self._client.im.v1.message.reply, request) + + body = self._build_create_message_body( + receive_id=chat_id, + msg_type=msg_type, + content=payload, + uuid_value=str(uuid.uuid4()), + ) + request = self._build_create_message_request("chat_id", body) + return await asyncio.to_thread(self._client.im.v1.message.create, request) + + @staticmethod + def _response_succeeded(response: Any) -> bool: + return bool(response and getattr(response, "success", lambda: False)()) + + @staticmethod + def _extract_response_field(response: Any, field_name: str) -> Any: + if not FeishuAdapter._response_succeeded(response): + return None + data = getattr(response, "data", None) + return getattr(data, field_name, None) if data else None + + def _response_error_result( + self, + response: Any, + *, + default_message: str, + override_error: Optional[str] = None, + ) -> SendResult: + if override_error: + return SendResult(success=False, error=override_error, raw_response=response) + code = getattr(response, "code", "unknown") + msg = getattr(response, "msg", default_message) + return SendResult(success=False, error=f"[{code}] {msg}", raw_response=response) + + def _finalize_send_result(self, response: Any, default_message: str) -> SendResult: + if not self._response_succeeded(response): + return self._response_error_result(response, default_message=default_message) + return SendResult( + success=True, + message_id=self._extract_response_field(response, "message_id"), + raw_response=response, + ) + + # ========================================================================= + # Connection internals — websocket / webhook setup + # ========================================================================= + + async def _connect_with_retry(self) -> None: + for attempt in range(_FEISHU_CONNECT_ATTEMPTS): + try: + if self._connection_mode == "websocket": + await self._connect_websocket() + else: + await self._connect_webhook() + return + except Exception as exc: + self._running = False + self._disable_websocket_auto_reconnect() + self._ws_future = None + await self._stop_webhook_server() + if attempt >= _FEISHU_CONNECT_ATTEMPTS - 1: + raise + wait_seconds = 2 ** attempt + logger.warning( + "[Feishu] Connect attempt %d/%d failed; retrying in %ds: %s", + attempt + 1, + _FEISHU_CONNECT_ATTEMPTS, + wait_seconds, + exc, + ) + await asyncio.sleep(wait_seconds) + + async def _connect_websocket(self) -> None: + if not FEISHU_WEBSOCKET_AVAILABLE: + raise RuntimeError("websockets not installed; websocket mode unavailable") + domain = FEISHU_DOMAIN if self._domain_name != "lark" else LARK_DOMAIN + self._client = self._build_lark_client(domain) + self._event_handler = self._build_event_handler() + if self._event_handler is None: + raise RuntimeError("failed to build Feishu event handler") + loop = self._loop + if loop is None or loop.is_closed(): + raise RuntimeError("adapter loop is not ready") + await self._hydrate_bot_identity() + self._ws_client = FeishuWSClient( + app_id=self._app_id, + app_secret=self._app_secret, + log_level=lark.LogLevel.INFO, + event_handler=self._event_handler, + domain=domain, + ) + self._ws_future = loop.run_in_executor( + None, + _run_official_feishu_ws_client, + self._ws_client, + self, + ) + + async def _connect_webhook(self) -> None: + if not FEISHU_WEBHOOK_AVAILABLE: + raise RuntimeError("aiohttp not installed; webhook mode unavailable") + domain = FEISHU_DOMAIN if self._domain_name != "lark" else LARK_DOMAIN + self._client = self._build_lark_client(domain) + self._event_handler = self._build_event_handler() + if self._event_handler is None: + raise RuntimeError("failed to build Feishu event handler") + await self._hydrate_bot_identity() + app = web.Application() + app.router.add_post(self._webhook_path, self._handle_webhook_request) + self._webhook_runner = web.AppRunner(app) + await self._webhook_runner.setup() + self._webhook_site = web.TCPSite(self._webhook_runner, self._webhook_host, self._webhook_port) + await self._webhook_site.start() + + def _build_lark_client(self, domain: Any) -> Any: + return ( + lark.Client.builder() + .app_id(self._app_id) + .app_secret(self._app_secret) + .domain(domain) + .log_level(lark.LogLevel.WARNING) + .build() + ) + + async def _feishu_send_with_retry( + self, + *, + chat_id: str, + msg_type: str, + payload: str, + reply_to: Optional[str], + metadata: Optional[Dict[str, Any]], + ) -> Any: + last_error: Optional[Exception] = None + active_reply_to = reply_to + for attempt in range(_FEISHU_SEND_ATTEMPTS): + try: + response = await self._send_raw_message( + chat_id=chat_id, + msg_type=msg_type, + payload=payload, + reply_to=active_reply_to, + metadata=metadata, + ) + # If replying to a message failed because it was withdrawn or not found, + # fall back to posting a new message directly to the chat. + if active_reply_to and not self._response_succeeded(response): + code = getattr(response, "code", None) + if code in _FEISHU_REPLY_FALLBACK_CODES: + logger.warning( + "[Feishu] Reply to %s failed (code %s — message withdrawn/missing); " + "falling back to new message in chat %s", + active_reply_to, + code, + chat_id, + ) + active_reply_to = None + response = await self._send_raw_message( + chat_id=chat_id, + msg_type=msg_type, + payload=payload, + reply_to=None, + metadata=metadata, + ) + return response + except Exception as exc: + last_error = exc + if msg_type == "post" and _POST_CONTENT_INVALID_RE.search(str(exc)): + raise + if attempt >= _FEISHU_SEND_ATTEMPTS - 1: + raise + wait_seconds = 2 ** attempt + logger.warning( + "[Feishu] Send attempt %d/%d failed for chat %s; retrying in %ds: %s", + attempt + 1, + _FEISHU_SEND_ATTEMPTS, + chat_id, + wait_seconds, + exc, + ) + await asyncio.sleep(wait_seconds) + raise last_error or RuntimeError("Feishu send failed") + + async def _release_app_lock(self) -> None: + if not self._app_lock_identity: + return + try: + release_scoped_lock(_FEISHU_APP_LOCK_SCOPE, self._app_lock_identity) + except Exception as exc: + logger.warning("[Feishu] Failed to release app lock: %s", exc, exc_info=True) + finally: + self._app_lock_identity = None + + # ========================================================================= + # Lark API request builders + # ========================================================================= + + @staticmethod + def _build_get_chat_request(chat_id: str) -> Any: + if "GetChatRequest" in globals(): + return GetChatRequest.builder().chat_id(chat_id).build() + return SimpleNamespace(chat_id=chat_id) + + @staticmethod + def _build_get_message_request(message_id: str) -> Any: + if "GetMessageRequest" in globals(): + return GetMessageRequest.builder().message_id(message_id).build() + return SimpleNamespace(message_id=message_id) + + @staticmethod + def _build_message_resource_request(*, message_id: str, file_key: str, resource_type: str) -> Any: + if "GetMessageResourceRequest" in globals(): + return ( + GetMessageResourceRequest.builder() + .message_id(message_id) + .file_key(file_key) + .type(resource_type) + .build() + ) + return SimpleNamespace(message_id=message_id, file_key=file_key, type=resource_type) + + @staticmethod + def _build_get_application_request(*, app_id: str, lang: str) -> Any: + if "GetApplicationRequest" in globals(): + return ( + GetApplicationRequest.builder() + .app_id(app_id) + .lang(lang) + .build() + ) + return SimpleNamespace(app_id=app_id, lang=lang) + + @staticmethod + def _build_reply_message_body(*, content: str, msg_type: str, reply_in_thread: bool, uuid_value: str) -> Any: + if "ReplyMessageRequestBody" in globals(): + return ( + ReplyMessageRequestBody.builder() + .content(content) + .msg_type(msg_type) + .reply_in_thread(reply_in_thread) + .uuid(uuid_value) + .build() + ) + return SimpleNamespace( + content=content, + msg_type=msg_type, + reply_in_thread=reply_in_thread, + uuid=uuid_value, + ) + + @staticmethod + def _build_reply_message_request(message_id: str, request_body: Any) -> Any: + if "ReplyMessageRequest" in globals(): + return ( + ReplyMessageRequest.builder() + .message_id(message_id) + .request_body(request_body) + .build() + ) + return SimpleNamespace(message_id=message_id, request_body=request_body) + + @staticmethod + def _build_update_message_body(*, msg_type: str, content: str) -> Any: + if "UpdateMessageRequestBody" in globals(): + return ( + UpdateMessageRequestBody.builder() + .msg_type(msg_type) + .content(content) + .build() + ) + return SimpleNamespace(msg_type=msg_type, content=content) + + @staticmethod + def _build_update_message_request(message_id: str, request_body: Any) -> Any: + if "UpdateMessageRequest" in globals(): + return ( + UpdateMessageRequest.builder() + .message_id(message_id) + .request_body(request_body) + .build() + ) + return SimpleNamespace(message_id=message_id, request_body=request_body) + + @staticmethod + def _build_create_message_body(*, receive_id: str, msg_type: str, content: str, uuid_value: str) -> Any: + if "CreateMessageRequestBody" in globals(): + return ( + CreateMessageRequestBody.builder() + .receive_id(receive_id) + .msg_type(msg_type) + .content(content) + .uuid(uuid_value) + .build() + ) + return SimpleNamespace( + receive_id=receive_id, + msg_type=msg_type, + content=content, + uuid=uuid_value, + ) + + @staticmethod + def _build_create_message_request(receive_id_type: str, request_body: Any) -> Any: + if "CreateMessageRequest" in globals(): + return ( + CreateMessageRequest.builder() + .receive_id_type(receive_id_type) + .request_body(request_body) + .build() + ) + return SimpleNamespace(receive_id_type=receive_id_type, request_body=request_body) + + @staticmethod + def _build_image_upload_body(*, image_type: str, image: Any) -> Any: + if "CreateImageRequestBody" in globals(): + return ( + CreateImageRequestBody.builder() + .image_type(image_type) + .image(image) + .build() + ) + return SimpleNamespace(image_type=image_type, image=image) + + @staticmethod + def _build_image_upload_request(request_body: Any) -> Any: + if "CreateImageRequest" in globals(): + return CreateImageRequest.builder().request_body(request_body).build() + return SimpleNamespace(request_body=request_body) + + @staticmethod + def _build_file_upload_body(*, file_type: str, file_name: str, file: Any) -> Any: + if "CreateFileRequestBody" in globals(): + return ( + CreateFileRequestBody.builder() + .file_type(file_type) + .file_name(file_name) + .file(file) + .build() + ) + return SimpleNamespace(file_type=file_type, file_name=file_name, file=file) + + @staticmethod + def _build_file_upload_request(request_body: Any) -> Any: + if "CreateFileRequest" in globals(): + return CreateFileRequest.builder().request_body(request_body).build() + return SimpleNamespace(request_body=request_body) + + def _build_post_payload(self, content: str) -> str: + return _build_markdown_post_payload(content) + + def _build_media_post_payload(self, *, caption: str, media_tag: Dict[str, str]) -> str: + payload = json.loads(self._build_post_payload(caption)) + content = payload.setdefault("zh_cn", {}).setdefault("content", []) + content.append([media_tag]) + return json.dumps(payload, ensure_ascii=False) + + @staticmethod + def _resolve_outbound_file_routing( + *, + file_path: str, + requested_message_type: str, + ) -> tuple[str, str]: + ext = Path(file_path).suffix.lower() + + if ext in _FEISHU_OPUS_UPLOAD_EXTENSIONS: + return "opus", "audio" + + if ext in _FEISHU_MEDIA_UPLOAD_EXTENSIONS: + return "mp4", "media" + + if ext in _FEISHU_DOC_UPLOAD_TYPES: + return _FEISHU_DOC_UPLOAD_TYPES[ext], "file" + + if requested_message_type == "file": + return _FEISHU_FILE_UPLOAD_TYPE, "file" + + return _FEISHU_FILE_UPLOAD_TYPE, "file" + + +# ============================================================================= +# QR scan-to-create onboarding +# +# Device-code flow: user scans a QR code with Feishu/Lark mobile app and the +# platform creates a fully configured bot application automatically. +# Called by `hermes gateway setup` via _setup_feishu() in hermes_cli/gateway.py. +# ============================================================================= + + +def _accounts_base_url(domain: str) -> str: + return _ONBOARD_ACCOUNTS_URLS.get(domain, _ONBOARD_ACCOUNTS_URLS["feishu"]) + + +def _onboard_open_base_url(domain: str) -> str: + return _ONBOARD_OPEN_URLS.get(domain, _ONBOARD_OPEN_URLS["feishu"]) + + +def _post_registration(base_url: str, body: Dict[str, str]) -> dict: + """POST form-encoded data to the registration endpoint, return parsed JSON. + + The registration endpoint returns JSON even on 4xx (e.g. poll returns + authorization_pending as a 400). We always parse the body regardless of + HTTP status. + """ + url = f"{base_url}{_REGISTRATION_PATH}" + data = urlencode(body).encode("utf-8") + req = Request(url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) + try: + with urlopen(req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp: + return json.loads(resp.read().decode("utf-8")) + except HTTPError as exc: + body_bytes = exc.read() + if body_bytes: + try: + return json.loads(body_bytes.decode("utf-8")) + except (ValueError, json.JSONDecodeError): + raise exc from None + raise + + +def _init_registration(domain: str = "feishu") -> None: + """Verify the environment supports client_secret auth. + + Raises RuntimeError if not supported. + """ + base_url = _accounts_base_url(domain) + res = _post_registration(base_url, {"action": "init"}) + methods = res.get("supported_auth_methods") or [] + if "client_secret" not in methods: + raise RuntimeError( + f"Feishu / Lark registration environment does not support client_secret auth. " + f"Supported: {methods}" + ) + + +def _begin_registration(domain: str = "feishu") -> dict: + """Start the device-code flow. Returns device_code, qr_url, user_code, interval, expire_in.""" + base_url = _accounts_base_url(domain) + res = _post_registration(base_url, { + "action": "begin", + "archetype": "PersonalAgent", + "auth_method": "client_secret", + "request_user_info": "open_id", + }) + device_code = res.get("device_code") + if not device_code: + raise RuntimeError("Feishu / Lark registration did not return a device_code") + qr_url = res.get("verification_uri_complete", "") + if "?" in qr_url: + qr_url += "&from=hermes&tp=hermes" + else: + qr_url += "?from=hermes&tp=hermes" + return { + "device_code": device_code, + "qr_url": qr_url, + "user_code": res.get("user_code", ""), + "interval": res.get("interval") or 5, + "expire_in": res.get("expire_in") or 600, + } + + +def _poll_registration( + *, + device_code: str, + interval: int, + expire_in: int, + domain: str = "feishu", +) -> Optional[dict]: + """Poll until the user scans the QR code, or timeout/denial. + + Returns dict with app_id, app_secret, domain, open_id on success. + Returns None on failure. + """ + deadline = time.time() + expire_in + current_domain = domain + domain_switched = False + poll_count = 0 + + while time.time() < deadline: + base_url = _accounts_base_url(current_domain) + try: + res = _post_registration(base_url, { + "action": "poll", + "device_code": device_code, + "tp": "ob_app", + }) + except (URLError, OSError, json.JSONDecodeError): + time.sleep(interval) + continue + + poll_count += 1 + if poll_count == 1: + print(" Fetching configuration results...", end="", flush=True) + elif poll_count % 6 == 0: + print(".", end="", flush=True) + + # Domain auto-detection + user_info = res.get("user_info") or {} + tenant_brand = user_info.get("tenant_brand") + if tenant_brand == "lark" and not domain_switched: + current_domain = "lark" + domain_switched = True + # Fall through — server may return credentials in this same response. + + # Success + if res.get("client_id") and res.get("client_secret"): + if poll_count > 0: + print() # newline after "Fetching configuration results..." dots + return { + "app_id": res["client_id"], + "app_secret": res["client_secret"], + "domain": current_domain, + "open_id": user_info.get("open_id"), + } + + # Terminal errors + error = res.get("error", "") + if error in ("access_denied", "expired_token"): + if poll_count > 0: + print() + logger.warning("[Feishu onboard] Registration %s", error) + return None + + # authorization_pending or unknown — keep polling + time.sleep(interval) + + if poll_count > 0: + print() + logger.warning("[Feishu onboard] Poll timed out after %ds", expire_in) + return None + + +try: + import qrcode as _qrcode_mod +except (ImportError, TypeError): + _qrcode_mod = None # type: ignore[assignment] + + +def _render_qr(url: str) -> bool: + """Try to render a QR code in the terminal. Returns True if successful.""" + if _qrcode_mod is None: + return False + try: + qr = _qrcode_mod.QRCode() + qr.add_data(url) + qr.make(fit=True) + qr.print_ascii(invert=True) + return True + except Exception: + return False + + +def probe_bot(app_id: str, app_secret: str, domain: str) -> Optional[dict]: + """Verify bot connectivity via /open-apis/bot/v3/info. + + Uses lark_oapi SDK when available, falls back to raw HTTP otherwise. + Returns {"bot_name": ..., "bot_open_id": ...} on success, None on failure. + """ + if FEISHU_AVAILABLE: + return _probe_bot_sdk(app_id, app_secret, domain) + return _probe_bot_http(app_id, app_secret, domain) + + +def _build_onboard_client(app_id: str, app_secret: str, domain: str) -> Any: + """Build a lark Client for the given credentials and domain.""" + sdk_domain = LARK_DOMAIN if domain == "lark" else FEISHU_DOMAIN + return ( + lark.Client.builder() + .app_id(app_id) + .app_secret(app_secret) + .domain(sdk_domain) + .log_level(lark.LogLevel.WARNING) + .build() + ) + + +def _parse_bot_response(data: dict) -> Optional[dict]: + """Extract bot_name and bot_open_id from a /bot/v3/info response.""" + if data.get("code") != 0: + return None + bot = data.get("bot") or data.get("data", {}).get("bot") or {} + return { + "bot_name": bot.get("bot_name"), + "bot_open_id": bot.get("open_id"), + } + + +def _probe_bot_sdk(app_id: str, app_secret: str, domain: str) -> Optional[dict]: + """Probe bot info using lark_oapi SDK.""" + try: + client = _build_onboard_client(app_id, app_secret, domain) + resp = client.request( + method="GET", + url="/open-apis/bot/v3/info", + body=None, + raw_response=True, + ) + return _parse_bot_response(json.loads(resp.content)) + except Exception as exc: + logger.debug("[Feishu onboard] SDK probe failed: %s", exc) + return None + + +def _probe_bot_http(app_id: str, app_secret: str, domain: str) -> Optional[dict]: + """Fallback probe using raw HTTP (when lark_oapi is not installed).""" + base_url = _onboard_open_base_url(domain) + try: + token_data = json.dumps({"app_id": app_id, "app_secret": app_secret}).encode("utf-8") + token_req = Request( + f"{base_url}/open-apis/auth/v3/tenant_access_token/internal", + data=token_data, + headers={"Content-Type": "application/json"}, + ) + with urlopen(token_req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp: + token_res = json.loads(resp.read().decode("utf-8")) + + access_token = token_res.get("tenant_access_token") + if not access_token: + return None + + bot_req = Request( + f"{base_url}/open-apis/bot/v3/info", + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + ) + with urlopen(bot_req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp: + bot_res = json.loads(resp.read().decode("utf-8")) + + return _parse_bot_response(bot_res) + except (URLError, OSError, KeyError, json.JSONDecodeError) as exc: + logger.debug("[Feishu onboard] HTTP probe failed: %s", exc) + return None + + +def qr_register( + *, + initial_domain: str = "feishu", + timeout_seconds: int = 600, +) -> Optional[dict]: + """Run the Feishu / Lark scan-to-create QR registration flow. + + Returns on success:: + + { + "app_id": str, + "app_secret": str, + "domain": "feishu" | "lark", + "open_id": str | None, + "bot_name": str | None, + "bot_open_id": str | None, + } + + Returns None on expected failures (network, auth denied, timeout). + Unexpected errors (bugs, protocol regressions) propagate to the caller. + """ + try: + return _qr_register_inner(initial_domain=initial_domain, timeout_seconds=timeout_seconds) + except (RuntimeError, URLError, OSError, json.JSONDecodeError) as exc: + logger.warning("[Feishu onboard] Registration failed: %s", exc) + return None + + +def _qr_register_inner( + *, + initial_domain: str, + timeout_seconds: int, +) -> Optional[dict]: + """Run init → begin → poll → probe. Raises on network/protocol errors.""" + print(" Connecting to Feishu / Lark...", end="", flush=True) + _init_registration(initial_domain) + begin = _begin_registration(initial_domain) + print(" done.") + + print() + qr_url = begin["qr_url"] + if _render_qr(qr_url): + print(f"\n Scan the QR code above, or open this URL directly:\n {qr_url}") + else: + print(f" Open this URL in Feishu / Lark on your phone:\n\n {qr_url}\n") + print(" Tip: pip install qrcode to display a scannable QR code here next time") + print() + + result = _poll_registration( + device_code=begin["device_code"], + interval=begin["interval"], + expire_in=min(begin["expire_in"], timeout_seconds), + domain=initial_domain, + ) + if not result: + return None + + # Probe bot — best-effort, don't fail the registration + bot_info = probe_bot(result["app_id"], result["app_secret"], result["domain"]) + if bot_info: + result["bot_name"] = bot_info.get("bot_name") + result["bot_open_id"] = bot_info.get("bot_open_id") + else: + result["bot_name"] = None + result["bot_open_id"] = None + + return result diff --git a/mindcli/_vendor/gateway/platforms/flash_asr.py b/mindcli/_vendor/gateway/platforms/flash_asr.py new file mode 100644 index 0000000..5c4a3da --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/flash_asr.py @@ -0,0 +1,193 @@ +""" +flash_asr.py — AnyFile2MD 音频转写模块 v2.0 + +架构修正(2026-04-15): + ❌ 旧方案:base64 multipart → DashScope 同步 API(10MB 限制) + ✅ 新方案:OSS 签名 URL → DashScope paraformer-v2 异步任务(无大小限制) + +职责(封闭函数,铁律 1): + transcribe_from_oss_url(url) → AsyncIterator[{"text", "begin_ms", "end_ms"}] + generate_oss_presign(user_id, ext) → {"upload_url", "oss_key", "read_url"} + is_supported_audio(filename) → bool + +不调用 LLM,不写 DB,不持久化任何状态。 +""" + +import asyncio +import hmac +import hashlib +import base64 +import os +import time +import logging +from datetime import datetime, timezone +from typing import AsyncIterator +from urllib.parse import quote + +import httpx + +logger = logging.getLogger(__name__) + +# ─── 支持格式 ──────────────────────────────────────────────── +SUPPORTED_EXTENSIONS = { + ".mp3", ".mp4", ".m4a", ".wav", ".webm", + ".ogg", ".aac", ".flac", ".opus", ".amr", +} + +def is_supported_audio(filename: str) -> bool: + ext = os.path.splitext(filename)[1].lower() + return ext in SUPPORTED_EXTENSIONS + + +# ─── OSS 配置 ───────────────────────────────────────────────── +def _oss_config() -> dict: + return { + "access_key_id": os.getenv("OSS_ACCESS_KEY_ID", ""), + "access_key_secret": os.getenv("OSS_ACCESS_KEY_SECRET", ""), + "bucket": os.getenv("OSS_BUCKET", "meetings-dev"), + "endpoint": os.getenv("OSS_ENDPOINT", "oss-cn-guangzhou.aliyuncs.com"), + "prefix": os.getenv("OSS_AUDIO_PREFIX", "mindos-next/audio"), + } + + +def generate_oss_presign(user_id: str, ext: str, expire_seconds: int = 3600, + prefix: str | None = None) -> dict: + """ + 生成 OSS 预签名 URL(使用 oss2 SDK,V2 验证过的可靠方式)。 + 返回:{ upload_url, oss_key, read_url, content_type } + - upload_url :前端直传用(HTTP PUT,必须带 Content-Type: application/octet-stream) + - read_url :交给 DashScope paraformer-v2 下载用 + - content_type:前端 PUT 时必须传此 Content-Type + + Args: + prefix: OSS 路径前缀。默认 None 时使用 OSS_AUDIO_PREFIX 环境变量。 + 文档管线传 "mindos-next/docs",音频管线不传(使用默认值)。 + """ + import oss2 # type: ignore + + cfg = _oss_config() + if not cfg["access_key_id"] or not cfg["access_key_secret"]: + raise RuntimeError("[FlashASR] OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET 未配置") + + actual_prefix = prefix or cfg["prefix"] + ts = int(time.time()) + oss_key = f"{actual_prefix}/{user_id}/{ts}_{user_id[:8]}{ext}" + + auth = oss2.Auth(cfg["access_key_id"], cfg["access_key_secret"]) + bucket = oss2.Bucket(auth, f"https://{cfg['endpoint']}", cfg["bucket"]) + + # Content-Type 必须写入签名,前端 PUT 时须传相同值 + content_type = "application/octet-stream" + upload_url = bucket.sign_url( + "PUT", oss_key, expire_seconds, + slash_safe=True, + headers={"Content-Type": content_type}, + ) + read_url = bucket.sign_url("GET", oss_key, expire_seconds, slash_safe=True) + + host = f"{cfg['bucket']}.{cfg['endpoint']}" + logger.info("[FlashASR] presign ok oss_key=%s", oss_key) + + return { + "upload_url": upload_url, + "read_url": read_url, + "oss_key": oss_key, + "host": host, + "content_type": content_type, + } + + +# ─── DashScope paraformer-v2 异步任务 ───────────────────────── +DASHSCOPE_API_KEY = lambda: os.getenv("DASHSCOPE_API_KEY", "") +DASHSCOPE_HOST = "https://dashscope.aliyuncs.com" +TRANSCRIPTION_URL = f"{DASHSCOPE_HOST}/api/v1/services/audio/asr/transcription" + +_HEADERS = lambda: { + "Authorization": f"Bearer {DASHSCOPE_API_KEY()}", + "Content-Type": "application/json", + "X-DashScope-Async": "enable", +} + + +async def _submit_task(file_url: str) -> str: + """提交 paraformer-v2 异步转写任务,返回 task_id。""" + body = { + "model": "paraformer-v2", + "input": {"file_urls": [file_url]}, + "parameters": { + "language_hints": ["zh", "en"], + "timestamp_alignment": True, + }, + } + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post(TRANSCRIPTION_URL, json=body, headers=_HEADERS()) + resp.raise_for_status() + data = resp.json() + + task_id = data.get("output", {}).get("task_id") + if not task_id: + raise RuntimeError(f"[FlashASR] 提交任务失败: {data}") + logger.info("[FlashASR] 任务已提交 task_id=%s", task_id) + return task_id + + +async def _poll_task(task_id: str, poll_interval: float = 5.0, timeout: float = 600.0) -> dict: + """轮询任务状态,返回完成后的 output。""" + url = f"{DASHSCOPE_HOST}/api/v1/tasks/{task_id}" + deadline = time.monotonic() + timeout + + async with httpx.AsyncClient(timeout=30) as client: + while time.monotonic() < deadline: + resp = await client.get(url, headers=_HEADERS()) + resp.raise_for_status() + data = resp.json() + status = data.get("output", {}).get("task_status", "") + + if status == "SUCCEEDED": + logger.info("[FlashASR] 任务完成 task_id=%s", task_id) + return data["output"] + elif status in ("FAILED", "CANCELED"): + raise RuntimeError(f"[FlashASR] 任务失败 task_id={task_id} status={status}: {data}") + + logger.debug("[FlashASR] 轮询中 task_id=%s status=%s", task_id, status) + await asyncio.sleep(poll_interval) + + raise RuntimeError(f"[FlashASR] 任务超时 task_id={task_id} (>{timeout}s)") + + +async def transcribe_from_oss_url(read_url: str) -> AsyncIterator[dict]: + """ + 调用 DashScope paraformer-v2 异步转写。 + yield {"text": str, "begin_ms": int, "end_ms": int} + """ + if not DASHSCOPE_API_KEY(): + raise RuntimeError("[FlashASR] DASHSCOPE_API_KEY 未配置") + + task_id = await _submit_task(read_url) + output = await _poll_task(task_id) + + # 下载转写结果 JSON + result_url = output.get("results", [{}])[0].get("transcription_url", "") + if not result_url: + raise RuntimeError(f"[FlashASR] 未找到 transcription_url: {output}") + + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.get(result_url) + resp.raise_for_status() + transcript_data = resp.json() + + # 解析句子列表 + sentences = transcript_data.get("transcripts", [{}])[0].get("sentences", []) + if not sentences: + # fallback:整段文本 + text = transcript_data.get("transcripts", [{}])[0].get("text", "") + if text: + yield {"text": text, "begin_ms": 0, "end_ms": 0} + return + + for s in sentences: + yield { + "text": s.get("text", "").strip(), + "begin_ms": int(s.get("begin_time", 0)), + "end_ms": int(s.get("end_time", 0)), + } diff --git a/mindcli/_vendor/gateway/platforms/helpers.py b/mindcli/_vendor/gateway/platforms/helpers.py new file mode 100644 index 0000000..c834dd8 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/helpers.py @@ -0,0 +1,261 @@ +"""Shared helper classes for gateway platform adapters. + +Extracts common patterns that were duplicated across 5-7 adapters: +message deduplication, text batch aggregation, markdown stripping, +and thread participation tracking. +""" + +import asyncio +import json +import logging +import re +import time +from pathlib import Path +from typing import TYPE_CHECKING, Dict, Optional + +if TYPE_CHECKING: + from gateway.platforms.base import BasePlatformAdapter, MessageEvent + +logger = logging.getLogger(__name__) + + +# ─── Message Deduplication ──────────────────────────────────────────────────── + + +class MessageDeduplicator: + """TTL-based message deduplication cache. + + Replaces the identical ``_seen_messages`` / ``_is_duplicate()`` pattern + previously duplicated in discord, slack, dingtalk, wecom, weixin, + mattermost, and feishu adapters. + + Usage:: + + self._dedup = MessageDeduplicator() + + # In message handler: + if self._dedup.is_duplicate(msg_id): + return + """ + + def __init__(self, max_size: int = 2000, ttl_seconds: float = 300): + self._seen: Dict[str, float] = {} + self._max_size = max_size + self._ttl = ttl_seconds + + def is_duplicate(self, msg_id: str) -> bool: + """Return True if *msg_id* was already seen within the TTL window.""" + if not msg_id: + return False + now = time.time() + if msg_id in self._seen: + return True + self._seen[msg_id] = now + if len(self._seen) > self._max_size: + cutoff = now - self._ttl + self._seen = {k: v for k, v in self._seen.items() if v > cutoff} + return False + + def clear(self): + """Clear all tracked messages.""" + self._seen.clear() + + +# ─── Text Batch Aggregation ────────────────────────────────────────────────── + + +class TextBatchAggregator: + """Aggregates rapid-fire text events into single messages. + + Replaces the ``_enqueue_text_event`` / ``_flush_text_batch`` pattern + previously duplicated in telegram, discord, matrix, wecom, and feishu. + + Usage:: + + self._text_batcher = TextBatchAggregator( + handler=self._message_handler, + batch_delay=0.6, + split_threshold=1900, + ) + + # In message dispatch: + if msg_type == MessageType.TEXT and self._text_batcher.is_enabled(): + self._text_batcher.enqueue(event, session_key) + return + """ + + def __init__( + self, + handler, + *, + batch_delay: float = 0.6, + split_delay: float = 2.0, + split_threshold: int = 4000, + ): + self._handler = handler + self._batch_delay = batch_delay + self._split_delay = split_delay + self._split_threshold = split_threshold + self._pending: Dict[str, "MessageEvent"] = {} + self._pending_tasks: Dict[str, asyncio.Task] = {} + + def is_enabled(self) -> bool: + """Return True if batching is active (delay > 0).""" + return self._batch_delay > 0 + + def enqueue(self, event: "MessageEvent", key: str) -> None: + """Add *event* to the pending batch for *key*.""" + chunk_len = len(event.text or "") + existing = self._pending.get(key) + if not existing: + event._last_chunk_len = chunk_len # type: ignore[attr-defined] + self._pending[key] = event + else: + existing.text = f"{existing.text}\n{event.text}" + existing._last_chunk_len = chunk_len # type: ignore[attr-defined] + + # Cancel prior flush timer, start a new one + prior = self._pending_tasks.get(key) + if prior and not prior.done(): + prior.cancel() + self._pending_tasks[key] = asyncio.create_task(self._flush(key)) + + async def _flush(self, key: str) -> None: + """Wait then dispatch the batched event for *key*.""" + current_task = self._pending_tasks.get(key) + pending = self._pending.get(key) + last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0 + + # Use longer delay when the last chunk looks like a split message + delay = self._split_delay if last_len >= self._split_threshold else self._batch_delay + await asyncio.sleep(delay) + + event = self._pending.pop(key, None) + if event: + try: + await self._handler(event) + except Exception: + logger.exception("[TextBatchAggregator] Error dispatching batched event for %s", key) + + if self._pending_tasks.get(key) is current_task: + self._pending_tasks.pop(key, None) + + def cancel_all(self) -> None: + """Cancel all pending flush tasks.""" + for task in self._pending_tasks.values(): + if not task.done(): + task.cancel() + self._pending_tasks.clear() + self._pending.clear() + + +# ─── Markdown Stripping ────────────────────────────────────────────────────── + +# Pre-compiled regexes for performance +_RE_BOLD = re.compile(r"\*\*(.+?)\*\*", re.DOTALL) +_RE_ITALIC_STAR = re.compile(r"\*(.+?)\*", re.DOTALL) +_RE_BOLD_UNDER = re.compile(r"__(.+?)__", re.DOTALL) +_RE_ITALIC_UNDER = re.compile(r"_(.+?)_", re.DOTALL) +_RE_CODE_BLOCK = re.compile(r"```[a-zA-Z0-9_+-]*\n?") +_RE_INLINE_CODE = re.compile(r"`(.+?)`") +_RE_HEADING = re.compile(r"^#{1,6}\s+", re.MULTILINE) +_RE_LINK = re.compile(r"\[([^\]]+)\]\([^\)]+\)") +_RE_MULTI_NEWLINE = re.compile(r"\n{3,}") + + +def strip_markdown(text: str) -> str: + """Strip markdown formatting for plain-text platforms (SMS, iMessage, etc.). + + Replaces the identical ``_strip_markdown()`` functions previously + duplicated in sms.py, bluebubbles.py, and feishu.py. + """ + text = _RE_BOLD.sub(r"\1", text) + text = _RE_ITALIC_STAR.sub(r"\1", text) + text = _RE_BOLD_UNDER.sub(r"\1", text) + text = _RE_ITALIC_UNDER.sub(r"\1", text) + text = _RE_CODE_BLOCK.sub("", text) + text = _RE_INLINE_CODE.sub(r"\1", text) + text = _RE_HEADING.sub("", text) + text = _RE_LINK.sub(r"\1", text) + text = _RE_MULTI_NEWLINE.sub("\n\n", text) + return text.strip() + + +# ─── Thread Participation Tracking ─────────────────────────────────────────── + + +class ThreadParticipationTracker: + """Persistent tracking of threads the bot has participated in. + + Replaces the identical ``_load/_save_participated_threads`` + + ``_mark_thread_participated`` pattern previously duplicated in + discord.py and matrix.py. + + Usage:: + + self._threads = ThreadParticipationTracker("discord") + + # Check membership: + if thread_id in self._threads: + ... + + # Mark participation: + self._threads.mark(thread_id) + """ + + _MAX_TRACKED = 500 + + def __init__(self, platform_name: str, max_tracked: int = 500): + self._platform = platform_name + self._max_tracked = max_tracked + self._threads: set = self._load() + + def _state_path(self) -> Path: + from hermes_constants import get_hermes_home + return get_hermes_home() / f"{self._platform}_threads.json" + + def _load(self) -> set: + path = self._state_path() + if path.exists(): + try: + return set(json.loads(path.read_text(encoding="utf-8"))) + except Exception: + pass + return set() + + def _save(self) -> None: + path = self._state_path() + path.parent.mkdir(parents=True, exist_ok=True) + thread_list = list(self._threads) + if len(thread_list) > self._max_tracked: + thread_list = thread_list[-self._max_tracked:] + self._threads = set(thread_list) + path.write_text(json.dumps(thread_list), encoding="utf-8") + + def mark(self, thread_id: str) -> None: + """Mark *thread_id* as participated and persist.""" + if thread_id not in self._threads: + self._threads.add(thread_id) + self._save() + + def __contains__(self, thread_id: str) -> bool: + return thread_id in self._threads + + def clear(self) -> None: + self._threads.clear() + + +# ─── Phone Number Redaction ────────────────────────────────────────────────── + + +def redact_phone(phone: str) -> str: + """Redact a phone number for logging, preserving country code and last 4. + + Replaces the identical ``_redact_phone()`` functions in signal.py, + sms.py, and bluebubbles.py. + """ + if not phone: + return "" + if len(phone) <= 8: + return phone[:2] + "****" + phone[-2:] if len(phone) > 4 else "****" + return phone[:4] + "****" + phone[-4:] diff --git a/mindcli/_vendor/gateway/platforms/homeassistant.py b/mindcli/_vendor/gateway/platforms/homeassistant.py new file mode 100644 index 0000000..7464655 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/homeassistant.py @@ -0,0 +1,449 @@ +""" +Home Assistant platform adapter. + +Connects to the HA WebSocket API for real-time event monitoring. +State-change events are converted to MessageEvent objects and forwarded +to the agent for processing. Outbound messages are delivered as HA +persistent notifications. + +Requires: +- aiohttp (already in messaging extras) +- HASS_TOKEN env var (Long-Lived Access Token) +- HASS_URL env var (default: http://homeassistant.local:8123) +""" + +import asyncio +import json +import logging +import os +import time +import uuid +from datetime import datetime +from typing import Any, Dict, Optional, Set + +try: + import aiohttp + AIOHTTP_AVAILABLE = True +except ImportError: + AIOHTTP_AVAILABLE = False + aiohttp = None # type: ignore[assignment] + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, +) + +logger = logging.getLogger(__name__) + + +def check_ha_requirements() -> bool: + """Check if Home Assistant dependencies are available and configured.""" + if not AIOHTTP_AVAILABLE: + return False + if not os.getenv("HASS_TOKEN"): + return False + return True + + +class HomeAssistantAdapter(BasePlatformAdapter): + """ + Home Assistant WebSocket adapter. + + Subscribes to ``state_changed`` events and forwards them as + MessageEvent objects. Supports domain/entity filtering and + per-entity cooldowns to avoid event floods. + """ + + MAX_MESSAGE_LENGTH = 4096 + + # Reconnection backoff schedule (seconds) + _BACKOFF_STEPS = [5, 10, 30, 60] + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.HOMEASSISTANT) + + # Connection state + self._session: Optional["aiohttp.ClientSession"] = None + self._ws: Optional["aiohttp.ClientWebSocketResponse"] = None + self._rest_session: Optional["aiohttp.ClientSession"] = None + self._listen_task: Optional[asyncio.Task] = None + self._msg_id: int = 0 + + # Configuration from extra + extra = config.extra or {} + token = config.token or os.getenv("HASS_TOKEN", "") + url = extra.get("url") or os.getenv("HASS_URL", "http://homeassistant.local:8123") + self._hass_url: str = url.rstrip("/") + self._hass_token: str = token + + # Event filtering + self._watch_domains: Set[str] = set(extra.get("watch_domains", [])) + self._watch_entities: Set[str] = set(extra.get("watch_entities", [])) + self._ignore_entities: Set[str] = set(extra.get("ignore_entities", [])) + self._watch_all: bool = bool(extra.get("watch_all", False)) + self._cooldown_seconds: int = int(extra.get("cooldown_seconds", 30)) + + # Cooldown tracking: entity_id -> last_event_timestamp + self._last_event_time: Dict[str, float] = {} + + def _next_id(self) -> int: + """Return the next WebSocket message ID.""" + self._msg_id += 1 + return self._msg_id + + # ------------------------------------------------------------------ + # Connection lifecycle + # ------------------------------------------------------------------ + + async def connect(self) -> bool: + """Connect to HA WebSocket API and subscribe to events.""" + if not AIOHTTP_AVAILABLE: + logger.warning("[%s] aiohttp not installed. Run: pip install aiohttp", self.name) + return False + + if not self._hass_token: + logger.warning("[%s] No HASS_TOKEN configured", self.name) + return False + + try: + success = await self._ws_connect() + if not success: + return False + + # Dedicated REST session for send() calls + self._rest_session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=30) + ) + + # Warn if no event filters are configured + if not self._watch_domains and not self._watch_entities and not self._watch_all: + logger.warning( + "[%s] No watch_domains, watch_entities, or watch_all configured. " + "All state_changed events will be dropped. Configure filters in " + "your HA platform config to receive events.", + self.name, + ) + + # Start background listener + self._listen_task = asyncio.create_task(self._listen_loop()) + self._running = True + logger.info("[%s] Connected to %s", self.name, self._hass_url) + return True + + except Exception as e: + logger.error("[%s] Failed to connect: %s", self.name, e) + return False + + async def _ws_connect(self) -> bool: + """Establish WebSocket connection and authenticate.""" + ws_url = self._hass_url.replace("http://", "ws://").replace("https://", "wss://") + ws_url = f"{ws_url}/api/websocket" + + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=30) + ) + self._ws = await self._session.ws_connect(ws_url, heartbeat=30, timeout=30) + + # Step 1: Receive auth_required + msg = await self._ws.receive_json() + if msg.get("type") != "auth_required": + logger.error("Expected auth_required, got: %s", msg.get("type")) + await self._cleanup_ws() + return False + + # Step 2: Send auth + await self._ws.send_json({ + "type": "auth", + "access_token": self._hass_token, + }) + + # Step 3: Wait for auth_ok + msg = await self._ws.receive_json() + if msg.get("type") != "auth_ok": + logger.error("Auth failed: %s", msg) + await self._cleanup_ws() + return False + + # Step 4: Subscribe to state_changed events + sub_id = self._next_id() + await self._ws.send_json({ + "id": sub_id, + "type": "subscribe_events", + "event_type": "state_changed", + }) + + # Verify subscription acknowledgement + msg = await self._ws.receive_json() + if not msg.get("success"): + logger.error("Failed to subscribe to events: %s", msg) + await self._cleanup_ws() + return False + + return True + + async def _cleanup_ws(self) -> None: + """Close WebSocket and session.""" + if self._ws and not self._ws.closed: + await self._ws.close() + self._ws = None + if self._session and not self._session.closed: + await self._session.close() + self._session = None + + async def disconnect(self) -> None: + """Disconnect from Home Assistant.""" + self._running = False + if self._listen_task: + self._listen_task.cancel() + try: + await self._listen_task + except asyncio.CancelledError: + pass + self._listen_task = None + + await self._cleanup_ws() + if self._rest_session and not self._rest_session.closed: + await self._rest_session.close() + self._rest_session = None + logger.info("[%s] Disconnected", self.name) + + # ------------------------------------------------------------------ + # Event listener + # ------------------------------------------------------------------ + + async def _listen_loop(self) -> None: + """Main event loop with automatic reconnection.""" + backoff_idx = 0 + + while self._running: + try: + await self._read_events() + except asyncio.CancelledError: + return + except Exception as e: + logger.warning("[%s] WebSocket error: %s", self.name, e) + + if not self._running: + return + + # Reconnect with backoff + delay = self._BACKOFF_STEPS[min(backoff_idx, len(self._BACKOFF_STEPS) - 1)] + logger.info("[%s] Reconnecting in %ds...", self.name, delay) + await asyncio.sleep(delay) + backoff_idx += 1 + + try: + await self._cleanup_ws() + success = await self._ws_connect() + if success: + backoff_idx = 0 # Reset on successful reconnect + logger.info("[%s] Reconnected", self.name) + except Exception as e: + logger.warning("[%s] Reconnection failed: %s", self.name, e) + + async def _read_events(self) -> None: + """Read events from WebSocket until disconnected.""" + if self._ws is None or self._ws.closed: + return + async for ws_msg in self._ws: + if ws_msg.type == aiohttp.WSMsgType.TEXT: + try: + data = json.loads(ws_msg.data) + if data.get("type") == "event": + await self._handle_ha_event(data.get("event", {})) + except json.JSONDecodeError: + logger.debug("Invalid JSON from HA WS: %s", ws_msg.data[:200]) + elif ws_msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + break + + async def _handle_ha_event(self, event: Dict[str, Any]) -> None: + """Process a state_changed event from Home Assistant.""" + event_data = event.get("data", {}) + entity_id: str = event_data.get("entity_id", "") + + if not entity_id: + return + + # Apply ignore filter + if entity_id in self._ignore_entities: + return + + # Apply domain/entity watch filters (closed by default — require + # explicit watch_domains, watch_entities, or watch_all to forward) + domain = entity_id.split(".")[0] if "." in entity_id else "" + if self._watch_domains or self._watch_entities: + domain_match = domain in self._watch_domains if self._watch_domains else False + entity_match = entity_id in self._watch_entities if self._watch_entities else False + if not domain_match and not entity_match: + return + elif not self._watch_all: + # No filters configured and watch_all is off — drop the event + return + + # Apply cooldown + now = time.time() + last = self._last_event_time.get(entity_id, 0) + if (now - last) < self._cooldown_seconds: + return + self._last_event_time[entity_id] = now + + # Build human-readable message + old_state = event_data.get("old_state", {}) + new_state = event_data.get("new_state", {}) + message = self._format_state_change(entity_id, old_state, new_state) + + if not message: + return + + # Build MessageEvent and forward to handler + source = self.build_source( + chat_id="ha_events", + chat_name="Home Assistant Events", + chat_type="channel", + user_id="homeassistant", + user_name="Home Assistant", + ) + + msg_event = MessageEvent( + text=message, + message_type=MessageType.TEXT, + source=source, + message_id=f"ha_{entity_id}_{int(now)}", + timestamp=datetime.now(), + ) + + await self.handle_message(msg_event) + + @staticmethod + def _format_state_change( + entity_id: str, + old_state: Dict[str, Any], + new_state: Dict[str, Any], + ) -> Optional[str]: + """Convert a state_changed event into a human-readable description.""" + if not new_state: + return None + + old_val = old_state.get("state", "unknown") if old_state else "unknown" + new_val = new_state.get("state", "unknown") + + # Skip if state didn't actually change + if old_val == new_val: + return None + + friendly_name = new_state.get("attributes", {}).get("friendly_name", entity_id) + domain = entity_id.split(".")[0] if "." in entity_id else "" + + # Domain-specific formatting + if domain == "climate": + attrs = new_state.get("attributes", {}) + temp = attrs.get("current_temperature", "?") + target = attrs.get("temperature", "?") + return ( + f"[Home Assistant] {friendly_name}: HVAC mode changed from " + f"'{old_val}' to '{new_val}' (current: {temp}, target: {target})" + ) + + if domain == "sensor": + unit = new_state.get("attributes", {}).get("unit_of_measurement", "") + return ( + f"[Home Assistant] {friendly_name}: changed from " + f"{old_val}{unit} to {new_val}{unit}" + ) + + if domain == "binary_sensor": + return ( + f"[Home Assistant] {friendly_name}: " + f"{'triggered' if new_val == 'on' else 'cleared'} " + f"(was {'triggered' if old_val == 'on' else 'cleared'})" + ) + + if domain in ("light", "switch", "fan"): + return ( + f"[Home Assistant] {friendly_name}: turned " + f"{'on' if new_val == 'on' else 'off'}" + ) + + if domain == "alarm_control_panel": + return ( + f"[Home Assistant] {friendly_name}: alarm state changed from " + f"'{old_val}' to '{new_val}'" + ) + + # Generic fallback + return ( + f"[Home Assistant] {friendly_name} ({entity_id}): " + f"changed from '{old_val}' to '{new_val}'" + ) + + # ------------------------------------------------------------------ + # Outbound messaging + # ------------------------------------------------------------------ + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a notification via HA REST API (persistent_notification.create). + + Uses the REST API instead of WebSocket to avoid a race condition + with the event listener loop that reads from the same WS connection. + """ + url = f"{self._hass_url}/api/services/persistent_notification/create" + headers = { + "Authorization": f"Bearer {self._hass_token}", + "Content-Type": "application/json", + } + payload = { + "title": "Hermes Agent", + "message": content[:self.MAX_MESSAGE_LENGTH], + } + + try: + if self._rest_session: + async with self._rest_session.post( + url, + headers=headers, + json=payload, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status < 300: + return SendResult(success=True, message_id=uuid.uuid4().hex[:12]) + else: + body = await resp.text() + return SendResult(success=False, error=f"HTTP {resp.status}: {body}") + else: + async with aiohttp.ClientSession() as session: + async with session.post( + url, + headers=headers, + json=payload, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status < 300: + return SendResult(success=True, message_id=uuid.uuid4().hex[:12]) + else: + body = await resp.text() + return SendResult(success=False, error=f"HTTP {resp.status}: {body}") + + except asyncio.TimeoutError: + return SendResult(success=False, error="Timeout sending notification to HA") + except Exception as e: + return SendResult(success=False, error=str(e)) + + async def send_typing(self, chat_id: str, metadata=None) -> None: + """No typing indicator for Home Assistant.""" + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Return basic info about the HA event channel.""" + return { + "name": "Home Assistant Events", + "type": "channel", + "url": self._hass_url, + } diff --git a/mindcli/_vendor/gateway/platforms/matrix.py b/mindcli/_vendor/gateway/platforms/matrix.py new file mode 100644 index 0000000..816d88b --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/matrix.py @@ -0,0 +1,2015 @@ +"""Matrix gateway adapter. + +Connects to any Matrix homeserver (self-hosted or matrix.org) via the +mautrix Python SDK. Supports optional end-to-end encryption (E2EE) +when installed with ``pip install "mautrix[encryption]"``. + +Environment variables: + MATRIX_HOMESERVER Homeserver URL (e.g. https://matrix.example.org) + MATRIX_ACCESS_TOKEN Access token (preferred auth method) + MATRIX_USER_ID Full user ID (@bot:server) — required for password login + MATRIX_PASSWORD Password (alternative to access token) + MATRIX_ENCRYPTION Set "true" to enable E2EE + MATRIX_DEVICE_ID Stable device ID for E2EE persistence across restarts + MATRIX_ALLOWED_USERS Comma-separated Matrix user IDs (@user:server) + MATRIX_HOME_ROOM Room ID for cron/notification delivery + MATRIX_REACTIONS Set "false" to disable processing lifecycle reactions + (eyes/checkmark/cross). Default: true + MATRIX_REQUIRE_MENTION Require @mention in rooms (default: true) + MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement + MATRIX_AUTO_THREAD Auto-create threads for room messages (default: true) + MATRIX_RECOVERY_KEY Recovery key for cross-signing verification after device key rotation + MATRIX_DM_MENTION_THREADS Create a thread when bot is @mentioned in a DM (default: false) +""" + +from __future__ import annotations + +import asyncio +import logging +import mimetypes +import os +import re +import time +from pathlib import Path +from typing import Any, Dict, Optional, Set + +from html import escape as _html_escape + +try: + from mautrix.types import ( + ContentURI, + EventID, + EventType, + PaginationDirection, + PresenceState, + RoomCreatePreset, + RoomID, + SyncToken, + TrustState, + UserID, + ) +except ImportError: + # Stubs so the module is importable without mautrix installed. + # check_matrix_requirements() will return False and the adapter + # won't be instantiated in production, but tests may exercise + # adapter methods so stubs must have the right attributes. + ContentURI = EventID = RoomID = SyncToken = UserID = str # type: ignore[misc,assignment] + + class _EventTypeStub: # type: ignore[no-redef] + ROOM_MESSAGE = "m.room.message" + REACTION = "m.reaction" + ROOM_ENCRYPTED = "m.room.encrypted" + ROOM_NAME = "m.room.name" + EventType = _EventTypeStub # type: ignore[misc,assignment] + + class _PaginationDirectionStub: # type: ignore[no-redef] + BACKWARD = "b" + FORWARD = "f" + PaginationDirection = _PaginationDirectionStub # type: ignore[misc,assignment] + + class _PresenceStateStub: # type: ignore[no-redef] + ONLINE = "online" + OFFLINE = "offline" + UNAVAILABLE = "unavailable" + PresenceState = _PresenceStateStub # type: ignore[misc,assignment] + + class _RoomCreatePresetStub: # type: ignore[no-redef] + PRIVATE = "private_chat" + PUBLIC = "public_chat" + TRUSTED_PRIVATE = "trusted_private_chat" + RoomCreatePreset = _RoomCreatePresetStub # type: ignore[misc,assignment] + + class _TrustStateStub: # type: ignore[no-redef] + UNVERIFIED = 0 + VERIFIED = 1 + TrustState = _TrustStateStub # type: ignore[misc,assignment] + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + ProcessingOutcome, + SendResult, +) +from gateway.platforms.helpers import ThreadParticipationTracker + +logger = logging.getLogger(__name__) + +# Matrix message size limit (4000 chars practical, spec has no hard limit +# but clients render poorly above this). +MAX_MESSAGE_LENGTH = 4000 + +# Store directory for E2EE keys and sync state. +# Uses get_hermes_home() so each profile gets its own Matrix store. +from hermes_constants import get_hermes_dir as _get_hermes_dir +_STORE_DIR = _get_hermes_dir("platforms/matrix/store", "matrix/store") +_CRYPTO_DB_PATH = _STORE_DIR / "crypto.db" + +# Grace period: ignore messages older than this many seconds before startup. +_STARTUP_GRACE_SECONDS = 5 + +# Pending undecrypted events: cap and TTL for retry buffer. +_MAX_PENDING_EVENTS = 100 +_PENDING_EVENT_TTL = 300 # seconds — stop retrying after 5 min + + +_E2EE_INSTALL_HINT = ( + "Install with: pip install 'mautrix[encryption]' " + "(requires libolm C library)" +) + + +def _check_e2ee_deps() -> bool: + """Return True if mautrix E2EE dependencies (python-olm) are available.""" + try: + from mautrix.crypto import OlmMachine # noqa: F401 + return True + except (ImportError, AttributeError): + return False + + +def check_matrix_requirements() -> bool: + """Return True if the Matrix adapter can be used.""" + token = os.getenv("MATRIX_ACCESS_TOKEN", "") + password = os.getenv("MATRIX_PASSWORD", "") + homeserver = os.getenv("MATRIX_HOMESERVER", "") + + if not token and not password: + logger.debug("Matrix: neither MATRIX_ACCESS_TOKEN nor MATRIX_PASSWORD set") + return False + if not homeserver: + logger.warning("Matrix: MATRIX_HOMESERVER not set") + return False + try: + import mautrix # noqa: F401 + except ImportError: + logger.warning( + "Matrix: mautrix not installed. " + "Run: pip install 'mautrix[encryption]'" + ) + return False + + # If encryption is requested, verify E2EE deps are available at startup + # rather than silently degrading to plaintext-only at connect time. + encryption_requested = os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes") + if encryption_requested and not _check_e2ee_deps(): + logger.error( + "Matrix: MATRIX_ENCRYPTION=true but E2EE dependencies are missing. %s. " + "Without this, encrypted rooms will not work. " + "Set MATRIX_ENCRYPTION=false to disable E2EE.", + _E2EE_INSTALL_HINT, + ) + return False + + return True + + +class _CryptoStateStore: + """Adapter that satisfies the mautrix crypto StateStore interface. + + OlmMachine requires a StateStore with ``is_encrypted``, + ``get_encryption_info``, and ``find_shared_rooms``. The basic + ``MemoryStateStore`` from ``mautrix.client`` doesn't implement these, + so we provide simple implementations that consult the client's room + state. + """ + + def __init__(self, client_state_store: Any, joined_rooms: set): + self._ss = client_state_store + self._joined_rooms = joined_rooms + + async def is_encrypted(self, room_id: str) -> bool: + return (await self.get_encryption_info(room_id)) is not None + + async def get_encryption_info(self, room_id: str): + if hasattr(self._ss, "get_encryption_info"): + return await self._ss.get_encryption_info(room_id) + return None + + async def find_shared_rooms(self, user_id: str) -> list: + # Return all joined rooms — simple but correct for a single-user bot. + return list(self._joined_rooms) + + +class MatrixAdapter(BasePlatformAdapter): + """Gateway adapter for Matrix (any homeserver).""" + + # Threshold for detecting Matrix client-side message splits. + # When a chunk is near the ~4000-char practical limit, a continuation + # is almost certain. + _SPLIT_THRESHOLD = 3900 + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.MATRIX) + + self._homeserver: str = ( + config.extra.get("homeserver", "") + or os.getenv("MATRIX_HOMESERVER", "") + ).rstrip("/") + self._access_token: str = config.token or os.getenv("MATRIX_ACCESS_TOKEN", "") + self._user_id: str = ( + config.extra.get("user_id", "") + or os.getenv("MATRIX_USER_ID", "") + ) + self._password: str = ( + config.extra.get("password", "") + or os.getenv("MATRIX_PASSWORD", "") + ) + self._encryption: bool = config.extra.get( + "encryption", + os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes"), + ) + self._device_id: str = ( + config.extra.get("device_id", "") + or os.getenv("MATRIX_DEVICE_ID", "") + ) + + self._client: Any = None # mautrix.client.Client + self._crypto_db: Any = None # mautrix.util.async_db.Database + self._sync_task: Optional[asyncio.Task] = None + self._closing = False + self._startup_ts: float = 0.0 + + # Cache: room_id → bool (is DM) + self._dm_rooms: Dict[str, bool] = {} + # Set of room IDs we've joined + self._joined_rooms: Set[str] = set() + # Event deduplication (bounded deque keeps newest entries) + from collections import deque + self._processed_events: deque = deque(maxlen=1000) + self._processed_events_set: set = set() + + # Buffer for undecrypted events pending key receipt. + # Each entry: (room_id, event, timestamp) + self._pending_megolm: list = [] + + # Thread participation tracking (for require_mention bypass) + self._threads = ThreadParticipationTracker("matrix") + + # Mention/thread gating — parsed once from env vars. + self._require_mention: bool = os.getenv("MATRIX_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no") + free_rooms_raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "") + self._free_rooms: Set[str] = {r.strip() for r in free_rooms_raw.split(",") if r.strip()} + self._auto_thread: bool = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in ("true", "1", "yes") + self._dm_mention_threads: bool = os.getenv("MATRIX_DM_MENTION_THREADS", "false").lower() in ("true", "1", "yes") + + # Reactions: configurable via MATRIX_REACTIONS (default: true). + self._reactions_enabled: bool = os.getenv( + "MATRIX_REACTIONS", "true" + ).lower() not in ("false", "0", "no") + self._pending_reactions: dict[tuple[str, str], str] = {} + + # Text batching: merge rapid successive messages (Telegram-style). + # Matrix clients split long messages around 4000 chars. + self._text_batch_delay_seconds = float(os.getenv("HERMES_MATRIX_TEXT_BATCH_DELAY_SECONDS", "0.6")) + self._text_batch_split_delay_seconds = float(os.getenv("HERMES_MATRIX_TEXT_BATCH_SPLIT_DELAY_SECONDS", "2.0")) + self._pending_text_batches: Dict[str, MessageEvent] = {} + self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {} + + def _is_duplicate_event(self, event_id) -> bool: + """Return True if this event was already processed. Tracks the ID otherwise.""" + if not event_id: + return False + if event_id in self._processed_events_set: + return True + if len(self._processed_events) == self._processed_events.maxlen: + evicted = self._processed_events[0] + self._processed_events_set.discard(evicted) + self._processed_events.append(event_id) + self._processed_events_set.add(event_id) + return False + + # ------------------------------------------------------------------ + # E2EE helpers + # ------------------------------------------------------------------ + + async def _verify_device_keys_on_server(self, client: Any, olm: Any) -> bool: + """Verify our device keys are on the homeserver after loading crypto state. + + Returns True if keys are valid or were successfully re-uploaded. + Returns False if verification fails (caller should refuse E2EE). + """ + try: + resp = await client.query_keys({client.mxid: [client.device_id]}) + except Exception as exc: + logger.error( + "Matrix: cannot verify device keys on server: %s — refusing E2EE", exc, + ) + return False + + # query_keys returns typed objects (QueryKeysResponse, DeviceKeys + # with KeyID keys). Normalise to plain strings for comparison. + device_keys_map = getattr(resp, "device_keys", {}) or {} + our_user_devices = device_keys_map.get(str(client.mxid)) or {} + our_keys = our_user_devices.get(str(client.device_id)) + + if not our_keys: + logger.warning("Matrix: device keys missing from server — re-uploading") + olm.account.shared = False + try: + await olm.share_keys() + except Exception as exc: + logger.error("Matrix: failed to re-upload device keys: %s", exc) + return False + return True + + # DeviceKeys.keys is a dict[KeyID, str]. Iterate to find the + # ed25519 key rather than constructing a KeyID for lookup. + server_ed25519 = None + keys_dict = getattr(our_keys, "keys", {}) or {} + for key_id, key_value in keys_dict.items(): + if str(key_id).startswith("ed25519:"): + server_ed25519 = str(key_value) + break + local_ed25519 = olm.account.identity_keys.get("ed25519") + + if server_ed25519 != local_ed25519: + if olm.account.shared: + # Restored account from DB but server has different keys — corrupted state. + logger.error( + "Matrix: server has different identity keys for device %s — " + "local crypto state is stale. Delete %s and restart.", + client.device_id, + _CRYPTO_DB_PATH, + ) + return False + + # Fresh account (never uploaded). Server has stale keys from a + # previous installation. Try to delete the old device and re-upload. + logger.warning( + "Matrix: server has stale keys for device %s — attempting re-upload", + client.device_id, + ) + try: + await client.api.request( + client.api.Method.DELETE + if hasattr(client.api, "Method") + else "DELETE", + f"/_matrix/client/v3/devices/{client.device_id}", + ) + logger.info("Matrix: deleted stale device %s from server", client.device_id) + except Exception: + # Device deletion often requires UIA or may simply not be + # permitted — that's fine, share_keys will try to overwrite. + pass + try: + await olm.share_keys() + except Exception as exc: + logger.error( + "Matrix: cannot upload device keys for %s: %s. " + "Try generating a new access token to get a fresh device.", + client.device_id, + exc, + ) + return False + + return True + + # ------------------------------------------------------------------ + # Required overrides + # ------------------------------------------------------------------ + + async def connect(self) -> bool: + """Connect to the Matrix homeserver and start syncing.""" + from mautrix.api import HTTPAPI + from mautrix.client import Client + from mautrix.client.state_store import MemoryStateStore, MemorySyncStore + + if not self._homeserver: + logger.error("Matrix: homeserver URL not configured") + return False + + # Ensure store dir exists for E2EE key persistence. + _STORE_DIR.mkdir(parents=True, exist_ok=True) + + # Create the HTTP API layer. + api = HTTPAPI( + base_url=self._homeserver, + token=self._access_token or "", + ) + + # Create the client. + state_store = MemoryStateStore() + sync_store = MemorySyncStore() + client = Client( + mxid=UserID(self._user_id) if self._user_id else UserID(""), + device_id=self._device_id or None, + api=api, + state_store=state_store, + sync_store=sync_store, + ) + + self._client = client + + # Authenticate. + if self._access_token: + api.token = self._access_token + + # Validate the token and learn user_id / device_id. + try: + resp = await client.whoami() + resolved_user_id = getattr(resp, "user_id", "") or self._user_id + resolved_device_id = getattr(resp, "device_id", "") + if resolved_user_id: + self._user_id = str(resolved_user_id) + client.mxid = UserID(self._user_id) + + # Prefer user-configured device_id for stable E2EE identity. + effective_device_id = self._device_id or resolved_device_id + if effective_device_id: + client.device_id = effective_device_id + + logger.info( + "Matrix: using access token for %s%s", + self._user_id or "(unknown user)", + f" (device {effective_device_id})" if effective_device_id else "", + ) + except Exception as exc: + logger.error( + "Matrix: whoami failed — check MATRIX_ACCESS_TOKEN and MATRIX_HOMESERVER: %s", + exc, + ) + await api.session.close() + return False + elif self._password and self._user_id: + try: + resp = await client.login( + identifier=self._user_id, + password=self._password, + device_name="Hermes Agent", + device_id=self._device_id or None, + ) + if resp and hasattr(resp, "device_id"): + client.device_id = resp.device_id + logger.info("Matrix: logged in as %s", self._user_id) + except Exception as exc: + logger.error("Matrix: login failed — %s", exc) + await api.session.close() + return False + else: + logger.error("Matrix: need MATRIX_ACCESS_TOKEN or MATRIX_USER_ID + MATRIX_PASSWORD") + await api.session.close() + return False + + # Set up E2EE if requested. + if self._encryption: + if not _check_e2ee_deps(): + logger.error( + "Matrix: MATRIX_ENCRYPTION=true but E2EE dependencies are missing. %s. " + "Refusing to connect — encrypted rooms would silently fail.", + _E2EE_INSTALL_HINT, + ) + await api.session.close() + return False + try: + from mautrix.crypto import OlmMachine + from mautrix.crypto.store.asyncpg import PgCryptoStore + from mautrix.util.async_db import Database + + _STORE_DIR.mkdir(parents=True, exist_ok=True) + + # Remove legacy pickle file from pre-SQLite era. + legacy_pickle = _STORE_DIR / "crypto_store.pickle" + if legacy_pickle.exists(): + logger.info("Matrix: removing legacy crypto_store.pickle (migrated to SQLite)") + legacy_pickle.unlink() + + # Open SQLite-backed crypto store. + crypto_db = Database.create( + f"sqlite:///{_CRYPTO_DB_PATH}", + upgrade_table=PgCryptoStore.upgrade_table, + ) + await crypto_db.start() + self._crypto_db = crypto_db + + _acct_id = self._user_id or "hermes" + _pickle_key = f"{_acct_id}:{self._device_id or 'default'}" + crypto_store = PgCryptoStore( + account_id=_acct_id, + pickle_key=_pickle_key, + db=crypto_db, + ) + await crypto_store.open() + + crypto_state = _CryptoStateStore(state_store, self._joined_rooms) + olm = OlmMachine(client, crypto_store, crypto_state) + + # Accept unverified devices so senders share Megolm + # session keys with us automatically. + olm.share_keys_min_trust = TrustState.UNVERIFIED + olm.send_keys_min_trust = TrustState.UNVERIFIED + + await olm.load() + + # Verify our device keys are still on the homeserver. + if not await self._verify_device_keys_on_server(client, olm): + await crypto_db.stop() + await api.session.close() + return False + + # Import cross-signing private keys from SSSS and self-sign + # the current device. Required after any device-key rotation + # (fresh crypto.db, share_keys re-upload) — otherwise the + # device's self-signing signature is stale and peers refuse + # to share Megolm sessions with the rotated device. + recovery_key = os.getenv("MATRIX_RECOVERY_KEY", "").strip() + if recovery_key: + try: + await olm.verify_with_recovery_key(recovery_key) + logger.info("Matrix: cross-signing verified via recovery key") + except Exception as exc: + logger.warning("Matrix: recovery key verification failed: %s", exc) + + client.crypto = olm + logger.info( + "Matrix: E2EE enabled (store: %s%s)", + str(_CRYPTO_DB_PATH), + f", device_id={client.device_id}" if client.device_id else "", + ) + except Exception as exc: + logger.error( + "Matrix: failed to create E2EE client: %s. %s", + exc, _E2EE_INSTALL_HINT, + ) + await api.session.close() + return False + + # Register event handlers. + from mautrix.client import InternalEventType as IntEvt + + client.add_event_handler(EventType.ROOM_MESSAGE, self._on_room_message) + client.add_event_handler(EventType.REACTION, self._on_reaction) + client.add_event_handler(IntEvt.INVITE, self._on_invite) + + if self._encryption and getattr(client, "crypto", None): + client.add_event_handler(EventType.ROOM_ENCRYPTED, self._on_encrypted_event) + + # Initial sync to catch up, then start background sync. + self._startup_ts = time.time() + self._closing = False + + try: + sync_data = await client.sync(timeout=10000, full_state=True) + if isinstance(sync_data, dict): + rooms_join = sync_data.get("rooms", {}).get("join", {}) + self._joined_rooms = set(rooms_join.keys()) + # Store the next_batch token so incremental syncs start + # from where the initial sync left off. + nb = sync_data.get("next_batch") + if nb: + await client.sync_store.put_next_batch(nb) + logger.info( + "Matrix: initial sync complete, joined %d rooms", + len(self._joined_rooms), + ) + # Build DM room cache from m.direct account data. + await self._refresh_dm_cache() + + # Dispatch events from the initial sync so the OlmMachine + # receives to-device key shares queued while we were offline. + try: + tasks = client.handle_sync(sync_data) + if tasks: + await asyncio.gather(*tasks) + except Exception as exc: + logger.warning("Matrix: initial sync event dispatch error: %s", exc) + else: + logger.warning("Matrix: initial sync returned unexpected type %s", type(sync_data).__name__) + except Exception as exc: + logger.warning("Matrix: initial sync error: %s", exc) + + # Share keys after initial sync if E2EE is enabled. + if self._encryption and getattr(client, "crypto", None): + try: + await client.crypto.share_keys() + except Exception as exc: + logger.warning("Matrix: initial key share failed: %s", exc) + + # Start the sync loop. + self._sync_task = asyncio.create_task(self._sync_loop()) + self._mark_connected() + return True + + async def disconnect(self) -> None: + """Disconnect from Matrix.""" + self._closing = True + + if self._sync_task and not self._sync_task.done(): + self._sync_task.cancel() + try: + await self._sync_task + except (asyncio.CancelledError, Exception): + pass + + # Close the SQLite crypto store database. + if hasattr(self, "_crypto_db") and self._crypto_db: + try: + await self._crypto_db.stop() + except Exception as exc: + logger.debug("Matrix: could not close crypto DB on disconnect: %s", exc) + + if self._client: + try: + await self._client.api.session.close() + except Exception: + pass + self._client = None + + logger.info("Matrix: disconnected") + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a message to a Matrix room.""" + + if not content: + return SendResult(success=True) + + formatted = self.format_message(content) + chunks = self.truncate_message(formatted, MAX_MESSAGE_LENGTH) + + last_event_id = None + for chunk in chunks: + msg_content: Dict[str, Any] = { + "msgtype": "m.text", + "body": chunk, + } + + # Convert markdown to HTML for rich rendering. + html = self._markdown_to_html(chunk) + if html and html != chunk: + msg_content["format"] = "org.matrix.custom.html" + msg_content["formatted_body"] = html + + # Reply-to support. + if reply_to: + msg_content["m.relates_to"] = { + "m.in_reply_to": {"event_id": reply_to} + } + + # Thread support: if metadata has thread_id, send as threaded reply. + thread_id = (metadata or {}).get("thread_id") + if thread_id: + relates_to = msg_content.get("m.relates_to", {}) + relates_to["rel_type"] = "m.thread" + relates_to["event_id"] = thread_id + relates_to["is_falling_back"] = True + if reply_to and "m.in_reply_to" not in relates_to: + relates_to["m.in_reply_to"] = {"event_id": reply_to} + msg_content["m.relates_to"] = relates_to + + try: + event_id = await asyncio.wait_for( + self._client.send_message_event( + RoomID(chat_id), + EventType.ROOM_MESSAGE, + msg_content, + ), + timeout=45, + ) + last_event_id = str(event_id) + logger.info("Matrix: sent event %s to %s", last_event_id, chat_id) + except Exception as exc: + # On E2EE errors, retry after sharing keys. + if self._encryption and getattr(self._client, "crypto", None): + try: + await self._client.crypto.share_keys() + event_id = await asyncio.wait_for( + self._client.send_message_event( + RoomID(chat_id), + EventType.ROOM_MESSAGE, + msg_content, + ), + timeout=45, + ) + last_event_id = str(event_id) + logger.info("Matrix: sent event %s to %s (after key share)", last_event_id, chat_id) + continue + except Exception as retry_exc: + logger.error("Matrix: failed to send to %s after retry: %s", chat_id, retry_exc) + return SendResult(success=False, error=str(retry_exc)) + logger.error("Matrix: failed to send to %s: %s", chat_id, exc) + return SendResult(success=False, error=str(exc)) + + return SendResult(success=True, message_id=last_event_id) + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Return room name and type (dm/group).""" + name = chat_id + chat_type = "dm" if await self._is_dm_room(chat_id) else "group" + + if self._client: + try: + name_evt = await self._client.get_state_event( + RoomID(chat_id), EventType.ROOM_NAME, + ) + if name_evt and hasattr(name_evt, "name") and name_evt.name: + name = name_evt.name + except Exception: + pass + + return {"name": name, "type": chat_type} + + # ------------------------------------------------------------------ + # Optional overrides + # ------------------------------------------------------------------ + + async def send_typing( + self, chat_id: str, metadata: Optional[Dict[str, Any]] = None + ) -> None: + """Send a typing indicator.""" + if self._client: + try: + await self._client.set_typing(RoomID(chat_id), timeout=30000) + except Exception: + pass + + async def edit_message( + self, chat_id: str, message_id: str, content: str + ) -> SendResult: + """Edit an existing message (via m.replace).""" + + formatted = self.format_message(content) + msg_content: Dict[str, Any] = { + "msgtype": "m.text", + "body": f"* {formatted}", + "m.new_content": { + "msgtype": "m.text", + "body": formatted, + }, + "m.relates_to": { + "rel_type": "m.replace", + "event_id": message_id, + }, + } + + html = self._markdown_to_html(formatted) + if html and html != formatted: + msg_content["m.new_content"]["format"] = "org.matrix.custom.html" + msg_content["m.new_content"]["formatted_body"] = html + msg_content["format"] = "org.matrix.custom.html" + msg_content["formatted_body"] = f"* {html}" + + try: + event_id = await self._client.send_message_event( + RoomID(chat_id), EventType.ROOM_MESSAGE, msg_content, + ) + return SendResult(success=True, message_id=str(event_id)) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Download an image URL and upload it to Matrix.""" + from tools.url_safety import is_safe_url + if not is_safe_url(image_url): + logger.warning("Matrix: blocked unsafe image URL (SSRF protection)") + return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata) + + try: + # Try aiohttp first (always available), fall back to httpx + try: + import aiohttp as _aiohttp + async with _aiohttp.ClientSession(trust_env=True) as http: + async with http.get(image_url, timeout=_aiohttp.ClientTimeout(total=30)) as resp: + resp.raise_for_status() + data = await resp.read() + ct = resp.content_type or "image/png" + fname = image_url.rsplit("/", 1)[-1].split("?")[0] or "image.png" + except ImportError: + import httpx + async with httpx.AsyncClient() as http: + resp = await http.get(image_url, follow_redirects=True, timeout=30) + resp.raise_for_status() + data = resp.content + ct = resp.headers.get("content-type", "image/png") + fname = image_url.rsplit("/", 1)[-1].split("?")[0] or "image.png" + except Exception as exc: + logger.warning("Matrix: failed to download image %s: %s", image_url, exc) + return await self.send(chat_id, f"{caption or ''}\n{image_url}".strip(), reply_to) + + return await self._upload_and_send(chat_id, data, fname, ct, "m.image", caption, reply_to, metadata) + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Upload a local image file to Matrix.""" + return await self._send_local_file(chat_id, image_path, "m.image", caption, reply_to, metadata=metadata) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Upload a local file as a document.""" + return await self._send_local_file(chat_id, file_path, "m.file", caption, reply_to, file_name, metadata) + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Upload an audio file as a voice message (MSC3245 native voice).""" + return await self._send_local_file( + chat_id, audio_path, "m.audio", caption, reply_to, + metadata=metadata, is_voice=True + ) + + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Upload a video file.""" + return await self._send_local_file(chat_id, video_path, "m.video", caption, reply_to, metadata=metadata) + + def format_message(self, content: str) -> str: + """Pass-through — Matrix supports standard Markdown natively.""" + # Strip image markdown; media is uploaded separately. + content = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r"\2", content) + return content + + # ------------------------------------------------------------------ + # File helpers + # ------------------------------------------------------------------ + + async def _upload_and_send( + self, + room_id: str, + data: bytes, + filename: str, + content_type: str, + msgtype: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + is_voice: bool = False, + ) -> SendResult: + """Upload bytes to Matrix and send as a media message.""" + + # Upload to homeserver. + try: + mxc_url = await self._client.upload_media( + data, + mime_type=content_type, + filename=filename, + ) + except Exception as exc: + logger.error("Matrix: upload failed: %s", exc) + return SendResult(success=False, error=str(exc)) + + # Build media message content. + msg_content: Dict[str, Any] = { + "msgtype": msgtype, + "body": caption or filename, + "url": str(mxc_url), + "info": { + "mimetype": content_type, + "size": len(data), + }, + } + + # Add MSC3245 voice flag for native voice messages. + if is_voice: + msg_content["org.matrix.msc3245.voice"] = {} + + if reply_to: + msg_content["m.relates_to"] = { + "m.in_reply_to": {"event_id": reply_to} + } + + thread_id = (metadata or {}).get("thread_id") + if thread_id: + relates_to = msg_content.get("m.relates_to", {}) + relates_to["rel_type"] = "m.thread" + relates_to["event_id"] = thread_id + relates_to["is_falling_back"] = True + msg_content["m.relates_to"] = relates_to + + try: + event_id = await self._client.send_message_event( + RoomID(room_id), EventType.ROOM_MESSAGE, msg_content, + ) + return SendResult(success=True, message_id=str(event_id)) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + async def _send_local_file( + self, + room_id: str, + file_path: str, + msgtype: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + file_name: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + is_voice: bool = False, + ) -> SendResult: + """Read a local file and upload it.""" + p = Path(file_path) + if not p.exists(): + return await self.send( + room_id, f"{caption or ''}\n(file not found: {file_path})", reply_to + ) + + fname = file_name or p.name + ct = mimetypes.guess_type(fname)[0] or "application/octet-stream" + data = p.read_bytes() + + return await self._upload_and_send(room_id, data, fname, ct, msgtype, caption, reply_to, metadata, is_voice) + + # ------------------------------------------------------------------ + # Sync loop + # ------------------------------------------------------------------ + + async def _sync_loop(self) -> None: + """Continuously sync with the homeserver.""" + client = self._client + # Resume from the token stored during the initial sync. + next_batch = await client.sync_store.get_next_batch() + while not self._closing: + try: + sync_data = await client.sync( + since=next_batch, timeout=30000, + ) + + # nio returns SyncError objects (not exceptions) for auth + # failures like M_UNKNOWN_TOKEN. Detect and stop immediately. + _sync_msg = getattr(sync_data, "message", None) + if _sync_msg and isinstance(_sync_msg, str): + _lower = _sync_msg.lower() + if "m_unknown_token" in _lower or "unknown_token" in _lower: + logger.error("Matrix: permanent auth error from sync: %s — stopping", _sync_msg) + return + + if isinstance(sync_data, dict): + # Update joined rooms from sync response. + rooms_join = sync_data.get("rooms", {}).get("join", {}) + if rooms_join: + self._joined_rooms.update(rooms_join.keys()) + + # Advance the sync token so the next request is + # incremental instead of a full initial sync. + nb = sync_data.get("next_batch") + if nb: + next_batch = nb + await client.sync_store.put_next_batch(nb) + + # Dispatch events to registered handlers so that + # _on_room_message / _on_reaction / _on_invite fire. + try: + tasks = client.handle_sync(sync_data) + if tasks: + await asyncio.gather(*tasks) + except Exception as exc: + logger.warning("Matrix: sync event dispatch error: %s", exc) + + # Retry any buffered undecrypted events. + if self._pending_megolm: + await self._retry_pending_decryptions() + + except asyncio.CancelledError: + return + except Exception as exc: + if self._closing: + return + # Detect permanent auth/permission failures. + err_str = str(exc).lower() + if "401" in err_str or "403" in err_str or "unauthorized" in err_str or "forbidden" in err_str: + logger.error("Matrix: permanent auth error: %s — stopping sync", exc) + return + logger.warning("Matrix: sync error: %s — retrying in 5s", exc) + await asyncio.sleep(5) + + async def _retry_pending_decryptions(self) -> None: + """Retry decrypting buffered encrypted events after new keys arrive.""" + client = self._client + if not client or not self._pending_megolm: + return + crypto = getattr(client, "crypto", None) + if not crypto: + return + + now = time.time() + still_pending: list = [] + + for room_id, event, ts in self._pending_megolm: + # Drop events that have aged past the TTL. + if now - ts > _PENDING_EVENT_TTL: + logger.debug( + "Matrix: dropping expired pending event %s (age %.0fs)", + getattr(event, "event_id", "?"), now - ts, + ) + continue + + try: + decrypted = await crypto.decrypt_megolm_event(event) + except Exception: + still_pending.append((room_id, event, ts)) + continue + + if decrypted is None or decrypted is event: + still_pending.append((room_id, event, ts)) + continue + + logger.info( + "Matrix: decrypted buffered event %s", + getattr(event, "event_id", "?"), + ) + + # Route to the appropriate handler. + # Remove from dedup set so _on_room_message doesn't drop it + # (the encrypted event ID was already registered by _on_encrypted_event). + decrypted_id = str(getattr(decrypted, "event_id", getattr(event, "event_id", ""))) + if decrypted_id: + self._processed_events_set.discard(decrypted_id) + try: + await self._on_room_message(decrypted) + except Exception as exc: + logger.warning( + "Matrix: error processing decrypted event %s: %s", + getattr(event, "event_id", "?"), exc, + ) + + self._pending_megolm = still_pending + + # ------------------------------------------------------------------ + # Event callbacks + # ------------------------------------------------------------------ + + async def _on_room_message(self, event: Any) -> None: + """Handle incoming room message events (text, media).""" + room_id = str(getattr(event, "room_id", "")) + sender = str(getattr(event, "sender", "")) + + # Ignore own messages. + if sender == self._user_id: + return + + # Deduplicate by event ID. + event_id = str(getattr(event, "event_id", "")) + if self._is_duplicate_event(event_id): + return + + # Startup grace: ignore old messages from initial sync. + raw_ts = getattr(event, "timestamp", None) or getattr(event, "server_timestamp", None) or 0 + event_ts = raw_ts / 1000.0 if raw_ts else 0.0 + if event_ts and event_ts < self._startup_ts - _STARTUP_GRACE_SECONDS: + return + + # Extract content from the event. + content = getattr(event, "content", None) + if content is None: + return + + # Get msgtype — either from content object or raw dict. + if hasattr(content, "msgtype"): + msgtype = str(content.msgtype) + elif isinstance(content, dict): + msgtype = content.get("msgtype", "") + else: + msgtype = "" + + # Determine source content dict for relation/thread extraction. + if isinstance(content, dict): + source_content = content + elif hasattr(content, "serialize"): + source_content = content.serialize() + else: + source_content = {} + + relates_to = source_content.get("m.relates_to", {}) + + # Skip edits (m.replace relation). + if relates_to.get("rel_type") == "m.replace": + return + + # Ignore m.notice to prevent bot-to-bot loops (m.notice is the + # conventional msgtype for bot responses in the Matrix ecosystem). + if msgtype == "m.notice": + return + + # Dispatch by msgtype. + media_msgtypes = ("m.image", "m.audio", "m.video", "m.file") + if msgtype in media_msgtypes: + await self._handle_media_message(room_id, sender, event_id, event_ts, source_content, relates_to, msgtype) + elif msgtype == "m.text": + await self._handle_text_message(room_id, sender, event_id, event_ts, source_content, relates_to) + + async def _resolve_message_context( + self, + room_id: str, + sender: str, + event_id: str, + body: str, + source_content: dict, + relates_to: dict, + ) -> Optional[tuple]: + """Shared mention/thread/DM gating for text and media handlers. + + Returns (body, is_dm, chat_type, thread_id, display_name, source) + or None if the message should be dropped (mention gating). + """ + is_dm = await self._is_dm_room(room_id) + chat_type = "dm" if is_dm else "group" + + thread_id = None + if relates_to.get("rel_type") == "m.thread": + thread_id = relates_to.get("event_id") + + formatted_body = source_content.get("formatted_body") + # m.mentions.user_ids (MSC3952 / Matrix v1.7) — authoritative mention signal. + mentions_block = source_content.get("m.mentions") or {} + mention_user_ids = mentions_block.get("user_ids") if isinstance(mentions_block, dict) else None + is_mentioned = self._is_bot_mentioned(body, formatted_body, mention_user_ids) + + # Require-mention gating. + if not is_dm: + is_free_room = room_id in self._free_rooms + in_bot_thread = bool(thread_id and thread_id in self._threads) + if self._require_mention and not is_free_room and not in_bot_thread: + if not is_mentioned: + return None + + # DM mention-thread. + if is_dm and not thread_id and self._dm_mention_threads and is_mentioned: + thread_id = event_id + self._threads.mark(thread_id) + + # Strip mention from body. + if is_mentioned: + body = self._strip_mention(body) + + # Auto-thread. + if not is_dm and not thread_id and self._auto_thread: + thread_id = event_id + self._threads.mark(thread_id) + + display_name = await self._get_display_name(room_id, sender) + source = self.build_source( + chat_id=room_id, + chat_type=chat_type, + user_id=sender, + user_name=display_name, + thread_id=thread_id, + ) + + if thread_id: + self._threads.mark(thread_id) + + self._background_read_receipt(room_id, event_id) + + return body, is_dm, chat_type, thread_id, display_name, source + + async def _handle_text_message( + self, + room_id: str, + sender: str, + event_id: str, + event_ts: float, + source_content: dict, + relates_to: dict, + ) -> None: + """Process a text message event.""" + body = source_content.get("body", "") or "" + if not body: + return + + ctx = await self._resolve_message_context( + room_id, sender, event_id, body, source_content, relates_to, + ) + if ctx is None: + return + body, is_dm, chat_type, thread_id, display_name, source = ctx + + # Reply-to detection. + reply_to = None + in_reply_to = relates_to.get("m.in_reply_to", {}) + if in_reply_to: + reply_to = in_reply_to.get("event_id") + + # Strip reply fallback from body. + if reply_to and body.startswith("> "): + lines = body.split("\n") + stripped = [] + past_fallback = False + for line in lines: + if not past_fallback: + if line.startswith("> ") or line == ">": + continue + if line == "": + past_fallback = True + continue + past_fallback = True + stripped.append(line) + body = "\n".join(stripped) if stripped else body + + msg_type = MessageType.TEXT + if body.startswith(("!", "/")): + msg_type = MessageType.COMMAND + + msg_event = MessageEvent( + text=body, + message_type=msg_type, + source=source, + raw_message=source_content, + message_id=event_id, + reply_to_message_id=reply_to, + ) + + if msg_type == MessageType.TEXT and self._text_batch_delay_seconds > 0: + self._enqueue_text_event(msg_event) + else: + await self.handle_message(msg_event) + + async def _handle_media_message( + self, + room_id: str, + sender: str, + event_id: str, + event_ts: float, + source_content: dict, + relates_to: dict, + msgtype: str, + ) -> None: + """Process a media message event (image, audio, video, file).""" + body = source_content.get("body", "") or "" + url = source_content.get("url", "") + + # Convert mxc:// to HTTP URL for downstream processing. + http_url = "" + if url and url.startswith("mxc://"): + http_url = self._mxc_to_http(url) + + # Extract MIME type from content info. + content_info = source_content.get("info", {}) + if not isinstance(content_info, dict): + content_info = {} + event_mimetype = content_info.get("mimetype", "") + + # For encrypted media, the URL may be in file.url. + file_content = source_content.get("file", {}) + if not url and isinstance(file_content, dict): + url = file_content.get("url", "") or "" + if url and url.startswith("mxc://"): + http_url = self._mxc_to_http(url) + + is_encrypted_media = bool(file_content and isinstance(file_content, dict) and file_content.get("url")) + + media_type = "application/octet-stream" + msg_type = MessageType.DOCUMENT + is_voice_message = False + + if msgtype == "m.image": + msg_type = MessageType.PHOTO + media_type = event_mimetype or "image/png" + elif msgtype == "m.audio": + if source_content.get("org.matrix.msc3245.voice") is not None: + is_voice_message = True + msg_type = MessageType.VOICE + else: + msg_type = MessageType.AUDIO + media_type = event_mimetype or "audio/ogg" + elif msgtype == "m.video": + msg_type = MessageType.VIDEO + media_type = event_mimetype or "video/mp4" + elif event_mimetype: + media_type = event_mimetype + + # Cache media locally when downstream tools need a real file path. + cached_path = None + should_cache_locally = ( + msg_type == MessageType.PHOTO or is_voice_message or is_encrypted_media + ) + if should_cache_locally and url: + try: + file_bytes = await self._client.download_media(ContentURI(url)) + if file_bytes is not None: + if is_encrypted_media: + from mautrix.crypto.attachments import decrypt_attachment + + hashes_value = file_content.get("hashes") if isinstance(file_content, dict) else None + hash_value = hashes_value.get("sha256") if isinstance(hashes_value, dict) else None + + key_value = file_content.get("key") if isinstance(file_content, dict) else None + if isinstance(key_value, dict): + key_value = key_value.get("k") + + iv_value = file_content.get("iv") if isinstance(file_content, dict) else None + + if key_value and hash_value and iv_value: + file_bytes = decrypt_attachment(file_bytes, key_value, hash_value, iv_value) + else: + logger.warning( + "[Matrix] Encrypted media event missing decryption metadata for %s", + event_id, + ) + file_bytes = None + + if file_bytes is not None: + from gateway.platforms.base import ( + cache_audio_from_bytes, + cache_document_from_bytes, + cache_image_from_bytes, + ) + + if msg_type == MessageType.PHOTO: + ext_map = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + } + ext = ext_map.get(media_type, ".jpg") + cached_path = cache_image_from_bytes(file_bytes, ext=ext) + logger.info("[Matrix] Cached user image at %s", cached_path) + elif msg_type in (MessageType.AUDIO, MessageType.VOICE): + ext = Path(body or ("voice.ogg" if is_voice_message else "audio.ogg")).suffix or ".ogg" + cached_path = cache_audio_from_bytes(file_bytes, ext=ext) + else: + filename = body or ( + "video.mp4" if msg_type == MessageType.VIDEO else "document" + ) + cached_path = cache_document_from_bytes(file_bytes, filename) + except Exception as e: + logger.warning("[Matrix] Failed to cache media: %s", e) + + ctx = await self._resolve_message_context( + room_id, sender, event_id, body, source_content, relates_to, + ) + if ctx is None: + return + body, is_dm, chat_type, thread_id, display_name, source = ctx + + allow_http_fallback = bool(http_url) and not is_encrypted_media + media_urls = [cached_path] if cached_path else ([http_url] if allow_http_fallback else None) + media_types = [media_type] if media_urls else None + + msg_event = MessageEvent( + text=body, + message_type=msg_type, + source=source, + raw_message=source_content, + message_id=event_id, + media_urls=media_urls, + media_types=media_types, + ) + + await self.handle_message(msg_event) + + async def _on_encrypted_event(self, event: Any) -> None: + """Handle encrypted events that could not be auto-decrypted.""" + room_id = str(getattr(event, "room_id", "")) + event_id = str(getattr(event, "event_id", "")) + + if self._is_duplicate_event(event_id): + return + + logger.warning( + "Matrix: could not decrypt event %s in %s — buffering for retry", + event_id, room_id, + ) + + self._pending_megolm.append((room_id, event, time.time())) + if len(self._pending_megolm) > _MAX_PENDING_EVENTS: + self._pending_megolm = self._pending_megolm[-_MAX_PENDING_EVENTS:] + + async def _on_invite(self, event: Any) -> None: + """Auto-join rooms when invited.""" + + room_id = str(getattr(event, "room_id", "")) + + logger.info( + "Matrix: invited to %s — joining", + room_id, + ) + try: + await self._client.join_room(RoomID(room_id)) + self._joined_rooms.add(room_id) + logger.info("Matrix: joined %s", room_id) + await self._refresh_dm_cache() + except Exception as exc: + logger.warning("Matrix: error joining %s: %s", room_id, exc) + + # ------------------------------------------------------------------ + # Reactions (send, receive, processing lifecycle) + # ------------------------------------------------------------------ + + async def _send_reaction( + self, room_id: str, event_id: str, emoji: str, + ) -> Optional[str]: + """Send an emoji reaction to a message in a room. + Returns the reaction event_id on success, None on failure. + """ + + if not self._client: + return None + content = { + "m.relates_to": { + "rel_type": "m.annotation", + "event_id": event_id, + "key": emoji, + } + } + try: + resp_event_id = await self._client.send_message_event( + RoomID(room_id), EventType.REACTION, content, + ) + logger.debug("Matrix: sent reaction %s to %s", emoji, event_id) + return str(resp_event_id) + except Exception as exc: + logger.debug("Matrix: reaction send error: %s", exc) + return None + + async def _redact_reaction( + self, room_id: str, reaction_event_id: str, reason: str = "", + ) -> bool: + """Remove a reaction by redacting its event.""" + return await self.redact_message(room_id, reaction_event_id, reason) + + async def on_processing_start(self, event: MessageEvent) -> None: + """Add eyes reaction when the agent starts processing a message.""" + if not self._reactions_enabled: + return + msg_id = event.message_id + room_id = event.source.chat_id + if msg_id and room_id: + reaction_event_id = await self._send_reaction(room_id, msg_id, "\U0001f440") + if reaction_event_id: + self._pending_reactions[(room_id, msg_id)] = reaction_event_id + + async def on_processing_complete( + self, event: MessageEvent, outcome: ProcessingOutcome, + ) -> None: + """Replace eyes with checkmark (success) or cross (failure).""" + if not self._reactions_enabled: + return + msg_id = event.message_id + room_id = event.source.chat_id + if not msg_id or not room_id: + return + if outcome == ProcessingOutcome.CANCELLED: + return + reaction_key = (room_id, msg_id) + if reaction_key in self._pending_reactions: + eyes_event_id = self._pending_reactions.pop(reaction_key) + if not await self._redact_reaction(room_id, eyes_event_id): + logger.debug("Matrix: failed to redact eyes reaction %s", eyes_event_id) + await self._send_reaction( + room_id, + msg_id, + "\u2705" if outcome == ProcessingOutcome.SUCCESS else "\u274c", + ) + + async def _on_reaction(self, event: Any) -> None: + """Handle incoming reaction events.""" + sender = str(getattr(event, "sender", "")) + if sender == self._user_id: + return + event_id = str(getattr(event, "event_id", "")) + if self._is_duplicate_event(event_id): + return + + room_id = str(getattr(event, "room_id", "")) + content = getattr(event, "content", None) + if content: + relates_to = content.get("m.relates_to", {}) if isinstance(content, dict) else getattr(content, "relates_to", {}) + reacts_to = "" + key = "" + if isinstance(relates_to, dict): + reacts_to = relates_to.get("event_id", "") + key = relates_to.get("key", "") + elif hasattr(relates_to, "event_id"): + reacts_to = str(getattr(relates_to, "event_id", "")) + key = str(getattr(relates_to, "key", "")) + logger.info( + "Matrix: reaction %s from %s on %s in %s", + key, sender, reacts_to, room_id, + ) + + # ------------------------------------------------------------------ + # Text message aggregation (handles Matrix client-side splits) + # ------------------------------------------------------------------ + + def _text_batch_key(self, event: MessageEvent) -> str: + """Session-scoped key for text message batching.""" + from gateway.session import build_session_key + return build_session_key( + event.source, + group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), + thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + ) + + def _enqueue_text_event(self, event: MessageEvent) -> None: + """Buffer a text event and reset the flush timer.""" + key = self._text_batch_key(event) + existing = self._pending_text_batches.get(key) + chunk_len = len(event.text or "") + if existing is None: + event._last_chunk_len = chunk_len # type: ignore[attr-defined] + self._pending_text_batches[key] = event + else: + if event.text: + existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text + existing._last_chunk_len = chunk_len # type: ignore[attr-defined] + if event.media_urls: + existing.media_urls.extend(event.media_urls) + existing.media_types.extend(event.media_types) + + prior_task = self._pending_text_batch_tasks.get(key) + if prior_task and not prior_task.done(): + prior_task.cancel() + self._pending_text_batch_tasks[key] = asyncio.create_task( + self._flush_text_batch(key) + ) + + async def _flush_text_batch(self, key: str) -> None: + """Wait for the quiet period then dispatch the aggregated text.""" + current_task = asyncio.current_task() + try: + pending = self._pending_text_batches.get(key) + last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0 + if last_len >= self._SPLIT_THRESHOLD: + delay = self._text_batch_split_delay_seconds + else: + delay = self._text_batch_delay_seconds + await asyncio.sleep(delay) + event = self._pending_text_batches.pop(key, None) + if not event: + return + logger.info( + "[Matrix] Flushing text batch %s (%d chars)", + key, len(event.text or ""), + ) + await self.handle_message(event) + finally: + if self._pending_text_batch_tasks.get(key) is current_task: + self._pending_text_batch_tasks.pop(key, None) + + # ------------------------------------------------------------------ + # Read receipts + # ------------------------------------------------------------------ + + def _background_read_receipt(self, room_id: str, event_id: str) -> None: + """Fire-and-forget read receipt with error logging.""" + async def _send() -> None: + try: + await self.send_read_receipt(room_id, event_id) + except Exception as exc: # pragma: no cover — defensive + logger.debug("Matrix: background read receipt failed: %s", exc) + asyncio.ensure_future(_send()) + + async def send_read_receipt(self, room_id: str, event_id: str) -> bool: + """Send a read receipt (m.read) for an event.""" + if not self._client: + return False + try: + await self._client.set_read_markers( + RoomID(room_id), + fully_read_event=EventID(event_id), + read_receipt=EventID(event_id), + ) + logger.debug("Matrix: sent read receipt for %s in %s", event_id, room_id) + return True + except Exception as exc: + logger.debug("Matrix: read receipt failed: %s", exc) + return False + + # ------------------------------------------------------------------ + # Message redaction + # ------------------------------------------------------------------ + + async def redact_message( + self, room_id: str, event_id: str, reason: str = "", + ) -> bool: + """Redact (delete) a message or event from a room.""" + if not self._client: + return False + try: + await self._client.redact( + RoomID(room_id), EventID(event_id), reason=reason or None, + ) + logger.info("Matrix: redacted %s in %s", event_id, room_id) + return True + except Exception as exc: + logger.warning("Matrix: redact error: %s", exc) + return False + + # ------------------------------------------------------------------ + # Room creation & management + # ------------------------------------------------------------------ + + async def create_room( + self, + name: str = "", + topic: str = "", + invite: Optional[list] = None, + is_direct: bool = False, + preset: str = "private_chat", + ) -> Optional[str]: + """Create a new Matrix room.""" + if not self._client: + return None + try: + preset_enum = { + "private_chat": RoomCreatePreset.PRIVATE, + "public_chat": RoomCreatePreset.PUBLIC, + "trusted_private_chat": RoomCreatePreset.TRUSTED_PRIVATE, + }.get(preset, RoomCreatePreset.PRIVATE) + invitees = [UserID(u) for u in (invite or [])] + room_id = await self._client.create_room( + name=name or None, + topic=topic or None, + invitees=invitees, + is_direct=is_direct, + preset=preset_enum, + ) + room_id_str = str(room_id) + self._joined_rooms.add(room_id_str) + logger.info("Matrix: created room %s (%s)", room_id_str, name or "unnamed") + return room_id_str + except Exception as exc: + logger.warning("Matrix: create_room error: %s", exc) + return None + + async def invite_user(self, room_id: str, user_id: str) -> bool: + """Invite a user to a room.""" + if not self._client: + return False + try: + await self._client.invite_user(RoomID(room_id), UserID(user_id)) + logger.info("Matrix: invited %s to %s", user_id, room_id) + return True + except Exception as exc: + logger.warning("Matrix: invite error: %s", exc) + return False + + # ------------------------------------------------------------------ + # Presence + # ------------------------------------------------------------------ + + _VALID_PRESENCE_STATES = frozenset(("online", "offline", "unavailable")) + + async def set_presence(self, state: str = "online", status_msg: str = "") -> bool: + """Set the bot's presence status.""" + if not self._client: + return False + if state not in self._VALID_PRESENCE_STATES: + logger.warning("Matrix: invalid presence state %r", state) + return False + try: + presence_map = { + "online": PresenceState.ONLINE, + "offline": PresenceState.OFFLINE, + "unavailable": PresenceState.UNAVAILABLE, + } + await self._client.set_presence( + presence=presence_map[state], + status=status_msg or None, + ) + logger.debug("Matrix: presence set to %s", state) + return True + except Exception as exc: + logger.debug("Matrix: set_presence failed: %s", exc) + return False + + # ------------------------------------------------------------------ + # Emote & notice message types + # ------------------------------------------------------------------ + + async def _send_simple_message( + self, chat_id: str, text: str, msgtype: str, + ) -> SendResult: + """Send a simple message (emote, notice) with optional HTML formatting.""" + if not self._client or not text: + return SendResult(success=False, error="No client or empty text") + + msg_content: Dict[str, Any] = {"msgtype": msgtype, "body": text} + html = self._markdown_to_html(text) + if html and html != text: + msg_content["format"] = "org.matrix.custom.html" + msg_content["formatted_body"] = html + + try: + event_id = await self._client.send_message_event( + RoomID(chat_id), EventType.ROOM_MESSAGE, msg_content, + ) + return SendResult(success=True, message_id=str(event_id)) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + async def _is_dm_room(self, room_id: str) -> bool: + """Check if a room is a DM.""" + if self._dm_rooms.get(room_id, False): + return True + # Fallback: check member count via state store. + state_store = getattr(self._client, "state_store", None) if self._client else None + if state_store: + try: + members = await state_store.get_members(room_id) + if members and len(members) == 2: + return True + except Exception: + pass + return False + + async def _refresh_dm_cache(self) -> None: + """Refresh the DM room cache from m.direct account data.""" + if not self._client: + return + + dm_data: Optional[Dict] = None + + try: + resp = await self._client.get_account_data("m.direct") + if hasattr(resp, "content"): + dm_data = resp.content + elif isinstance(resp, dict): + dm_data = resp + except Exception as exc: + logger.debug("Matrix: get_account_data('m.direct') failed: %s", exc) + + if dm_data is None: + return + + dm_room_ids: Set[str] = set() + for user_id, rooms in dm_data.items(): + if isinstance(rooms, list): + dm_room_ids.update(str(r) for r in rooms) + + self._dm_rooms = { + rid: (rid in dm_room_ids) + for rid in self._joined_rooms + } + + # ------------------------------------------------------------------ + # Mention detection helpers + # ------------------------------------------------------------------ + + def _is_bot_mentioned( + self, + body: str, + formatted_body: Optional[str] = None, + mention_user_ids: Optional[list] = None, + ) -> bool: + """Return True if the bot is mentioned in the message. + + Per MSC3952, ``m.mentions.user_ids`` is the authoritative mention + signal in the Matrix spec. When the sender's client populates that + field with the bot's user-id, we trust it — even when the visible + body text does not contain an explicit ``@bot`` string (some clients + only render mention "pills" in ``formatted_body`` or use display + names). + """ + # m.mentions.user_ids — authoritative per MSC3952 / Matrix v1.7. + if mention_user_ids and self._user_id and self._user_id in mention_user_ids: + return True + if not body and not formatted_body: + return False + if self._user_id and self._user_id in body: + return True + if self._user_id and ":" in self._user_id: + localpart = self._user_id.split(":")[0].lstrip("@") + if localpart and re.search(r'\b' + re.escape(localpart) + r'\b', body, re.IGNORECASE): + return True + if formatted_body and self._user_id: + if f"matrix.to/#/{self._user_id}" in formatted_body: + return True + return False + + def _strip_mention(self, body: str) -> str: + """Remove bot mention from message body.""" + if self._user_id: + body = body.replace(self._user_id, "") + if self._user_id and ":" in self._user_id: + localpart = self._user_id.split(":")[0].lstrip("@") + if localpart: + body = re.sub(r'\b' + re.escape(localpart) + r'\b', '', body, flags=re.IGNORECASE) + return body.strip() + + async def _get_display_name(self, room_id: str, user_id: str) -> str: + """Get a user's display name in a room, falling back to user_id.""" + state_store = getattr(self._client, "state_store", None) if self._client else None + if state_store: + try: + member = await state_store.get_member(room_id, user_id) + if member and getattr(member, "displayname", None): + return member.displayname + except Exception: + pass + # Strip the @...:server format to just the localpart. + if user_id.startswith("@") and ":" in user_id: + return user_id[1:].split(":")[0] + return user_id + + def _mxc_to_http(self, mxc_url: str) -> str: + """Convert mxc://server/media_id to an HTTP download URL.""" + if not mxc_url.startswith("mxc://"): + return mxc_url + parts = mxc_url[6:] # strip mxc:// + return f"{self._homeserver}/_matrix/client/v1/media/download/{parts}" + + def _markdown_to_html(self, text: str) -> str: + """Convert Markdown to Matrix-compatible HTML (org.matrix.custom.html). + + Uses the ``markdown`` library when available (installed with the + ``matrix`` extra). Falls back to a comprehensive regex converter + that handles fenced code blocks, inline code, headers, bold, + italic, strikethrough, links, blockquotes, lists, and horizontal + rules — everything the Matrix HTML spec allows. + """ + try: + import markdown as _md + + md = _md.Markdown( + extensions=["fenced_code", "tables", "nl2br", "sane_lists"], + ) + if "html_block" in md.preprocessors: + md.preprocessors.deregister("html_block") + + html = md.convert(text) + md.reset() + + if html.count("

") == 1: + html = html.replace("

", "").replace("

", "") + return html + except ImportError: + pass + + return self._markdown_to_html_fallback(text) + + # ------------------------------------------------------------------ + # Regex-based Markdown -> HTML (no extra dependencies) + # ------------------------------------------------------------------ + + @staticmethod + def _sanitize_link_url(url: str) -> str: + """Sanitize a URL for use in an href attribute.""" + stripped = url.strip() + scheme = stripped.split(":", 1)[0].lower().strip() if ":" in stripped else "" + if scheme in ("javascript", "data", "vbscript"): + return "" + return stripped.replace('"', """) + + @staticmethod + def _markdown_to_html_fallback(text: str) -> str: + """Comprehensive regex Markdown-to-HTML for Matrix.""" + placeholders: list = [] + + def _protect_html(html_fragment: str) -> str: + idx = len(placeholders) + placeholders.append(html_fragment) + return f"\x00PROTECTED{idx}\x00" + + # Fenced code blocks: ```lang\n...\n``` + result = re.sub( + r"```(\w*)\n(.*?)```", + lambda m: _protect_html( + f'
'
+                f"{_html_escape(m.group(2))}
" + if m.group(1) + else f"
{_html_escape(m.group(2))}
" + ), + text, + flags=re.DOTALL, + ) + + # Inline code: `code` + result = re.sub( + r"`([^`\n]+)`", + lambda m: _protect_html( + f"{_html_escape(m.group(1))}" + ), + result, + ) + + # Extract and protect markdown links before escaping. + result = re.sub( + r"\[([^\]]+)\]\(([^)]+)\)", + lambda m: _protect_html( + '{}'.format( + MatrixAdapter._sanitize_link_url(m.group(2)), + _html_escape(m.group(1)), + ) + ), + result, + ) + + # HTML-escape remaining text. + parts = re.split(r"(\x00PROTECTED\d+\x00)", result) + for idx, part in enumerate(parts): + if not part.startswith("\x00PROTECTED"): + parts[idx] = _html_escape(part) + result = "".join(parts) + + # Block-level transforms (line-oriented). + lines = result.split("\n") + out_lines: list = [] + i = 0 + while i < len(lines): + line = lines[i] + + # Horizontal rule + if re.match(r"^[\s]*([-*_])\s*\1\s*\1[\s\-*_]*$", line): + out_lines.append("
") + i += 1 + continue + + # Headers + hdr = re.match(r"^(#{1,6})\s+(.+)$", line) + if hdr: + level = len(hdr.group(1)) + out_lines.append(f"{hdr.group(2).strip()}") + i += 1 + continue + + # Blockquote + if line.startswith("> ") or line == ">" or line.startswith("> ") or line == ">": + bq_lines = [] + while i < len(lines) and ( + lines[i].startswith("> ") or lines[i] == ">" + or lines[i].startswith("> ") or lines[i] == ">" + ): + ln = lines[i] + if ln.startswith("> "): + bq_lines.append(ln[5:]) + elif ln.startswith("> "): + bq_lines.append(ln[2:]) + else: + bq_lines.append("") + i += 1 + out_lines.append(f"
{'
'.join(bq_lines)}
") + continue + + # Unordered list + ul_match = re.match(r"^[\s]*[-*+]\s+(.+)$", line) + if ul_match: + items = [] + while i < len(lines) and re.match(r"^[\s]*[-*+]\s+(.+)$", lines[i]): + items.append(re.match(r"^[\s]*[-*+]\s+(.+)$", lines[i]).group(1)) + i += 1 + li = "".join(f"
  • {item}
  • " for item in items) + out_lines.append(f"
      {li}
    ") + continue + + # Ordered list + ol_match = re.match(r"^[\s]*\d+[.)]\s+(.+)$", line) + if ol_match: + items = [] + while i < len(lines) and re.match(r"^[\s]*\d+[.)]\s+(.+)$", lines[i]): + items.append(re.match(r"^[\s]*\d+[.)]\s+(.+)$", lines[i]).group(1)) + i += 1 + li = "".join(f"
  • {item}
  • " for item in items) + out_lines.append(f"
      {li}
    ") + continue + + out_lines.append(line) + i += 1 + + result = "\n".join(out_lines) + + # Inline transforms. + result = re.sub(r"\*\*(.+?)\*\*", r"\1", result, flags=re.DOTALL) + result = re.sub(r"__(.+?)__", r"\1", result, flags=re.DOTALL) + result = re.sub(r"\*(.+?)\*", r"\1", result, flags=re.DOTALL) + result = re.sub(r"(?\1", result, flags=re.DOTALL) + result = re.sub(r"~~(.+?)~~", r"\1", result, flags=re.DOTALL) + result = re.sub(r"\n", "
    \n", result) + result = re.sub(r"
    \n()
    ", r"\1", result) + + # Restore protected regions. + for idx, original in enumerate(placeholders): + result = result.replace(f"\x00PROTECTED{idx}\x00", original) + + return result diff --git a/mindcli/_vendor/gateway/platforms/mattermost.py b/mindcli/_vendor/gateway/platforms/mattermost.py new file mode 100644 index 0000000..23a86f0 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/mattermost.py @@ -0,0 +1,733 @@ +"""Mattermost gateway adapter. + +Connects to a self-hosted (or cloud) Mattermost instance via its REST API +(v4) and WebSocket for real-time events. No external Mattermost library +required — uses aiohttp which is already a Hermes dependency. + +Environment variables: + MATTERMOST_URL Server URL (e.g. https://mm.example.com) + MATTERMOST_TOKEN Bot token or personal-access token + MATTERMOST_ALLOWED_USERS Comma-separated user IDs + MATTERMOST_HOME_CHANNEL Channel ID for cron/notification delivery +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import re +from pathlib import Path +from typing import Any, Dict, List, Optional + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.helpers import MessageDeduplicator +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, +) + +logger = logging.getLogger(__name__) + +# Mattermost post size limit (server default is 16383, but 4000 is the +# practical limit for readable messages — matching OpenClaw's choice). +MAX_POST_LENGTH = 4000 + +# Channel type codes returned by the Mattermost API. +_CHANNEL_TYPE_MAP = { + "D": "dm", + "G": "group", + "P": "group", # private channel → treat as group + "O": "channel", +} + +# Reconnect parameters (exponential backoff). +_RECONNECT_BASE_DELAY = 2.0 +_RECONNECT_MAX_DELAY = 60.0 +_RECONNECT_JITTER = 0.2 + + +def check_mattermost_requirements() -> bool: + """Return True if the Mattermost adapter can be used.""" + token = os.getenv("MATTERMOST_TOKEN", "") + url = os.getenv("MATTERMOST_URL", "") + if not token: + logger.debug("Mattermost: MATTERMOST_TOKEN not set") + return False + if not url: + logger.warning("Mattermost: MATTERMOST_URL not set") + return False + try: + import aiohttp # noqa: F401 + return True + except ImportError: + logger.warning("Mattermost: aiohttp not installed") + return False + + +class MattermostAdapter(BasePlatformAdapter): + """Gateway adapter for Mattermost (self-hosted or cloud).""" + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.MATTERMOST) + + self._base_url: str = ( + config.extra.get("url", "") + or os.getenv("MATTERMOST_URL", "") + ).rstrip("/") + self._token: str = config.token or os.getenv("MATTERMOST_TOKEN", "") + + self._bot_user_id: str = "" + self._bot_username: str = "" + + # aiohttp session + websocket handle + self._session: Any = None # aiohttp.ClientSession + self._ws: Any = None # aiohttp.ClientWebSocketResponse + self._ws_task: Optional[asyncio.Task] = None + self._reconnect_task: Optional[asyncio.Task] = None + self._closing = False + + # Reply mode: "thread" to nest replies, "off" for flat messages. + self._reply_mode: str = ( + config.extra.get("reply_mode", "") + or os.getenv("MATTERMOST_REPLY_MODE", "off") + ).lower() + + # Dedup cache (prevent reprocessing) + self._dedup = MessageDeduplicator() + + # ------------------------------------------------------------------ + # HTTP helpers + # ------------------------------------------------------------------ + + def _headers(self) -> Dict[str, str]: + return { + "Authorization": f"Bearer {self._token}", + "Content-Type": "application/json", + } + + async def _api_get(self, path: str) -> Dict[str, Any]: + """GET /api/v4/{path}.""" + import aiohttp + url = f"{self._base_url}/api/v4/{path.lstrip('/')}" + try: + async with self._session.get(url, headers=self._headers(), timeout=aiohttp.ClientTimeout(total=30)) as resp: + if resp.status >= 400: + body = await resp.text() + logger.error("MM API GET %s → %s: %s", path, resp.status, body[:200]) + return {} + return await resp.json() + except aiohttp.ClientError as exc: + logger.error("MM API GET %s network error: %s", path, exc) + return {} + + async def _api_post( + self, path: str, payload: Dict[str, Any] + ) -> Dict[str, Any]: + """POST /api/v4/{path} with JSON body.""" + import aiohttp + url = f"{self._base_url}/api/v4/{path.lstrip('/')}" + try: + async with self._session.post( + url, headers=self._headers(), json=payload, + timeout=aiohttp.ClientTimeout(total=30) + ) as resp: + if resp.status >= 400: + body = await resp.text() + logger.error("MM API POST %s → %s: %s", path, resp.status, body[:200]) + return {} + return await resp.json() + except aiohttp.ClientError as exc: + logger.error("MM API POST %s network error: %s", path, exc) + return {} + + async def _api_put( + self, path: str, payload: Dict[str, Any] + ) -> Dict[str, Any]: + """PUT /api/v4/{path} with JSON body.""" + import aiohttp + url = f"{self._base_url}/api/v4/{path.lstrip('/')}" + try: + async with self._session.put( + url, headers=self._headers(), json=payload + ) as resp: + if resp.status >= 400: + body = await resp.text() + logger.error("MM API PUT %s → %s: %s", path, resp.status, body[:200]) + return {} + return await resp.json() + except aiohttp.ClientError as exc: + logger.error("MM API PUT %s network error: %s", path, exc) + return {} + + async def _upload_file( + self, channel_id: str, file_data: bytes, filename: str, content_type: str = "application/octet-stream" + ) -> Optional[str]: + """Upload a file and return its file ID, or None on failure.""" + import aiohttp + + url = f"{self._base_url}/api/v4/files" + form = aiohttp.FormData() + form.add_field("channel_id", channel_id) + form.add_field( + "files", + file_data, + filename=filename, + content_type=content_type, + ) + headers = {"Authorization": f"Bearer {self._token}"} + async with self._session.post(url, headers=headers, data=form, timeout=aiohttp.ClientTimeout(total=60)) as resp: + if resp.status >= 400: + body = await resp.text() + logger.error("MM file upload → %s: %s", resp.status, body[:200]) + return None + data = await resp.json() + infos = data.get("file_infos", []) + return infos[0]["id"] if infos else None + + # ------------------------------------------------------------------ + # Required overrides + # ------------------------------------------------------------------ + + async def connect(self) -> bool: + """Connect to Mattermost and start the WebSocket listener.""" + import aiohttp + + if not self._base_url or not self._token: + logger.error("Mattermost: URL or token not configured") + return False + + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=30) + ) + self._closing = False + + # Verify credentials and fetch bot identity. + me = await self._api_get("users/me") + if not me or "id" not in me: + logger.error("Mattermost: failed to authenticate — check MATTERMOST_TOKEN and MATTERMOST_URL") + await self._session.close() + return False + + self._bot_user_id = me["id"] + self._bot_username = me.get("username", "") + logger.info( + "Mattermost: authenticated as @%s (%s) on %s", + self._bot_username, + self._bot_user_id, + self._base_url, + ) + + # Start WebSocket in background. + self._ws_task = asyncio.create_task(self._ws_loop()) + self._mark_connected() + return True + + async def disconnect(self) -> None: + """Disconnect from Mattermost.""" + self._closing = True + + if self._ws_task and not self._ws_task.done(): + self._ws_task.cancel() + try: + await self._ws_task + except (asyncio.CancelledError, Exception): + pass + + if self._reconnect_task and not self._reconnect_task.done(): + self._reconnect_task.cancel() + + if self._ws: + await self._ws.close() + self._ws = None + + if self._session and not self._session.closed: + await self._session.close() + + logger.info("Mattermost: disconnected") + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a message (or multiple chunks) to a channel.""" + if not content: + return SendResult(success=True) + + formatted = self.format_message(content) + chunks = self.truncate_message(formatted, MAX_POST_LENGTH) + + last_id = None + for chunk in chunks: + payload: Dict[str, Any] = { + "channel_id": chat_id, + "message": chunk, + } + # Thread support: reply_to is the root post ID. + if reply_to and self._reply_mode == "thread": + payload["root_id"] = reply_to + + data = await self._api_post("posts", payload) + if not data or "id" not in data: + return SendResult(success=False, error="Failed to create post") + last_id = data["id"] + + return SendResult(success=True, message_id=last_id) + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Return channel name and type.""" + data = await self._api_get(f"channels/{chat_id}") + if not data: + return {"name": chat_id, "type": "channel"} + + ch_type = _CHANNEL_TYPE_MAP.get(data.get("type", "O"), "channel") + display_name = data.get("display_name") or data.get("name") or chat_id + return {"name": display_name, "type": ch_type} + + # ------------------------------------------------------------------ + # Optional overrides + # ------------------------------------------------------------------ + + async def send_typing( + self, chat_id: str, metadata: Optional[Dict[str, Any]] = None + ) -> None: + """Send a typing indicator.""" + await self._api_post( + f"users/{self._bot_user_id}/typing", + {"channel_id": chat_id}, + ) + + async def edit_message( + self, chat_id: str, message_id: str, content: str + ) -> SendResult: + """Edit an existing post.""" + formatted = self.format_message(content) + data = await self._api_put( + f"posts/{message_id}/patch", + {"message": formatted}, + ) + if not data or "id" not in data: + return SendResult(success=False, error="Failed to edit post") + return SendResult(success=True, message_id=data["id"]) + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Download an image and upload it as a file attachment.""" + return await self._send_url_as_file( + chat_id, image_url, caption, reply_to, "image" + ) + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Upload a local image file.""" + return await self._send_local_file( + chat_id, image_path, caption, reply_to + ) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Upload a local file as a document.""" + return await self._send_local_file( + chat_id, file_path, caption, reply_to, file_name + ) + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Upload an audio file.""" + return await self._send_local_file( + chat_id, audio_path, caption, reply_to + ) + + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Upload a video file.""" + return await self._send_local_file( + chat_id, video_path, caption, reply_to + ) + + def format_message(self, content: str) -> str: + """Mattermost uses standard Markdown — mostly pass through. + + Strip image markdown into plain links (files are uploaded separately). + """ + # Convert ![alt](url) to just the URL — Mattermost renders + # image URLs as inline previews automatically. + content = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r"\2", content) + return content + + # ------------------------------------------------------------------ + # File helpers + # ------------------------------------------------------------------ + + async def _send_url_as_file( + self, + chat_id: str, + url: str, + caption: Optional[str], + reply_to: Optional[str], + kind: str = "file", + ) -> SendResult: + """Download a URL and upload it as a file attachment.""" + from tools.url_safety import is_safe_url + if not is_safe_url(url): + logger.warning("Mattermost: blocked unsafe URL (SSRF protection)") + return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to) + + import asyncio + import aiohttp + + last_exc = None + file_data = None + ct = "application/octet-stream" + fname = url.rsplit("/", 1)[-1].split("?")[0] or f"{kind}.png" + + for attempt in range(3): + try: + async with self._session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp: + if resp.status >= 500 or resp.status == 429: + if attempt < 2: + logger.debug("Mattermost download retry %d/2 for %s (status %d)", + attempt + 1, url[:80], resp.status) + await asyncio.sleep(1.5 * (attempt + 1)) + continue + if resp.status >= 400: + return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to) + file_data = await resp.read() + ct = resp.content_type or "application/octet-stream" + break + except (aiohttp.ClientError, asyncio.TimeoutError) as exc: + if attempt < 2: + await asyncio.sleep(1.5 * (attempt + 1)) + continue + logger.warning("Mattermost: failed to download %s after %d attempts: %s", url, attempt + 1, exc) + return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to) + + if file_data is None: + logger.warning("Mattermost: download returned no data for %s", url) + return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to) + + file_id = await self._upload_file(chat_id, file_data, fname, ct) + if not file_id: + return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to) + + payload: Dict[str, Any] = { + "channel_id": chat_id, + "message": caption or "", + "file_ids": [file_id], + } + if reply_to and self._reply_mode == "thread": + payload["root_id"] = reply_to + + data = await self._api_post("posts", payload) + if not data or "id" not in data: + return SendResult(success=False, error="Failed to post with file") + return SendResult(success=True, message_id=data["id"]) + + async def _send_local_file( + self, + chat_id: str, + file_path: str, + caption: Optional[str], + reply_to: Optional[str], + file_name: Optional[str] = None, + ) -> SendResult: + """Upload a local file and attach it to a post.""" + import mimetypes + + p = Path(file_path) + if not p.exists(): + return await self.send( + chat_id, f"{caption or ''}\n(file not found: {file_path})", reply_to + ) + + fname = file_name or p.name + ct = mimetypes.guess_type(fname)[0] or "application/octet-stream" + file_data = p.read_bytes() + + file_id = await self._upload_file(chat_id, file_data, fname, ct) + if not file_id: + return SendResult(success=False, error="File upload failed") + + payload: Dict[str, Any] = { + "channel_id": chat_id, + "message": caption or "", + "file_ids": [file_id], + } + if reply_to and self._reply_mode == "thread": + payload["root_id"] = reply_to + + data = await self._api_post("posts", payload) + if not data or "id" not in data: + return SendResult(success=False, error="Failed to post with file") + return SendResult(success=True, message_id=data["id"]) + + # ------------------------------------------------------------------ + # WebSocket + # ------------------------------------------------------------------ + + async def _ws_loop(self) -> None: + """Connect to the WebSocket and listen for events, reconnecting on failure.""" + delay = _RECONNECT_BASE_DELAY + while not self._closing: + try: + await self._ws_connect_and_listen() + # Clean disconnect — reset delay. + delay = _RECONNECT_BASE_DELAY + except asyncio.CancelledError: + return + except Exception as exc: + if self._closing: + return + # Detect permanent auth/permission failures that will never + # succeed on retry — stop reconnecting instead of looping forever. + import aiohttp + err_str = str(exc).lower() + if isinstance(exc, aiohttp.WSServerHandshakeError) and exc.status in (401, 403): + logger.error("Mattermost WS auth failed (HTTP %d) — stopping reconnect", exc.status) + return + if "401" in err_str or "403" in err_str or "unauthorized" in err_str: + logger.error("Mattermost WS permanent error: %s — stopping reconnect", exc) + return + logger.warning("Mattermost WS error: %s — reconnecting in %.0fs", exc, delay) + + if self._closing: + return + + # Exponential backoff with jitter. + import random + jitter = delay * _RECONNECT_JITTER * random.random() + await asyncio.sleep(delay + jitter) + delay = min(delay * 2, _RECONNECT_MAX_DELAY) + + async def _ws_connect_and_listen(self) -> None: + """Single WebSocket session: connect, authenticate, process events.""" + # Build WS URL: https:// → wss://, http:// → ws:// + ws_url = re.sub(r"^http", "ws", self._base_url) + "/api/v4/websocket" + logger.info("Mattermost: connecting to %s", ws_url) + + self._ws = await self._session.ws_connect(ws_url, heartbeat=30.0) + + # Authenticate via the WebSocket. + auth_msg = { + "seq": 1, + "action": "authentication_challenge", + "data": {"token": self._token}, + } + await self._ws.send_json(auth_msg) + logger.info("Mattermost: WebSocket connected and authenticated") + + async for raw_msg in self._ws: + if self._closing: + return + + if raw_msg.type in ( + raw_msg.type.TEXT, + raw_msg.type.BINARY, + ): + try: + event = json.loads(raw_msg.data) + except (json.JSONDecodeError, TypeError): + continue + await self._handle_ws_event(event) + elif raw_msg.type in ( + raw_msg.type.ERROR, + raw_msg.type.CLOSE, + raw_msg.type.CLOSING, + raw_msg.type.CLOSED, + ): + logger.info("Mattermost: WebSocket closed (%s)", raw_msg.type) + break + + async def _handle_ws_event(self, event: Dict[str, Any]) -> None: + """Process a single WebSocket event.""" + event_type = event.get("event") + if event_type != "posted": + return + + data = event.get("data", {}) + raw_post_str = data.get("post") + if not raw_post_str: + return + + try: + post = json.loads(raw_post_str) + except (json.JSONDecodeError, TypeError): + return + + # Ignore own messages. + if post.get("user_id") == self._bot_user_id: + return + + # Ignore system posts. + if post.get("type"): + return + + post_id = post.get("id", "") + + # Dedup. + if self._dedup.is_duplicate(post_id): + return + + # Build message event. + channel_id = post.get("channel_id", "") + channel_type_raw = data.get("channel_type", "O") + chat_type = _CHANNEL_TYPE_MAP.get(channel_type_raw, "channel") + + # For DMs, user_id is sufficient. For channels, check for @mention. + message_text = post.get("message", "") + + # Mention-gating for non-DM channels. + # Config (env vars): + # MATTERMOST_REQUIRE_MENTION: Require @mention in channels (default: true) + # MATTERMOST_FREE_RESPONSE_CHANNELS: Channel IDs where bot responds without mention + if channel_type_raw != "D": + require_mention = os.getenv( + "MATTERMOST_REQUIRE_MENTION", "true" + ).lower() not in ("false", "0", "no") + + free_channels_raw = os.getenv("MATTERMOST_FREE_RESPONSE_CHANNELS", "") + free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()} + is_free_channel = channel_id in free_channels + + mention_patterns = [ + f"@{self._bot_username}", + f"@{self._bot_user_id}", + ] + has_mention = any( + pattern.lower() in message_text.lower() + for pattern in mention_patterns + ) + + if require_mention and not is_free_channel and not has_mention: + logger.debug( + "Mattermost: skipping non-DM message without @mention (channel=%s)", + channel_id, + ) + return + + # Strip @mention from the message text so the agent sees clean input. + if has_mention: + for pattern in mention_patterns: + message_text = re.sub( + re.escape(pattern), "", message_text, flags=re.IGNORECASE + ).strip() + + # Resolve sender info. + sender_id = post.get("user_id", "") + sender_name = data.get("sender_name", "").lstrip("@") or sender_id + + # Thread support: if the post is in a thread, use root_id. + thread_id = post.get("root_id") or None + + # Determine message type. + file_ids = post.get("file_ids") or [] + msg_type = MessageType.TEXT + if message_text.startswith("/"): + msg_type = MessageType.COMMAND + + # Download file attachments immediately (URLs require auth headers + # that downstream tools won't have). + media_urls: List[str] = [] + media_types: List[str] = [] + for fid in file_ids: + try: + file_info = await self._api_get(f"files/{fid}/info") + fname = file_info.get("name", f"file_{fid}") + ext = Path(fname).suffix or "" + mime = file_info.get("mime_type", "application/octet-stream") + + import aiohttp + dl_url = f"{self._base_url}/api/v4/files/{fid}" + async with self._session.get( + dl_url, + headers={"Authorization": f"Bearer {self._token}"}, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + if resp.status < 400: + file_data = await resp.read() + from gateway.platforms.base import cache_image_from_bytes, cache_document_from_bytes + if mime.startswith("image/"): + local_path = cache_image_from_bytes(file_data, ext or ".png") + media_urls.append(local_path) + media_types.append(mime) + elif mime.startswith("audio/"): + from gateway.platforms.base import cache_audio_from_bytes + local_path = cache_audio_from_bytes(file_data, ext or ".ogg") + media_urls.append(local_path) + media_types.append(mime) + else: + local_path = cache_document_from_bytes(file_data, fname) + media_urls.append(local_path) + media_types.append(mime) + else: + logger.warning("Mattermost: failed to download file %s: HTTP %s", fid, resp.status) + except Exception as exc: + logger.warning("Mattermost: error downloading file %s: %s", fid, exc) + + # Set message type based on downloaded media types. + if media_types and msg_type == MessageType.TEXT: + if any(m.startswith("image/") for m in media_types): + msg_type = MessageType.PHOTO + elif any(m.startswith("audio/") for m in media_types): + msg_type = MessageType.VOICE + elif media_types: + msg_type = MessageType.DOCUMENT + + source = self.build_source( + chat_id=channel_id, + chat_type=chat_type, + user_id=sender_id, + user_name=sender_name, + thread_id=thread_id, + ) + + msg_event = MessageEvent( + text=message_text, + message_type=msg_type, + source=source, + raw_message=post, + message_id=post_id, + media_urls=media_urls if media_urls else None, + media_types=media_types if media_types else None, + ) + + await self.handle_message(msg_event) + + diff --git a/mindcli/_vendor/gateway/platforms/md_converter.py b/mindcli/_vendor/gateway/platforms/md_converter.py new file mode 100644 index 0000000..81fa974 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/md_converter.py @@ -0,0 +1,113 @@ +""" +MindOS NEXT — mdConverter:transcript 片段 → Markdown 文件追加 + +封闭函数(铁律 1): + 输入:transcript 文本片段(str)+ 可选毫秒偏移(int) + 输出:追加到 wiki/meetings/*.md + 副作用:仅磁盘文件写入;不调用 LLM,不写 DB。 + +MD-First(铁律 6): + 音频/文件的唯一持久化形式就是这个 .md 文件。 + 无 DB 记录,无 UUID,文件名即身份。 +""" + +import os +import time +import threading +from datetime import datetime +from pathlib import Path +from typing import Optional + + +_MIME_TO_EXT = { + "audio/mpeg": "mp3", "audio/mp4": "m4a", "audio/mp3": "mp3", + "audio/wav": "wav", "audio/webm": "webm", "audio/ogg": "ogg", + "audio/aac": "aac", "audio/flac": "flac", +} + +_SUPPORTED_EXTS = {".mp3", ".mp4", ".m4a", ".wav", ".webm", ".ogg", ".aac", ".flac"} + + +def is_supported_audio(filename: str) -> bool: + return Path(filename).suffix.lower() in _SUPPORTED_EXTS + + +class MdConverter: + """transcript 片段追加为 Markdown 文件(L0 原文层)。""" + + def __init__(self, wiki_dir: str): + self.meetings_dir = Path(wiki_dir) / "raw" + self.meetings_dir.mkdir(parents=True, exist_ok=True) + self._lock = threading.Lock() + + def new_file( + self, + title: str = "未命名", + source_filename: str = "", + duration_hint: str = "", + ) -> Path: + """ + 创建新 MD 文件,写入 YAML-style header,返回文件路径。 + 文件名:YYYY-MM-DD_HHmm_.md + """ + now = datetime.now() + safe_title = title.replace("/", "-").replace("\\", "-")[:20] + filename = f"{now.strftime('%Y-%m-%d_%H%M')}_{safe_title}.md" + file_path = self.meetings_dir / filename + + source_line = f"来源:`{source_filename}` | " if source_filename else "" + duration_line = f"时长:{duration_hint} | " if duration_hint else "" + + header = ( + f"# {now.strftime('%Y-%m-%d %H:%M')} {title}\n\n" + f"> {source_line}{duration_line}" + f"转写模型:qwen3-asr-flash | 入库时间:{now.strftime('%Y-%m-%d %H:%M')}\n\n" + f"---\n\n" + ) + with self._lock: + file_path.write_text(header, encoding="utf-8") + + return file_path + + def append_segment( + self, + file_path: Path, + text: str, + offset_ms: int = 0, + ) -> int: + """ + 追加一个 transcript 片段。 + 格式:[MM:SS] 文字内容 + 返回追加的字符数。 + """ + text = text.strip() + if not text: + return 0 + + if offset_ms > 0: + total_s = offset_ms // 1000 + mm, ss = divmod(total_s, 60) + timestamp = f"[{mm:02d}:{ss:02d}] " + else: + timestamp = "" + + line = f"{timestamp}{text}\n\n" + with self._lock: + with file_path.open("a", encoding="utf-8") as f: + f.write(line) + + return len(text) + + def finalize(self, file_path: Path, char_count: int = 0) -> None: + """写入结束标记。""" + summary = f"\n---\n\n> ✅ 转写完成,共 {char_count} 字\n" + with self._lock: + with file_path.open("a", encoding="utf-8") as f: + f.write(summary) + + def relative_path(self, file_path: Path) -> str: + """返回相对于 meetings_dir 父目录的路径,用于日志/SSE。""" + try: + return str(file_path.relative_to(self.meetings_dir.parent)) + except ValueError: + return str(file_path) diff --git a/mindcli/_vendor/gateway/platforms/mindcli_bridge.py b/mindcli/_vendor/gateway/platforms/mindcli_bridge.py new file mode 100644 index 0000000..a341708 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/mindcli_bridge.py @@ -0,0 +1,249 @@ +""" +Mind CLI Bridge — Cloud 端 WebSocket Tunnel 管理器。 + +管理所有 Mind CLI 用户的 Tunnel 连接: +- 接收 CLI 的 WebSocket 握手 + JWT 认证 +- 能力审计(审批通过的工具白名单) +- 工具调用派发(Cloud LLM → Tunnel → CLI → 结果) +""" + +import asyncio +import json +import logging +import time +import uuid +from typing import Any + +from aiohttp import web + +logger = logging.getLogger("mindcli_bridge") + +# ── 初版白名单(所有内置工具默认通过) ──────────────────── +DEFAULT_APPROVED_TOOLS = [ + "terminal", + "file_read", + "file_write", + "file_ops", + "grep", + "code_execution", +] + +# 工具调用超时 +TOOL_CALL_TIMEOUT = 120 # 秒 +# 心跳检测:60s 无消息判定离线 +HEARTBEAT_TIMEOUT = 60 + + +class MindCLIBridge: + """ + 管理所有 CLI 用户的 Tunnel 连接。 + + 每个 userId 最多维持一个活跃 WebSocket 连接。 + """ + + def __init__(self): + # userId → WebSocketResponse + self._connections: dict[str, web.WebSocketResponse] = {} + # userId → 能力报告 + self._capabilities: dict[str, dict] = {} + # userId → 最后心跳时间 + self._last_heartbeat: dict[str, float] = {} + # 待处理的 tool_call 请求 + self._pending: dict[str, asyncio.Future] = {} + + async def handle_tunnel_connect(self, request: web.Request) -> web.WebSocketResponse: + """ + WebSocket 握手入口。 + + 路由:GET /mindos-next/ws/cli-tunnel + 认证:Header Authorization: Bearer <JWT> + """ + ws = web.WebSocketResponse(heartbeat=30, autoping=True) + await ws.prepare(request) + + # ── JWT 认证 ── + token = _extract_token(request) + if not token: + await ws.send_json({"type": "error", "message": "Missing Authorization header"}) + await ws.close() + return ws + + # 使用 MindPass 认证策略 + user = await self._verify_token(token, request) + if not user: + await ws.send_json({"type": "error", "message": "Invalid or expired token"}) + await ws.close() + return ws + + user_id = user["userId"] + + # 关闭旧连接 + old_ws = self._connections.get(user_id) + if old_ws and not old_ws.closed: + logger.info("[Bridge] 关闭 %s 的旧连接", user_id) + await old_ws.close() + + self._connections[user_id] = ws + self._last_heartbeat[user_id] = time.time() + logger.info("[Bridge] CLI 连接: userId=%s", user_id) + + # ── 发送连接确认 ── + await ws.send_json({ + "type": "connected", + "userId": user_id, + }) + + # ── 等待能力报告 ── + try: + msg = await asyncio.wait_for(ws.receive_json(), timeout=10) + if msg.get("type") == "capability_report": + self._capabilities[user_id] = msg + logger.info( + "[Bridge] 能力报告: userId=%s, tools=%d, mcp=%d", + user_id, + len(msg.get("tools", [])), + len(msg.get("mcp_servers", [])), + ) + + # 审批工具 + approved = self._audit_capabilities(msg) + await ws.send_json({ + "type": "approved_tools", + "tools": approved, + }) + except asyncio.TimeoutError: + logger.warning("[Bridge] %s 未在 10s 内发送能力报告", user_id) + + # ── 消息循环 ── + try: + async for msg in ws: + if msg.type == web.WSMsgType.TEXT: + data = json.loads(msg.data) + await self._handle_message(user_id, data) + elif msg.type in (web.WSMsgType.ERROR, web.WSMsgType.CLOSE): + break + except Exception as e: + logger.warning("[Bridge] %s 连接异常: %s", user_id, e) + finally: + self._cleanup(user_id) + + return ws + + def is_connected(self, user_id: str) -> bool: + """检查用户的 CLI 是否在线。""" + ws = self._connections.get(user_id) + return ws is not None and not ws.closed + + def get_capabilities(self, user_id: str) -> dict | None: + """获取用户 CLI 的能力报告。""" + return self._capabilities.get(user_id) + + async def dispatch_tool_call( + self, user_id: str, tool_name: str, args: dict + ) -> dict: + """ + 派发工具调用:Cloud LLM → Tunnel → CLI → 结果。 + + Args: + user_id: 用户 ID + tool_name: 工具名(如 "terminal") + args: 工具参数 + + Returns: + {"output": "...", "exit_code": 0} 或 {"error": "..."} + """ + ws = self._connections.get(user_id) + if not ws or ws.closed: + return {"error": "CLI not connected"} + + call_id = str(uuid.uuid4())[:8] + + # 创建 Future 等待 CLI 响应 + future: asyncio.Future = asyncio.get_event_loop().create_future() + self._pending[call_id] = future + + try: + # 发送 JSON-RPC tool_call + await ws.send_json({ + "jsonrpc": "2.0", + "method": "tool_call", + "params": {"tool": tool_name, "args": args}, + "id": call_id, + }) + logger.info("[Bridge] → %s: %s (id=%s)", user_id, tool_name, call_id) + + # 等待结果 + result = await asyncio.wait_for(future, timeout=TOOL_CALL_TIMEOUT) + logger.info("[Bridge] ← %s: %s 完成 (id=%s)", user_id, tool_name, call_id) + return result + + except asyncio.TimeoutError: + logger.warning("[Bridge] %s: %s 超时 (id=%s)", user_id, tool_name, call_id) + return {"error": f"Tool call timed out after {TOOL_CALL_TIMEOUT}s"} + finally: + self._pending.pop(call_id, None) + + # ── 内部方法 ────────────────────────────────────── + + async def _verify_token(self, token: str, request: web.Request) -> dict | None: + """JWT 验证(复用 MindPass 认证策略)。""" + try: + from platforms.sse_base.auth_strategies import MindPassAuth + auth = MindPassAuth() + return await auth.verify(token) + except Exception as e: + logger.error("[Bridge] Token 验证失败: %s", e) + return None + + def _audit_capabilities(self, report: dict) -> list[str]: + """ + 审计 CLI 上报的能力,返回审批通过的工具列表。 + + 初版:白名单 = DEFAULT_APPROVED_TOOLS 与上报工具的交集。 + 后续:从 DB 读取 per-user 配置。 + """ + reported_tools = {t["name"] for t in report.get("tools", [])} + approved = [t for t in DEFAULT_APPROVED_TOOLS if t in reported_tools] + return approved + + async def _handle_message(self, user_id: str, data: dict) -> None: + """处理 CLI 发来的消息。""" + self._last_heartbeat[user_id] = time.time() + + # JSON-RPC 响应(工具调用结果) + if data.get("jsonrpc") == "2.0" and "id" in data: + call_id = data["id"] + future = self._pending.get(call_id) + if future and not future.done(): + result = data.get("result", {"error": "Empty result"}) + future.set_result(result) + return + + # 心跳 pong + if data.get("type") == "pong": + return + + logger.debug("[Bridge] %s 未知消息: %s", user_id, data.get("type")) + + def _cleanup(self, user_id: str) -> None: + """连接关闭时清理。""" + self._connections.pop(user_id, None) + self._capabilities.pop(user_id, None) + self._last_heartbeat.pop(user_id, None) + + # 取消所有待处理的 Future + to_cancel = [k for k, v in self._pending.items() if not v.done()] + for k in to_cancel: + self._pending[k].set_exception(ConnectionError("CLI disconnected")) + del self._pending[k] + + logger.info("[Bridge] CLI 断开: userId=%s", user_id) + + +def _extract_token(request: web.Request) -> str | None: + """从 Authorization header 或 query 参数提取 token。""" + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + return auth_header[7:] + # 备选:query 参数(WebSocket 某些客户端不支持 header) + return request.query.get("token") diff --git a/mindcli/_vendor/gateway/platforms/mindos_sse.py b/mindcli/_vendor/gateway/platforms/mindos_sse.py new file mode 100644 index 0000000..8e223da --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/mindos_sse.py @@ -0,0 +1,1589 @@ +""" +MindOS 3.0 SSE Gateway (mindos_sse.py) + +继承 BaseSSEServer 基座,仅包含 MindOS NEXT 特有的: + L4: _runChat — Agent 编排(积分 + wiki + 模型选择 + AIAgent) + L5: 业务 API — 音频、连接器、积分、Admin、知识花园 + +共享逻辑(L1 认证 + L2 SSE + L3 翻译闭包)由 BaseSSEServer 提供。 + +工程约束(继承自 MindOS V2 的相对路径哲学): + /mindos-next/events → SSE 长连接(不在 /api/ 下,符合 EventSource 惯例) + /mindos-next/api/* → 所有 HTTP API(与 Angular SPA 路由彻底隔离) + /mindos-next/{其他} → Angular SPA 路由(dev server 服务 index.html) +""" + +# ── sys.path 修正(必须在所有 import 之前)────────────────────────── +# platforms/ 目录下有 signal.py / email.py 等与 stdlib 同名的本地模块, +# 会遮蔽 Python 标准库。将本目录从 sys.path[0] 移到末尾,确保 stdlib 优先。 +import sys as _sys, os as _os +_this_dir = _os.path.dirname(_os.path.abspath(__file__)) +if _this_dir in _sys.path: + _sys.path.remove(_this_dir) +_sys.path.append(_this_dir) # 降权到末尾:本地模块仍可 import,但不遮蔽 stdlib + +# 将 hermes-overlay 加入 PYTHONPATH (BaseSSEServer 基座所在位置) +_overlay_dir = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.dirname(__file__)))), "hermes-overlay") +if _os.path.isdir(_overlay_dir) and _overlay_dir not in _sys.path: + _sys.path.insert(0, _overlay_dir) +# ────────────────────────────────────────────────────────────────────── + +import asyncio +import json +import logging +import os +import time +import uuid +from typing import Any, Dict, Optional + +try: + from aiohttp import web + AIOHTTP_AVAILABLE = True +except ImportError: + AIOHTTP_AVAILABLE = False + web = None # type: ignore[assignment] + +from platforms.sse_base.sse_server import BaseSSEServer, CORS_HEADERS +from platforms.sse_base import callback_atoms as atoms + +logger = logging.getLogger(__name__) + +# ── 兼容别名(dashscope_realtime.py 通过 from mindos_sse import 引用)── +_CORS_HEADERS = CORS_HEADERS + + +async def _verifyTokenAsync(token: str) -> dict | None: + """验证 JWT token,返回 user dict 或 None。 + + 复用 MindPassAuth 策略(委托 MindPass 服务验证), + 与 BaseSSEServer._extractUser 完全一致。 + 供 dashscope_realtime.py WS handler 复用。 + """ + from platforms.sse_base.auth_strategies import MindPassAuth + strategy = MindPassAuth() + user = await strategy.verify(token) + if not user: + return None + # 兼容 dashscope_realtime.py 期望的 sub 字段 + return {"sub": user.get("userId", ""), **user} + +# ── 延迟加载工具(避免 import 循环)── +try: + import tools.feishu_tool # noqa: F401 — 触发 registry.register() 副作用 +except Exception as _fe: + logger.warning("[MindOSSSE] feishu_tool 加载失败(非致命): %s", _fe) + +try: + import tools.tencent_meeting_tool # noqa: F401 +except Exception as _tm: + logger.warning("[MindOSSSE] tencent_meeting_tool 加载失败(非致命): %s", _tm) + +try: + import tools.ima_tool # noqa: F401 +except Exception as _ima: + logger.warning("[MindOSSSE] ima_tool 加载失败(非致命): %s", _ima) + + +# ══════════════════════════════════════════════════ +# Wiki 辅助函数(L4 _runChat 和 L5 _handleHistory 共用) +# ══════════════════════════════════════════════════ + +def normalize_wiki_path(raw_path: str, user_id: str) -> str: + """ + 将 Hermes write_file 的任意路径标准化为 + 相对于 {WIKI_DIR}/{userId}/ 的路径。 + + 输入可能是: + "wiki/{uid}/reports/xxx.md" — Hermes 相对路径(含 wiki/{uid} 前缀) + "/opt/.../wiki/{uid}/raw/xxx.md" — 绝对路径 + "reports/xxx.md" — 已经标准化 + "raw/xxx.md" — MdConverter 输出 + + 输出统一为: + "reports/xxx.md" + "raw/xxx.md" + + ★ 这是全文件唯一允许的路径标准化点,不在其他地方重写 realpath/relpath 逻辑。 + """ + wiki_dir = os.getenv("MINDOS_WIKI_DIR", os.path.expanduser("~/.hermes/wiki")) + user_wiki = os.path.join(wiki_dir, user_id) + + # 1. 去掉 wiki/{uid}/ 前缀(Hermes 最常见格式) + prefix = f"wiki/{user_id}/" + if raw_path.startswith(prefix): + return raw_path[len(prefix):] + + # 2. 绝对路径 → os.path.relpath + if os.path.isabs(raw_path): + try: + return os.path.relpath(raw_path, user_wiki) + except ValueError: + return os.path.basename(raw_path) + + # 3. 已经是 "reports/xxx.md" 或 "raw/xxx.md" → 直接返回 + return raw_path + + +def _read_wiki_file(rel_path: str, user_id: str) -> str: + """读取 wiki 文件内容;失败返回空字符串(non-fatal)。 + + 当直接路径读不到时,按知识花园三层架构探查子目录: + reports/ → wiki/ → raw/ + 解决 Agent write_file 路径与磁盘实际位置错位的问题。 + """ + wiki_dir = os.getenv("MINDOS_WIKI_DIR", os.path.expanduser("~/.hermes/wiki")) + user_wiki = os.path.join(wiki_dir, user_id) + full_path = os.path.join(user_wiki, rel_path) + + # 1. 直接路径 + try: + with open(full_path, encoding="utf-8") as f: + return f.read() + except Exception: + pass + + # 2. 容错探查:文件名可能落在子目录中(Agent 路径漂移) + basename = os.path.basename(rel_path) + for subdir in ("reports", "wiki", "raw", "raw/transcripts", "raw/meetings"): + fallback = os.path.join(user_wiki, subdir, basename) + if fallback != full_path and os.path.isfile(fallback): + try: + with open(fallback, encoding="utf-8") as f: + logger.info( + "[MindOSSSE] _read_wiki_file fallback: %s → %s/%s", + rel_path, subdir, basename, + ) + return f.read() + except Exception: + pass + + return "" + + +# ══════════════════════════════════════════════════ +# MindOS NEXT Gateway — 继承 BaseSSEServer +# ══════════════════════════════════════════════════ + +class MindOSSSEServer(BaseSSEServer): + """ + MindOS 3.0 前端的 SSE 服务器。 + + 继承 BaseSSEServer 共享基座,实现: + - L4: _runChat(积分预检 + 模型选择 + wiki 注入 + AIAgent + 积分扣减) + - L5: 业务 API(音频、连接器、积分、Admin、知识花园、User Profile) + """ + + # 单例引用(供 dashscope_realtime.py 访问 _pushEvent) + instance: "MindOSSSEServer | None" = None + + # MindOS NEXT 特有的工具标签 + _EXTRA_TOOL_LABELS = { + "feishu_list_profiles": "📋 正在查询飞书账号", + "feishu_init_profile": "🔗 正在创建飞书 Profile", + "feishu_auth_domain": "🔐 正在获取飞书授权链接", + "feishu_query": "📊 正在查询飞书数据", + "tencent_meeting_list_profiles": "📋 正在查询腾讯会议账号", + "tencent_meeting_query": "📹 正在查询腾讯会议数据", + "ima_list_profiles": "📋 正在查询 IMA 账号", + "ima_query": "📝 正在操作 IMA 笔记/知识库", + } + + def __init__(self, host: str = "127.0.0.1", port: int = 8650): + super().__init__(prefix="/mindos-next", host=host, port=port) + MindOSSSEServer.instance = self + + # ── L3 覆写:追加 file:created 检测器 ── + + def get_callback_atoms(self) -> list: + return [ + atoms.make_stream_delta, + atoms.make_tool_thinking, + atoms.make_file_created_detector, # ★ MindOS 知识花园特有 + ] + + def get_tool_labels(self) -> dict: + return self._EXTRA_TOOL_LABELS + + def _get_platform_source(self) -> str: + return "mindos" + + def _get_storage_root(self) -> str: + """覆写基座方法:三元域 storage 根目录。 + + 未设置 MINDOS_STORAGE_ROOT 时返回空字符串, + 基座的三元域装配自动跳过(向后兼容)。 + """ + return os.getenv("MINDOS_STORAGE_ROOT", "") + + def _extract_chat_params(self, body: dict, user: dict) -> dict: + return { + "model_id": body.get("modelId", "").strip(), + "phone": user.get("phone", ""), + "displayName": user.get("name", ""), + } + + # ══════════════════════════════════════════════ + # L4: _runChat + # ══════════════════════════════════════════════ + + async def _runChat(self, userId: str, chatId: str, text: str, model_id: str = "", phone: str = "", displayName: str = "", **kwargs) -> None: + """ + 在后台执行 Hermes AIAgent 对话。 + 全部结果通过 SSE 推送。 + + model_id:前端传入的模型别名(对齐 LiteLLM model_group_alias), + 为空时回退到环境变量 MINDOS_MODEL。 + + 知识花园路径通过环境变量 MINDOS_WIKI_DIR 配置, + 默认使用当前 Hermes 的 wiki 目录。 + """ + try: + # ── SSE 连接等待 ── + await self._wait_for_sse(userId, timeout_seconds=30.0) + + from run_agent import AIAgent + from hermes_state import SessionDB + + db = SessionDB() + loop = asyncio.get_event_loop() + + # P1-6:积分预检(余额不足则拒绝) + credit_check = db.check_credits(userId, phone=phone, display_name=displayName) + if not credit_check["allowed"]: + self._pushEvent(userId, "agent:error", { + "chatId": chatId, + "message": "今日免费额度已用完,请充值后继续使用。", + "errorType": "quota_exceeded", + "credits": credit_check, + }) + return + + # P1-6 fix: 拍快照——session 的 tokens 是累积的, + # Agent 结束后用 (当前 - 快照) 计算本轮增量 + _snap = db.get_session(chatId) or {} + _snap_input = _snap.get("input_tokens") or 0 + _snap_output = _snap.get("output_tokens") or 0 + + # P0修复D0:模型选择——回退链:model_id(前端选择)→ MINDOS_MODEL(环境变量)→ 默认 + effective_model = model_id or os.getenv("MINDOS_MODEL", "qwen-plus") + + # ── Fix D1:抢在 AIAgent 之前写入 user_id ── + try: + db.create_session( + session_id=chatId, + source="mindos", + user_id=userId, + model=effective_model, + ) + except Exception as e: + logger.warning("[MindOSSSE] pre-create_session failed (non-fatal): %s", e) + + # 知识花园路径(用户隔离:wiki/{userId}/) + _wiki_root = os.getenv( + "MINDOS_WIKI_DIR", + os.path.expanduser("~/.hermes/wiki"), + ) + wikiDir = os.path.join(_wiki_root, userId) + + # ── 使用基座的 _makeCallbacks 组合闭包 ── + stream_cb, tool_cb, reasoning_cb = self._makeCallbacks( + userId, chatId, loop, + wiki_reader=_read_wiki_file, + path_normalizer=normalize_wiki_path, + ) + + agent = AIAgent( + model=effective_model, + base_url=os.getenv("OPENAI_API_BASE", ""), + api_key=os.getenv("OPENAI_API_KEY", ""), + enabled_toolsets=["file", "web", "skills", "connectors"], + stream_delta_callback=stream_cb, + tool_progress_callback=tool_cb, + reasoning_callback=reasoning_cb, + quiet_mode=True, + platform="mindos", + session_id=chatId, + session_db=db, + user_id=userId, + request_overrides={ + "user": userId, + "extra_body": { + "thinking": {"type": "disabled"}, + }, + }, + ) + + # 推送"正在思考"事件给前端 + self._pushEvent(userId, "agent:thinking", { + "chatId": chatId, + "step": "reasoning", + "message": "🧠 正在思考中…", + }) + + # 加载上下文履历 + history = db.get_messages_as_conversation(chatId) + + # 系统提示词:告知知识花园位置 + 用户身份 + systemPrompt = f"""你是 MindOS NEXT 智能助理。 +当用户询问你的身份时,你必须回答"我是 MindOS NEXT"。 + +## 用户身份 +MINDOS_USER_ID: {userId} +(调用任何连接器工具时,必须将此 ID 作为 user_id 参数传入) + +## 可用连接器工具 +- **飞书连接器**:查询飞书日历、会议、文档等。工具:`feishu_list_profiles`、`feishu_query` +- **腾讯会议连接器**:查询腾讯会议列表、AI 纪要、录制,或预约会议。工具:`tencent_meeting_list_profiles`、`tencent_meeting_query` + - 调用前必须先调 `tencent_meeting_list_profiles` 确认已绑定 Token + - 涉及时间的操作必须先用 `tencent_meeting_query(tool_name=convert_timestamp)` 获取当前时间 + - 若未绑定,告知用户在左侧边栏点击腾讯会议图标完成绑定 +- **IMA 连接器**(QQ 笔记/知识库):搜索、读取、新建笔记,操作知识库。工具:`ima_list_profiles`、`ima_query` + - 调用前必须先调 `ima_list_profiles` 确认已绑定 + - 若未绑定,告知用户在左侧边栏点击 IMA 图标,填写 Client ID 和 API Key 完成绑定 + +## 知识花园(三层架构) +你的个人知识库位于:{wikiDir} + +目录结构: +- `{wikiDir}/raw/` — LV0 原始素材(会议转录、文章、上传文件)。只读参考,不要修改。 + - `raw/transcripts/` — 会议转录原文 + - `raw/meetings/` — 会议纪要 +- `{wikiDir}/wiki/` — LV1 知识提炼(实体、概念、对比分析等知识卡片) + - `wiki/index.md` — 全局索引(先读这个了解全貌) + - `wiki/SCHEMA.md` — 知识模型定义 + - `wiki/entities/` — 人物/产品/机构实体卡片 + - `wiki/concepts/` — 概念和方法论卡片 + - `wiki/cards/` — 独立知识卡片 + - `wiki/comparisons/` — 对比分析 +- `{wikiDir}/reports/` — LV2 报告输出(你生成的分析报告写到这里) + +使用方法: +1. 先读取 {wikiDir}/wiki/index.md 了解知识库全貌 +2. 使用 search_files 在 {wikiDir}/ 下搜索相关内容 +3. 基于知识库中的 [[wikilinks]] 追溯关联实体 + +## 文件写入规范(强制) +- 所有 write_file 调用必须使用**绝对路径** +- 生成报告 → 写入 `{wikiDir}/reports/报告名.md` +- 新增/更新知识卡片 → 写入 `{wikiDir}/wiki/` 对应子目录 +- 严禁使用相对路径(如 "report.md"),这会导致文件丢失 +- 严禁直接写入 `{wikiDir}/` 根目录 + +## 输出规范 +- 引用知识库内容时,标注来源文件名 +- 使用 Markdown 格式输出 +- 中文回答 +""" + + # 确保用户 wiki 目录存在(程序性保证,不依赖 LLM) + os.makedirs(wikiDir, exist_ok=True) + + from tools.terminal_tool import register_task_env_overrides, clear_task_env_overrides + register_task_env_overrides(chatId, {"cwd": wikiDir}) + + try: + result = await self._run_agent_task( + lambda: agent.run_conversation( + user_message=text, + system_message=systemPrompt, + conversation_history=history, + ), + userId, chatId, + ) + finally: + clear_task_env_overrides(chatId) + + finalResponse = result.get("final_response", "") + if not finalResponse: + finalResponse = result.get("error", "(未生成回答)") + + # P1-6:LLM 对话完成后扣减积分(增量计费) + _MODEL_RATES = { + "deepseek": (1, 2), + "qwen-plus": (4, 12), + "glm": (5, 16), + "gemini-pro-vertex": (9, 36), + } + try: + session_info = db.get_session(chatId) + if session_info: + delta_input = max(0, (session_info.get("input_tokens") or 0) - _snap_input) + delta_output = max(0, (session_info.get("output_tokens") or 0) - _snap_output) + in_rate, out_rate = _MODEL_RATES.get(effective_model, (4, 12)) + credits_used = max(1, (delta_input * in_rate + delta_output * out_rate) // 1000) + db.deduct_credits( + user_id=userId, + credits=credits_used, + tx_type="llm_chat", + session_id=chatId, + model=effective_model, + raw_metric=json.dumps({ + "delta_input": delta_input, + "delta_output": delta_output, + "in_rate": in_rate, + "out_rate": out_rate, + }), + ) + except Exception as _ce: + logger.warning("[MindOSSSE] credit deduction failed (non-fatal): %s", _ce) + + self._pushEvent(userId, "agent:done", { + "chatId": chatId, + "fullAnswer": finalResponse, + }) + + except Exception as e: + logger.error("[MindOSSSE] Chat error for %s/%s: %s", userId, chatId, e, exc_info=True) + self._pushEvent(userId, "agent:error", { + "chatId": chatId, + "message": f"处理失败:{str(e)}", + }) + + # ══════════════════════════════════════════════ + # L3.5: 水合原子覆写(MindOS 特有的产出物重建) + # ══════════════════════════════════════════════ + + def get_hydrate_atoms(self) -> list: + """MindOS 水合原子:从 tool_calls 中 write_file 重建 files 列表。 + + 对齐 callback_atoms 的闭包组合范式:基座 _handleHistory → _hydrateMessages + 会对每条消息依次调用这些原子。 + + 兼容旧数据:V9 前的消息不含 attachments 列,通过 make_hydrate_files_from_tool_calls + 从 tool_calls JSON 中重建产出物。当 attachments 正式启用后,此原子作为 fallback。 + """ + from platforms.sse_base import hydrate_atoms as ha + + # 水合上下文中无法直接获取 userId(它在 request 中), + # 因此 MindOS 需要覆写 _hydrateMessages 注入 userId。 + # 见下方 _hydrateMessages 覆写。 + return [] + + def get_hydrate_post_processors(self) -> list: + """MindOS 后处理:跨消息文件聚合 + 消息过滤。""" + from platforms.sse_base import hydrate_atoms as ha + + return [ha.aggregate_files_across_messages] + + def _hydrateMessages(self, messages: list, **context) -> list: + """MindOS 覆写:注入 userId 上下文后执行水合。 + + 基座 _handleHistory 传入 userId=... 和 chatId=..., + 此处利用 userId 构造 per-user 的 file reader/normalizer 闭包。 + """ + from platforms.sse_base import hydrate_atoms as ha + + userId = context.get("userId", "") + + # 构造绑定 userId 的兼容原子 + fileHydrator = ha.make_hydrate_files_from_tool_calls( + userId=userId, + wiki_reader=_read_wiki_file, + path_normalizer=normalize_wiki_path, + ) + + # 单条消息原子管线 + for i, msg in enumerate(messages): + msg = fileHydrator(msg) + messages[i] = msg + + # 跨消息后处理(文件聚合 + 消息过滤) + for processor in self.get_hydrate_post_processors(): + messages = processor(messages) + + return messages + + # ══════════════════════════════════════════════ + # L5: 覆写 _handleSessionsList(MindOS 特有的 get_sessions_for_user) + # ══════════════════════════════════════════════ + + async def _handleSessionsList(self, request: "web.Request") -> "web.Response": + """GET /mindos-next/api/sessions/list — 返回当前用户的所有会话""" + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS) + + userId = user["userId"] + try: + from hermes_state import SessionDB + import datetime + db = SessionDB() + rows = db.get_sessions_for_user(userId) + sessions = [] + for r in rows: + ts = r.get("last_active") or r.get("started_at") or 0 + updated_at = datetime.datetime.fromtimestamp( + ts, tz=datetime.timezone.utc + ).isoformat() + title = r.get("title") or r.get("preview") or "对话" + sessions.append({ + "chatId": r["id"], + "title": title[:24], + "updatedAt": updated_at, + "preview": r.get("preview", ""), + "workProductCount": r.get("work_product_count", 0), + "messageCount": r.get("message_count", 0), + }) + return web.json_response({"sessions": sessions}, headers=CORS_HEADERS) + except Exception as e: + logger.error("Failed to list sessions for userId=%s: %s", userId, e) + return web.json_response({"sessions": [], "error": str(e)}, headers=CORS_HEADERS) + + # ══════════════════════════════════════════════ + # L5: 音频管线 + # ══════════════════════════════════════════════ + + async def _handleAudioPresign(self, request: "web.Request") -> "web.Response": + """GET /mindos-next/api/audio/presign?ext=.m4a""" + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS) + + ext = request.rel_url.query.get("ext", ".m4a") + if not ext.startswith("."): + ext = "." + ext + + try: + from flash_asr import generate_oss_presign # type: ignore + result = generate_oss_presign(user["userId"], ext) + return web.json_response(result, headers=CORS_HEADERS) + except Exception as e: + logger.error("[Presign] error: %s", e) + return web.json_response({"error": str(e)}, status=500, headers=CORS_HEADERS) + + async def _handleAudioTranscribe(self, request: "web.Request") -> "web.Response": + """POST /mindos-next/api/audio/transcribe""" + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS) + + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400, headers=CORS_HEADERS) + + oss_key = body.get("ossKey", "") + read_url = body.get("readUrl", "") + title = body.get("title", "未命名") or "未命名" + chat_id = body.get("chatId", "") + + if not oss_key or not read_url: + return web.json_response({"error": "ossKey 和 readUrl 必填"}, status=400, headers=CORS_HEADERS) + + asyncio.create_task( + self._runOssTranscribePipeline( + userId=user["userId"], chatId=chat_id, + readUrl=read_url, ossKey=oss_key, title=title, + ) + ) + return web.json_response( + {"received": True, "message": "转写进行中,请通过 SSE 跟踪进度"}, + status=202, headers=CORS_HEADERS, + ) + + async def _runOssTranscribePipeline(self, userId, chatId, readUrl, ossKey, title): + """后台任务:离线音频 → Markdown(委托给 voice_import 管线)。""" + try: + from voice_import import run as voice_import_run # type: ignore + await voice_import_run( + sse_server=self, + user_id=userId, chat_id=chatId, + read_url=readUrl, oss_key=ossKey, title=title, + ) + except Exception as e: + logger.error("[OSSTranscribe] pipeline error: %s", e, exc_info=True) + self._pushEvent(userId, "md:error", {"chatId": chatId, "message": f"转写失败:{e}"}) + + async def _handleAudioUpload(self, request: "web.Request") -> "web.Response": + """POST /mindos-next/api/audio/upload (封存保留)""" + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS) + + userId = user["userId"] + + try: + reader = await request.multipart() + except Exception: + return web.json_response({"error": "Invalid multipart"}, status=400, headers=CORS_HEADERS) + + import tempfile, pathlib + tmp_path: Optional[str] = None + title = "未命名" + chat_id = "" + original_filename = "upload" + + try: + async for field in reader: + if field.name == "title": + title = (await field.read(decode=True)).decode("utf-8", errors="replace").strip() or "未命名" + elif field.name == "chatId": + chat_id = (await field.read(decode=True)).decode("utf-8", errors="replace").strip() + elif field.name == "audio": + original_filename = getattr(field, "filename", "audio.m4a") or "audio.m4a" + suffix = pathlib.Path(original_filename).suffix or ".m4a" + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: + tmp_path = tmp.name + while True: + chunk = await field.read_chunk(8192) + if not chunk: + break + tmp.write(chunk) + except Exception as e: + logger.error("[AudioUpload] multipart read error: %s", e) + return web.json_response({"error": f"读取上传文件失败: {e}"}, status=400, headers=CORS_HEADERS) + + if not tmp_path: + return web.json_response({"error": "未找到 audio 字段"}, status=400, headers=CORS_HEADERS) + + asyncio.create_task( + self._runAudioPipeline(userId, chat_id, tmp_path, original_filename, title) + ) + + return web.json_response( + {"received": True, "message": "转写进行中,请通过 SSE 跟踪进度"}, + status=202, headers=CORS_HEADERS, + ) + + # ── 上传防重辅助 ── + + def _raw_index_path(self, wiki_dir: str) -> str: + return os.path.join(wiki_dir, ".raw_index.json") + + def _lookup_raw_index(self, wiki_dir: str, sha256: str) -> Optional[str]: + idx_path = self._raw_index_path(wiki_dir) + if not os.path.exists(idx_path): + return None + try: + with open(idx_path, encoding="utf-8") as f: + idx = json.load(f) + return idx.get(sha256) + except Exception: + return None + + def _update_raw_index(self, wiki_dir: str, sha256: str, rel_path: str) -> None: + idx_path = self._raw_index_path(wiki_dir) + try: + with open(idx_path, encoding="utf-8") as f: + idx = json.load(f) + except Exception: + idx = {} + idx[sha256] = rel_path + with open(idx_path, "w", encoding="utf-8") as f: + json.dump(idx, f, ensure_ascii=False, indent=2) + + async def _runAudioPipeline(self, userId, chatId, tmp_path, original_filename, title): + """后台任务:SHA-256 防重 → ASR → md_converter → SSE 进度推送。""" + import hashlib as _hs + try: + from flash_asr import transcribe_file # type: ignore + from md_converter import MdConverter # type: ignore + + _wiki_root = os.getenv("MINDOS_WIKI_DIR", os.path.expanduser("~/.hermes/wiki")) + wiki_dir = os.path.join(_wiki_root, userId) + converter = MdConverter(wiki_dir) + + with open(tmp_path, "rb") as fh: + sha256 = _hs.sha256(fh.read()).hexdigest() + + existing_rel = self._lookup_raw_index(wiki_dir, sha256) + if existing_rel: + logger.info("[AudioUpload] 防重命中: %s → %s", original_filename, existing_rel) + existing_content = _read_wiki_file(existing_rel, userId) + self._pushEvent(userId, "md:appended", { + "chatId": chatId, "file": existing_rel, "chars": len(existing_content), + "provider": "dedup", + "message": f"✅ 文件已入库(自动去重):{os.path.basename(existing_rel)}", + }) + return + + self._pushEvent(userId, "md:progress", { + "chatId": chatId, "stage": "asr_start", "filename": original_filename, + }) + + result = await transcribe_file(tmp_path, api_key=os.getenv("DASHSCOPE_API_KEY", "")) + text = result["text"] + provider = result["provider"] + + if not text: + self._pushEvent(userId, "md:error", { + "chatId": chatId, "message": "转写结果为空,请检查音频文件", + }) + return + + md_file = converter.new_file(title=title, source_filename=original_filename) + total_chars = converter.append_segment(md_file, text, offset_ms=0) + converter.finalize(md_file, char_count=total_chars) + rel_path = converter.relative_path(md_file) + + self._update_raw_index(wiki_dir, sha256, rel_path) + + logger.info("[AudioUpload] ✅ %s → %s (%d 字, provider=%s)", + original_filename, rel_path, total_chars, provider) + + self._pushEvent(userId, "md:appended", { + "chatId": chatId, "file": rel_path, "chars": total_chars, + "provider": provider, + "message": f"✅ 会议记录已入库:{md_file.name}({total_chars} 字)", + }) + + except Exception as e: + logger.error("[AudioUpload] pipeline error: %s", e, exc_info=True) + self._pushEvent(userId, "md:error", {"chatId": chatId, "message": f"转写失败:{e}"}) + finally: + try: + os.unlink(tmp_path) + except Exception: + pass + # ══════════════════════════════════════════════ + # L5: 通用文件管线(AnyFile2MD Phase C — 文档/图片解析) + # ══════════════════════════════════════════════ + + async def _handleFilePresign(self, request: "web.Request") -> "web.Response": + """GET /mindos-next/api/file/presign?ext=.pdf + + 复用 flash_asr.generate_oss_presign(),但使用不同的 OSS 路径前缀。 + """ + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS) + + ext = request.rel_url.query.get("ext", ".pdf") + if not ext.startswith("."): + ext = "." + ext + + try: + from flash_asr import generate_oss_presign # type: ignore + result = generate_oss_presign( + user["userId"], ext, prefix="mindos-next/docs", + ) + return web.json_response(result, headers=CORS_HEADERS) + except Exception as e: + logger.error("[FilePresign] error: %s", e) + return web.json_response({"error": str(e)}, status=500, headers=CORS_HEADERS) + + async def _handleFileParse(self, request: "web.Request") -> "web.Response": + """POST /mindos-next/api/file/parse + Body: { ossKey, readUrl, filename, title?, chatId } + + HTTP 只确认收到(戒律 2)。解析/写 MD/SSE 推送在后台 asyncio.create_task。 + """ + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS) + + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400, headers=CORS_HEADERS) + + oss_key = body.get("ossKey", "") + read_url = body.get("readUrl", "") + filename = body.get("filename", "") + title = body.get("title", "") or filename or "未命名" + chat_id = body.get("chatId", "") + + if not oss_key or not read_url or not filename: + return web.json_response( + {"error": "ossKey、readUrl、filename 必填"}, + status=400, headers=CORS_HEADERS, + ) + + # 格式支持检查 + from infra.pipelines.anyfile2md import isSupported # type: ignore + if not isSupported(filename): + return web.json_response( + {"error": f"不支持的文件格式: {filename}"}, + status=400, headers=CORS_HEADERS, + ) + + asyncio.create_task( + self._runDocParsePipeline( + userId=user["userId"], chatId=chat_id, + readUrl=read_url, filename=filename, title=title, + ) + ) + return web.json_response( + {"received": True, "message": "解析进行中,请通过 SSE 跟踪进度"}, + status=202, headers=CORS_HEADERS, + ) + + async def _runDocParsePipeline(self, userId, chatId, readUrl, filename, title): + """后台任务:anyfile2md → md_converter → SSE 推送。 + + 进度通过 onProgress 回调注入管线,管线本身无状态。 + """ + import hashlib as _hs + try: + from infra.pipelines.anyfile2md import parse as parse_to_markdown # type: ignore + from md_converter import MdConverter # type: ignore + + _wiki_root = os.getenv("MINDOS_WIKI_DIR", os.path.expanduser("~/.hermes/wiki")) + wiki_dir = os.path.join(_wiki_root, userId) + converter = MdConverter(wiki_dir) + + # ── 进度回调绑定(管线每个原子边界触发)── + def _progress(stage: str, detail: dict): + self._pushEvent(userId, "md:progress", { + "chatId": chatId, "stage": stage, "filename": filename, **detail, + }) + + # 调用原子化管线(带进度回调) + result = await parse_to_markdown(readUrl, filename, onProgress=_progress) + text = result["text"] + provider = result["provider"] + vl_pages = result.get("vlPages", result.get("vl_pages", 0)) + + if not text: + self._pushEvent(userId, "md:error", { + "chatId": chatId, "message": "解析结果为空,请检查文件内容", + }) + return + + # 写入 MD 文件(复用 md_converter) + _progress("writing", {}) + md_file = converter.new_file(title=title, source_filename=filename) + total_chars = converter.append_segment(md_file, text, offset_ms=0) + converter.finalize(md_file, char_count=total_chars) + rel_path = converter.relative_path(md_file) + + # SHA-256 防重索引 + sha256 = _hs.sha256(text.encode("utf-8")).hexdigest() + self._update_raw_index(wiki_dir, sha256, rel_path) + + # VL 积分扣减(仅 VL 降级时) + if vl_pages > 0: + try: + from hermes_state import SessionDB as _CreditDB + _cdb = _CreditDB() + vl_credits = max(1, vl_pages * 5) # ~5 credits/页 + _cdb.deduct_credits( + user_id=userId, credits=vl_credits, + tx_type="vl_doc_parse", session_id=chatId, + model="qwen-vl", + raw_metric=json.dumps({ + "pages": vl_pages, "chars": total_chars, + "filename": filename, + }), + ) + except Exception as _ce: + logger.warning("[DocParse] credit deduction failed: %s", _ce) + + logger.info( + "[DocParse] ✅ %s → %s (%d 字, provider=%s, vl_pages=%d)", + filename, rel_path, total_chars, provider, vl_pages, + ) + + # DB 持久化(只存元数据,不存 mdContent — 内容通过 wiki API 按需读取) + try: + from hermes_state import SessionDB as _SessionDB + _db = _SessionDB() + _db.create_session( + session_id=chatId, source="mindos", + user_id=userId, model="doc_parse", + ) + _db.append_message( + session_id=chatId, role="assistant", + content=json.dumps({ + "type": "document", "fileName": title, + "mdPath": rel_path, "chars": total_chars, + "provider": provider, + }, ensure_ascii=False), + ) + except Exception as _pe: + logger.warning("[DocParse] persist failed (non-fatal): %s", _pe) + + # SSE 完成通知(不含 mdContent,避免阻塞 SSE 通道) + self._pushEvent(userId, "md:appended", { + "chatId": chatId, "file": rel_path, "chars": total_chars, + "provider": provider, "fileName": title, + "message": f"✅ 文档已入库:{os.path.basename(str(md_file))}({total_chars} 字)", + }) + + except Exception as e: + logger.error("[DocParse] pipeline error: %s", e, exc_info=True) + self._pushEvent(userId, "md:error", { + "chatId": chatId, "message": f"文件解析失败:{e}", + }) + + # ══════════════════════════════════════════════ + # L4.5: CRX 纯文本直写通道(garden/clip) + # ══════════════════════════════════════════════ + + async def _handleGardenClip(self, request: "web.Request") -> "web.Response": + """POST /mindos-next/api/garden/clip + + 为 MindOS NEXT Clipper (CRX) 提供的极简落盘通道。 + CRX 端已将内容解析为带 YAML Frontmatter 的纯净 Markdown, + 此端点仅做 JWT 鉴权 + 直写文件 + SSE 通知,不经过 OSS/anyfile2md 管线。 + """ + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS) + + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400, headers=CORS_HEADERS) + + filename = body.get("filename", "").strip() + content = body.get("content", "") + + if not filename or not content: + return web.json_response( + {"error": "filename 和 content 必填"}, + status=400, headers=CORS_HEADERS, + ) + + # 路径遍历防护:强制取 basename,禁止 ../ 穿透 + filename = os.path.basename(filename) + if not filename: + return web.json_response( + {"error": "无效的文件名"}, + status=400, headers=CORS_HEADERS, + ) + + # 确保 .md 扩展名 + if not filename.endswith(".md"): + filename += ".md" + + userId = user["userId"] + import hashlib as _hs + + # SHA-256 去重(剥离 YAML Frontmatter 再做哈希) + # YAML 头中 clipped_at 每次都不同,必须排除才能正确去重 + import re as _re + body_for_hash = _re.sub(r'^---\n.*?\n---\n*', '', content, count=1, flags=_re.DOTALL) + sha256 = _hs.sha256(body_for_hash.encode("utf-8")).hexdigest() + _wiki_root = os.getenv("MINDOS_WIKI_DIR", os.path.expanduser("~/.hermes/wiki")) + wiki_dir = os.path.join(_wiki_root, userId) + clip_dir = os.path.join(wiki_dir, "raw", "web_clips") + os.makedirs(clip_dir, exist_ok=True) + + # 检查是否已存在相同内容 + existing = self._check_raw_index(wiki_dir, sha256) + if existing: + logger.info("[GardenClip] 重复内容,跳过写入: %s (sha=%s…)", filename, sha256[:12]) + return web.json_response( + {"received": True, "duplicate": True, "existingFile": existing}, + headers=CORS_HEADERS, + ) + + # 直写 .md 文件 + file_path = os.path.join(clip_dir, filename) + # 文件名冲突时自动加序号 + if os.path.exists(file_path): + base, ext = os.path.splitext(filename) + counter = 1 + while os.path.exists(file_path): + file_path = os.path.join(clip_dir, f"{base}_{counter}{ext}") + counter += 1 + + try: + with open(file_path, "w", encoding="utf-8") as f: + f.write(content) + except Exception as e: + logger.error("[GardenClip] 写入失败: %s", e) + return web.json_response( + {"error": f"文件写入失败: {e}"}, + status=500, headers=CORS_HEADERS, + ) + + # 更新 SHA-256 防重索引 + rel_path = os.path.relpath(file_path, wiki_dir) + self._update_raw_index(wiki_dir, sha256, rel_path) + total_chars = len(content) + + logger.info( + "[GardenClip] ✅ %s → %s (%d 字, sha=%s…)", + filename, rel_path, total_chars, sha256[:12], + ) + + # SSE 通知前端刷新文件列表 + self._pushEvent(userId, "md:appended", { + "file": rel_path, "chars": total_chars, + "fileName": os.path.basename(file_path), + "message": f"✅ 剪藏已入库:{os.path.basename(file_path)}({total_chars} 字)", + }) + + return web.json_response( + {"received": True, "file": rel_path, "chars": total_chars}, + headers=CORS_HEADERS, + ) + + def _check_raw_index(self, wiki_dir: str, sha256: str) -> str: + """检查 SHA-256 是否已在 raw_index 中,返回已有文件的相对路径或空字符串。""" + index_path = os.path.join(wiki_dir, ".raw_index.json") + if not os.path.exists(index_path): + return "" + try: + with open(index_path, "r", encoding="utf-8") as f: + index = json.load(f) + return index.get(sha256, "") + except Exception: + return "" + + # ══════════════════════════════════════════════ + # L5: 连接器管理 API + # ══════════════════════════════════════════════ + + async def _handleFeishuProfiles(self, request): + """GET /mindos-next/api/feishu/profiles""" + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401) + + user_id = user.get("userId", "") + if not user_id: + return web.json_response({"error": "missing user_id"}, status=400) + + try: + from tools.registry import registry + raw = registry.dispatch("feishu_list_profiles", {"user_id": user_id}) + data = json.loads(raw) + profiles = data.get("profiles", []) + accounts = [] + for p in profiles: + name = p.get("name", "") + alias = p.get("alias", "") or "" + accounts.append({ + "profileName": name, + "alias": alias, + "label": alias if alias else name, + "status": "connected", + "tokenStatus": p.get("tokenStatus", ""), + "expiresAt": p.get("expiresAt", ""), + "user": p.get("user", ""), + }) + return web.json_response({"accounts": accounts, "count": len(accounts)}) + except Exception as e: + logger.error("[MindOSSSE] feishu profiles error: %s", e) + return web.json_response({"error": str(e)}, status=500) + + async def _handleFeishuSetAlias(self, request): + """POST /mindos-next/api/feishu/profiles/alias""" + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401) + + user_id = user.get("userId", "") + try: + body = await request.json() + profile_name = str(body.get("profile_name", "")).strip() + alias = str(body.get("alias", "")).strip() + if not profile_name: + return web.json_response({"error": "profile_name 必填"}, status=400) + + from tools.registry import registry + raw = registry.dispatch("feishu_rename_profile", { + "user_id": user_id, "profile_name": profile_name, "alias": alias, + }) + data = json.loads(raw) + return web.json_response(data) + except Exception as e: + logger.error("[MindOSSSE] feishu set alias error: %s", e) + return web.json_response({"error": str(e)}, status=500) + + async def _handleTencentMeetingProfiles(self, request): + """GET /mindos-next/api/tencent-meeting/profiles""" + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401) + + user_id = user.get("userId", "") + if not user_id: + return web.json_response({"error": "missing user_id"}, status=400) + + try: + from tools.tencent_meeting_tool import has_user_token, TOKEN_OBTAIN_URL + token_configured = has_user_token(user_id) + + if not token_configured: + return web.json_response({ + "accounts": [], "count": 0, "tokenConfigured": False, + "tokenObtainUrl": TOKEN_OBTAIN_URL, + "notice": "腾讯会议目前仅支持个人账号认证,请前往上述链接获取 Token 完成绑定。", + }) + + from tools.registry import registry + raw = registry.dispatch("tencent_meeting_list_profiles", {"user_id": user_id}) + data = json.loads(raw) + profiles = data.get("profiles", []) + accounts = [] + for p in profiles: + accounts.append({ + "profileName": p.get("profileName", ""), + "label": p.get("label", "腾讯会议"), + "status": p.get("status", "unknown"), + "tokenConfigured": True, + "notice": p.get("notice", "腾讯会议目前仅支持个人账号认证。"), + }) + return web.json_response({ + "accounts": accounts, "count": len(accounts), + "tokenConfigured": True, "tokenObtainUrl": TOKEN_OBTAIN_URL, + }) + except Exception as e: + logger.error("[MindOSSSE] tencent meeting profiles error: %s", e) + return web.json_response({"error": str(e)}, status=500) + + async def _handleTencentMeetingBind(self, request): + """POST /mindos-next/api/tencent-meeting/bind""" + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401) + + user_id = user.get("userId", "") + try: + body = await request.json() + token = str(body.get("token", "")).strip() + + from tools.tencent_meeting_tool import set_user_token + set_user_token(user_id, token) + + if token: + logger.info("[MindOSSSE] tencent meeting token saved for user %s", user_id[:8]) + return web.json_response({"success": True, "message": "腾讯会议账号绑定成功。"}) + else: + logger.info("[MindOSSSE] tencent meeting token cleared for user %s", user_id[:8]) + return web.json_response({"success": True, "message": "已解绑腾讯会议账号。"}) + except Exception as e: + logger.error("[MindOSSSE] tencent meeting bind error: %s", e) + return web.json_response({"error": str(e)}, status=500) + + async def _handleImaProfiles(self, request): + """GET /mindos-next/api/ima/profiles""" + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS) + + user_id = user.get("userId", "") + try: + from tools.ima_tool import has_user_credentials, CREDENTIALS_URL + if not has_user_credentials(user_id): + return web.json_response({ + "success": True, "profiles": [], "count": 0, + "credentialsConfigured": False, "credentialsUrl": CREDENTIALS_URL, + }, headers=CORS_HEADERS) + + from tools.registry import registry + raw = registry.dispatch("ima_list_profiles", {"user_id": user_id}) + data = json.loads(raw) if isinstance(raw, str) else raw + return web.json_response(data, headers=CORS_HEADERS) + except Exception as e: + logger.error("[MindOSSSE] ima profiles error: %s", e) + return web.json_response({"error": str(e)}, status=500, headers=CORS_HEADERS) + + async def _handleImaBind(self, request): + """POST /mindos-next/api/ima/bind""" + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS) + + user_id = user.get("userId", "") + try: + body = await request.json() + client_id = str(body.get("client_id", "")).strip() + api_key = str(body.get("api_key", "")).strip() + + from tools.ima_tool import set_user_credentials + set_user_credentials(user_id, client_id, api_key) + + if client_id and api_key: + logger.info("[MindOSSSE] ima credentials saved for user %s", user_id[:8]) + return web.json_response({"success": True, "message": "IMA 账号绑定成功。"}, headers=CORS_HEADERS) + else: + logger.info("[MindOSSSE] ima credentials cleared for user %s", user_id[:8]) + return web.json_response({"success": True, "message": "已解绑 IMA 账号。"}, headers=CORS_HEADERS) + except Exception as e: + logger.error("[MindOSSSE] ima bind error: %s", e) + return web.json_response({"error": str(e)}, status=500, headers=CORS_HEADERS) + + # ══════════════════════════════════════════════ + # L5: 知识花园发布 + # ══════════════════════════════════════════════ + + async def _handleGardenPublish(self, request): + """POST /mindos-next/api/garden/publish""" + import shutil as _sh, re as _re + import pathlib as _pl + + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS) + userId = user["userId"] + + try: + body = await request.json() + garden_name = body.get("gardenName", "").strip() + except Exception: + return web.json_response({"error": "invalid JSON, need gardenName"}, status=400, headers=CORS_HEADERS) + + if not garden_name: + return web.json_response({"error": "gardenName required"}, status=400, headers=CORS_HEADERS) + + _wiki_root = os.getenv("MINDOS_WIKI_DIR", os.path.expanduser("~/.hermes/wiki")) + user_wiki = _pl.Path(_wiki_root) / userId + garden_dir = user_wiki / "gardens" / garden_name + files_md = garden_dir / "files.md" + + if not files_md.exists(): + return web.json_response( + {"error": f"gardens/{garden_name}/files.md 不存在"}, + status=404, headers=CORS_HEADERS, + ) + + files_text = files_md.read_text(encoding="utf-8") + source_files = set(_re.findall(r'(?:^|\s)- *(raw/[^\s]+\.md)', files_text, _re.MULTILINE)) + + if not source_files: + return web.json_response( + {"error": "files.md 中未找到任何 raw/*.md 源文件"}, + status=400, headers=CORS_HEADERS, + ) + + wiki_pages_dirs = ["entities", "concepts", "comparisons", "queries"] + matched_pages = [] + frontmatter_re = _re.compile(r'^---\n(.*?)\n---', _re.DOTALL) + sources_re = _re.compile(r'sources:\s*\[([^\]]*)\]') + + for subdir in wiki_pages_dirs: + d = user_wiki / subdir + if not d.is_dir(): + continue + for md_file in d.glob("*.md"): + text = md_file.read_text(encoding="utf-8", errors="replace") + fm_match = frontmatter_re.search(text) + if not fm_match: + continue + fm_text = fm_match.group(1) + src_match = sources_re.search(fm_text) + if not src_match: + continue + srcs = {s.strip().strip('"').strip("'") for s in src_match.group(1).split(",")} + if srcs & source_files: + matched_pages.append(md_file) + + pub_dir = _pl.Path(_wiki_root) / "commons" / "published" / userId / garden_name + pub_dir.mkdir(parents=True, exist_ok=True) + + copied = 0 + for page in matched_pages: + rel = page.relative_to(user_wiki) + dest = pub_dir / rel + dest.parent.mkdir(parents=True, exist_ok=True) + _sh.copy2(page, dest) + copied += 1 + + _sh.copy2(files_md, pub_dir / "files.md") + + readings_dir = garden_dir / "readings" + if readings_dir.is_dir(): + _sh.copytree(readings_dir, pub_dir / "readings", dirs_exist_ok=True) + + commons_dir = _pl.Path(_wiki_root) / "commons" + commons_dir.mkdir(parents=True, exist_ok=True) + index_md = commons_dir / "index.md" + entry = f"- [{userId}/{garden_name}]({userId}/{garden_name}) \n" + if not index_md.exists(): + index_md.write_text(f"# MindOS 知识花园广场\n\n{entry}", encoding="utf-8") + else: + existing = index_md.read_text(encoding="utf-8") + if f"{userId}/{garden_name}" not in existing: + with open(index_md, "a", encoding="utf-8") as f: + f.write(entry) + + logger.info("[Garden] 发布 %s/%s: %d 个 wiki 页面", userId, garden_name, copied) + return web.json_response({ + "published": True, "gardenName": garden_name, + "wikiPagesCopied": copied, + "path": f"commons/published/{userId}/{garden_name}/", + }, headers=CORS_HEADERS) + + # ══════════════════════════════════════════════ + # L5: 文件读取(MD popWindow 用) + # ══════════════════════════════════════════════ + + async def _handleFileRaw(self, request): + """GET /mindos-next/api/file/raw?path=raw/xxx.md""" + import pathlib as _pl + + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS) + userId = user["userId"] + + rel = request.rel_url.query.get("path", "").strip() + if not rel: + return web.json_response({"error": "path required"}, status=400, headers=CORS_HEADERS) + + _wiki_root = os.getenv("MINDOS_WIKI_DIR", os.path.expanduser("~/.hermes/wiki")) + wiki_root_path = _pl.Path(_wiki_root) + + is_public = rel.startswith("commons/published/") + if is_public: + base = wiki_root_path + target = (base / rel).resolve() + try: + target.relative_to((base / "commons" / "published").resolve()) + except ValueError: + return web.json_response({"error": "forbidden"}, status=403, headers=CORS_HEADERS) + else: + base = wiki_root_path / userId + target = (base / rel).resolve() + try: + target.relative_to(base.resolve()) + except ValueError: + return web.json_response({"error": "forbidden"}, status=403, headers=CORS_HEADERS) + + if not target.exists() or not target.is_file(): + return web.json_response({"error": "not found", "path": rel}, status=404, headers=CORS_HEADERS) + + content = target.read_text(encoding="utf-8") + return web.json_response({"content": content, "path": rel}, headers=CORS_HEADERS) + + # ══════════════════════════════════════════════ + # L5: 积分 & Admin & User Profile + # ══════════════════════════════════════════════ + + async def _handleCredits(self, request): + """GET /mindos-next/api/credits""" + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS) + + try: + from hermes_state import SessionDB + db = SessionDB() + credits = db.check_credits(user["userId"]) + return web.json_response(credits, headers=CORS_HEADERS) + except Exception as e: + logger.error("Failed to check credits: %s", e) + return web.json_response({"error": str(e)}, status=500, headers=CORS_HEADERS) + + _ADMIN_USER_IDS = {"11c1cece-2422-41e7-86f0-1f54b6162b95"} + + async def _requireAdmin(self, request): + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS) + if user["userId"] not in self._ADMIN_USER_IDS: + return web.json_response({"error": "Forbidden"}, status=403, headers=CORS_HEADERS) + return user + + async def _handleAdminDashboard(self, request): + """GET /mindos-next/api/admin/dashboard?days=7""" + result = await self._requireAdmin(request) + if isinstance(result, web.Response): + return result + try: + days = int(request.query.get("days", "7")) + from hermes_state import SessionDB + db = SessionDB() + dashboard = db.admin_dashboard(days=days) + return web.json_response(dashboard, headers=CORS_HEADERS) + except Exception as e: + logger.error("Admin dashboard error: %s", e) + return web.json_response({"error": str(e)}, status=500, headers=CORS_HEADERS) + + async def _handleAdminTopup(self, request): + """POST /mindos-next/api/admin/credits/topup""" + result = await self._requireAdmin(request) + if isinstance(result, web.Response): + return result + try: + body = await request.json() + target_user = body.get("userId") + amount = int(body.get("amount", 0)) + reason = body.get("reason", "admin_topup") + if not target_user or amount <= 0: + return web.json_response( + {"error": "需要 userId 和正整数 amount"}, status=400, headers=CORS_HEADERS + ) + from hermes_state import SessionDB + db = SessionDB() + updated = db.admin_topup(target_user, amount, reason) + logger.info("[Admin] topup userId=%s amount=%d reason=%s", target_user, amount, reason) + return web.json_response({"ok": True, "credits": updated}, headers=CORS_HEADERS) + except Exception as e: + logger.error("Admin topup error: %s", e) + return web.json_response({"error": str(e)}, status=500, headers=CORS_HEADERS) + + async def _handleUpdateName(self, request): + """POST /mindos-next/api/user/name""" + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS) + try: + body = await request.json() + new_name = (body.get("name") or "").strip() + if not new_name: + return web.json_response({"error": "name 不能为空"}, status=400, headers=CORS_HEADERS) + from hermes_state import SessionDB + db = SessionDB() + with db._lock: + db._conn.execute( + "UPDATE user_credits SET display_name = ?, updated_at = datetime('now') WHERE user_id = ?", + (new_name, user["userId"]), + ) + db._conn.commit() + logger.info("[Profile] userId=%s name → %s", user["userId"], new_name) + return web.json_response({"ok": True, "name": new_name}, headers=CORS_HEADERS) + except Exception as e: + logger.error("Update name error: %s", e) + return web.json_response({"error": str(e)}, status=500, headers=CORS_HEADERS) + + async def _handleUploadAvatar(self, request): + """POST /mindos-next/api/user/avatar""" + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS) + try: + reader = await request.multipart() + field = await reader.next() + if field is None or field.name != "avatar": + return web.json_response({"error": "请上传 avatar 字段"}, status=400, headers=CORS_HEADERS) + + from pathlib import Path + avatar_dir = Path(os.environ.get("MINDOS_STORAGE", ".")) / "storage" / "avatars" + avatar_dir.mkdir(parents=True, exist_ok=True) + + ext = ".jpg" + ct = field.headers.get("Content-Type", "") + if "png" in ct: + ext = ".png" + elif "webp" in ct: + ext = ".webp" + + filename = f"{user['userId'][:8]}_{uuid.uuid4().hex[:6]}{ext}" + filepath = avatar_dir / filename + + data = await field.read(decode=False) + if len(data) > 2 * 1024 * 1024: + return web.json_response({"error": "文件超过 2MB"}, status=400, headers=CORS_HEADERS) + + filepath.write_bytes(data) + + avatar_url = f"/mindos-next/api/avatar/{filename}" + logger.info("[Profile] userId=%s avatar → %s", user["userId"], avatar_url) + return web.json_response({"ok": True, "avatarUrl": avatar_url}, headers=CORS_HEADERS) + except Exception as e: + logger.error("Upload avatar error: %s", e) + return web.json_response({"error": str(e)}, status=500, headers=CORS_HEADERS) + + async def _handleServeAvatar(self, request): + """GET /mindos-next/api/avatar/{filename}""" + from pathlib import Path + filename = request.match_info.get("filename", "") + avatar_dir = Path(os.environ.get("MINDOS_STORAGE", ".")) / "storage" / "avatars" + filepath = avatar_dir / filename + if not filepath.exists() or not filepath.is_file(): + return web.json_response({"error": "not found"}, status=404, headers=CORS_HEADERS) + + ct = "image/jpeg" + if filename.endswith(".png"): + ct = "image/png" + elif filename.endswith(".webp"): + ct = "image/webp" + + return web.Response( + body=filepath.read_bytes(), + content_type=ct, + headers={**CORS_HEADERS, "Cache-Control": "public, max-age=31536000"}, + ) + + # ══════════════════════════════════════════════ + # L5: 路由注册(覆写 BaseSSEServer.register_routes) + # ══════════════════════════════════════════════ + + def register_routes(self, app) -> None: + """注册 MindOS NEXT 特有的 HTTP 路由。""" + p = self._prefix + + # 音频管线 + app.router.add_get(f"{p}/api/audio/presign", self._handleAudioPresign) + app.router.add_post(f"{p}/api/audio/transcribe", self._handleAudioTranscribe) + app.router.add_post(f"{p}/api/audio/upload", self._handleAudioUpload) + app.router.add_get(f"{p}/api/file/raw", self._handleFileRaw) + app.router.add_post(f"{p}/api/garden/publish", self._handleGardenPublish) + app.router.add_post(f"{p}/api/garden/clip", self._handleGardenClip) + + # 通用文件管线(AnyFile2MD Phase C) + app.router.add_get(f"{p}/api/file/presign", self._handleFilePresign) + app.router.add_post(f"{p}/api/file/parse", self._handleFileParse) + + # 飞书连接器 + app.router.add_get(f"{p}/api/feishu/profiles", self._handleFeishuProfiles) + app.router.add_post(f"{p}/api/feishu/profiles/alias", self._handleFeishuSetAlias) + + # 腾讯会议 + app.router.add_get(f"{p}/api/tencent-meeting/profiles", self._handleTencentMeetingProfiles) + app.router.add_post(f"{p}/api/tencent-meeting/bind", self._handleTencentMeetingBind) + + # IMA + app.router.add_get(f"{p}/api/ima/profiles", self._handleImaProfiles) + app.router.add_post(f"{p}/api/ima/bind", self._handleImaBind) + + # 实时录音 WS + try: + from gateway.platforms.dashscope_realtime import handleWsRecord, register_server + register_server(self) + app.router.add_get(f"{p}/ws/record", handleWsRecord) + logger.info("[MindOSSSE] 实时录音 WS 端点已注册: %s/ws/record", p) + except ImportError as e: + logger.warning("[MindOSSSE] dashscope_realtime 不可用,跳过 WS 路由: %s", e) + + # 积分 & Admin + app.router.add_get(f"{p}/api/credits", self._handleCredits) + app.router.add_get(f"{p}/api/admin/dashboard", self._handleAdminDashboard) + app.router.add_post(f"{p}/api/admin/credits/topup", self._handleAdminTopup) + + # User Profile + app.router.add_post(f"{p}/api/user/name", self._handleUpdateName) + app.router.add_post(f"{p}/api/user/avatar", self._handleUploadAvatar) + app.router.add_get(f"{p}/api/avatar/{{filename}}", self._handleServeAvatar) + + # ── CLI Tunnel ────────────────────────────────────────── + try: + from platforms.mindcli_bridge import MindCLIBridge + self._cli_bridge = MindCLIBridge() + app.router.add_get(f"{p}/ws/cli-tunnel", self._cli_bridge.handle_tunnel_connect) + + # 注入 bridge 到 cli_tunnel_tool,使工具 handler 能通过 Tunnel 派发调用 + try: + from tools.cli_tunnel_tool import set_bridge + set_bridge(self._cli_bridge) + logger.info("[MindOSSSE] CLI Tunnel + Tool Registry 集成完成") + except ImportError: + logger.debug("[MindOSSSE] cli_tunnel_tool 未就绪(非阻塞)") + + logger.info("[MindOSSSE] CLI Tunnel 端点已注册: %s/ws/cli-tunnel", p) + except ImportError as e: + self._cli_bridge = None + logger.warning("[MindOSSSE] mindcli_bridge 不可用,跳过 CLI Tunnel: %s", e) + + +# ── 独立启动入口 ── +async def main(): + """直接运行此文件来启动 MindOS SSE 服务器。""" + import sys + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + + logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s") + + server = MindOSSSEServer( + host=os.getenv("MINDOS_SSE_HOST", "127.0.0.1"), + port=int(os.getenv("MINDOS_SSE_PORT", "8650")), + ) + await server.start() + + try: + while True: + await asyncio.sleep(3600) + except KeyboardInterrupt: + pass + finally: + await server.stop() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/mindcli/_vendor/gateway/platforms/qqbot.py b/mindcli/_vendor/gateway/platforms/qqbot.py new file mode 100644 index 0000000..7103689 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/qqbot.py @@ -0,0 +1,1960 @@ +""" +QQ Bot platform adapter using the Official QQ Bot API (v2). + +Connects to the QQ Bot WebSocket Gateway for inbound events and uses the +REST API (``api.sgroup.qq.com``) for outbound messages and media uploads. + +Configuration in config.yaml: + platforms: + qq: + enabled: true + extra: + app_id: "your-app-id" # or QQ_APP_ID env var + client_secret: "your-secret" # or QQ_CLIENT_SECRET env var + markdown_support: true # enable QQ markdown (msg_type 2) + dm_policy: "open" # open | allowlist | disabled + allow_from: ["openid_1"] + group_policy: "open" # open | allowlist | disabled + group_allow_from: ["group_openid_1"] + stt: # Voice-to-text config (optional) + provider: "zai" # zai (GLM-ASR), openai (Whisper), etc. + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4" + apiKey: "your-stt-api-key" # or set QQ_STT_API_KEY env var + model: "glm-asr" # glm-asr, whisper-1, etc. + + Voice transcription priority: + 1. QQ's built-in ``asr_refer_text`` (Tencent ASR — free, always tried first) + 2. Configured STT provider via ``stt`` config or ``QQ_STT_*`` env vars + +Reference: https://bot.q.qq.com/wiki/develop/api-v2/ +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import logging +import mimetypes +import os +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse + +try: + import aiohttp + AIOHTTP_AVAILABLE = True +except ImportError: + AIOHTTP_AVAILABLE = False + aiohttp = None # type: ignore[assignment] + +try: + import httpx + HTTPX_AVAILABLE = True +except ImportError: + HTTPX_AVAILABLE = False + httpx = None # type: ignore[assignment] + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, + cache_document_from_bytes, + cache_image_from_bytes, +) +from gateway.platforms.helpers import strip_markdown + +logger = logging.getLogger(__name__) + + +class QQCloseError(Exception): + """Raised when QQ WebSocket closes with a specific code. + + Carries the close code and reason for proper handling in the reconnect loop. + """ + + def __init__(self, code, reason=""): + self.code = int(code) if code else None + self.reason = str(reason) if reason else "" + super().__init__(f"WebSocket closed (code={self.code}, reason={self.reason})") +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +API_BASE = "https://api.sgroup.qq.com" +TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken" +GATEWAY_URL_PATH = "/gateway" + +DEFAULT_API_TIMEOUT = 30.0 +FILE_UPLOAD_TIMEOUT = 120.0 +CONNECT_TIMEOUT_SECONDS = 20.0 + +RECONNECT_BACKOFF = [2, 5, 10, 30, 60] +MAX_RECONNECT_ATTEMPTS = 100 +RATE_LIMIT_DELAY = 60 # seconds +QUICK_DISCONNECT_THRESHOLD = 5.0 # seconds +MAX_QUICK_DISCONNECT_COUNT = 3 + +MAX_MESSAGE_LENGTH = 4000 +DEDUP_WINDOW_SECONDS = 300 +DEDUP_MAX_SIZE = 1000 + +# QQ Bot message types +MSG_TYPE_TEXT = 0 +MSG_TYPE_MARKDOWN = 2 +MSG_TYPE_MEDIA = 7 +MSG_TYPE_INPUT_NOTIFY = 6 + +# QQ Bot file media types +MEDIA_TYPE_IMAGE = 1 +MEDIA_TYPE_VIDEO = 2 +MEDIA_TYPE_VOICE = 3 +MEDIA_TYPE_FILE = 4 + + +def check_qq_requirements() -> bool: + """Check if QQ runtime dependencies are available.""" + return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE + + +def _coerce_list(value: Any) -> List[str]: + """Coerce config values into a trimmed string list.""" + if value is None: + return [] + if isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] + if isinstance(value, (list, tuple, set)): + return [str(item).strip() for item in value if str(item).strip()] + return [str(value).strip()] if str(value).strip() else [] + + +# --------------------------------------------------------------------------- +# QQAdapter +# --------------------------------------------------------------------------- + +class QQAdapter(BasePlatformAdapter): + """QQ Bot adapter backed by the official QQ Bot WebSocket Gateway + REST API.""" + + # QQ Bot API does not support editing sent messages. + SUPPORTS_MESSAGE_EDITING = False + + def _fail_pending(self, reason: str) -> None: + """Fail all pending response futures.""" + for fut in self._pending_responses.values(): + if not fut.done(): + fut.set_exception(RuntimeError(reason)) + self._pending_responses.clear() + + MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.QQBOT) + + extra = config.extra or {} + self._app_id = str(extra.get("app_id") or os.getenv("QQ_APP_ID", "")).strip() + self._client_secret = str(extra.get("client_secret") or os.getenv("QQ_CLIENT_SECRET", "")).strip() + self._markdown_support = bool(extra.get("markdown_support", True)) + + # Auth/ACL policies + self._dm_policy = str(extra.get("dm_policy", "open")).strip().lower() + self._allow_from = _coerce_list(extra.get("allow_from") or extra.get("allowFrom")) + self._group_policy = str(extra.get("group_policy", "open")).strip().lower() + self._group_allow_from = _coerce_list(extra.get("group_allow_from") or extra.get("groupAllowFrom")) + + # Connection state + self._session: Optional[aiohttp.ClientSession] = None + self._ws: Optional[aiohttp.ClientWebSocketResponse] = None + self._http_client: Optional[httpx.AsyncClient] = None + self._listen_task: Optional[asyncio.Task] = None + self._heartbeat_task: Optional[asyncio.Task] = None + self._heartbeat_interval: float = 30.0 # seconds, updated by Hello + self._session_id: Optional[str] = None + self._last_seq: Optional[int] = None + self._chat_type_map: Dict[str, str] = {} # chat_id → "c2c"|"group"|"guild"|"dm" + + # Request/response correlation + self._pending_responses: Dict[str, asyncio.Future] = {} + self._seen_messages: Dict[str, float] = {} + + # Token cache + self._access_token: Optional[str] = None + self._token_expires_at: float = 0.0 + self._token_lock = asyncio.Lock() + + # Upload cache: content_hash -> {file_info, file_uuid, expires_at} + self._upload_cache: Dict[str, Dict[str, Any]] = {} + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return "QQBot" + + # ------------------------------------------------------------------ + # Connection lifecycle + # ------------------------------------------------------------------ + + async def connect(self) -> bool: + """Authenticate, obtain gateway URL, and open the WebSocket.""" + if not AIOHTTP_AVAILABLE: + message = "QQ startup failed: aiohttp not installed" + self._set_fatal_error("qq_missing_dependency", message, retryable=True) + logger.warning("[%s] %s. Run: pip install aiohttp", self.name, message) + return False + if not HTTPX_AVAILABLE: + message = "QQ startup failed: httpx not installed" + self._set_fatal_error("qq_missing_dependency", message, retryable=True) + logger.warning("[%s] %s. Run: pip install httpx", self.name, message) + return False + if not self._app_id or not self._client_secret: + message = "QQ startup failed: QQ_APP_ID and QQ_CLIENT_SECRET are required" + self._set_fatal_error("qq_missing_credentials", message, retryable=True) + logger.warning("[%s] %s", self.name, message) + return False + + # Prevent duplicate connections with the same credentials + if not self._acquire_platform_lock( + "qqbot-appid", self._app_id, "QQBot app ID" + ): + return False + + try: + self._http_client = httpx.AsyncClient(timeout=30.0, follow_redirects=True) + + # 1. Get access token + await self._ensure_token() + + # 2. Get WebSocket gateway URL + gateway_url = await self._get_gateway_url() + logger.info("[%s] Gateway URL: %s", self.name, gateway_url) + + # 3. Open WebSocket + await self._open_ws(gateway_url) + + # 4. Start listeners + self._listen_task = asyncio.create_task(self._listen_loop()) + self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) + self._mark_connected() + logger.info("[%s] Connected", self.name) + return True + except Exception as exc: + message = f"QQ startup failed: {exc}" + self._set_fatal_error("qq_connect_error", message, retryable=True) + logger.error("[%s] %s", self.name, message, exc_info=True) + await self._cleanup() + self._release_platform_lock() + return False + + async def disconnect(self) -> None: + """Close all connections and stop listeners.""" + self._running = False + self._mark_disconnected() + + if self._listen_task: + self._listen_task.cancel() + try: + await self._listen_task + except asyncio.CancelledError: + pass + self._listen_task = None + + if self._heartbeat_task: + self._heartbeat_task.cancel() + try: + await self._heartbeat_task + except asyncio.CancelledError: + pass + self._heartbeat_task = None + + await self._cleanup() + self._release_platform_lock() + logger.info("[%s] Disconnected", self.name) + + async def _cleanup(self) -> None: + """Close WebSocket, HTTP session, and client.""" + if self._ws and not self._ws.closed: + await self._ws.close() + self._ws = None + + if self._session and not self._session.closed: + await self._session.close() + self._session = None + + if self._http_client: + await self._http_client.aclose() + self._http_client = None + + # Fail pending + for fut in self._pending_responses.values(): + if not fut.done(): + fut.set_exception(RuntimeError("Disconnected")) + self._pending_responses.clear() + + # ------------------------------------------------------------------ + # Token management + # ------------------------------------------------------------------ + + async def _ensure_token(self) -> str: + """Return a valid access token, refreshing if needed (with singleflight).""" + if self._access_token and time.time() < self._token_expires_at - 60: + return self._access_token + + async with self._token_lock: + # Double-check after acquiring lock + if self._access_token and time.time() < self._token_expires_at - 60: + return self._access_token + + try: + resp = await self._http_client.post( + TOKEN_URL, + json={"appId": self._app_id, "clientSecret": self._client_secret}, + timeout=DEFAULT_API_TIMEOUT, + ) + resp.raise_for_status() + data = resp.json() + except Exception as exc: + raise RuntimeError(f"Failed to get QQ Bot access token: {exc}") from exc + + token = data.get("access_token") + if not token: + raise RuntimeError(f"QQ Bot token response missing access_token: {data}") + + expires_in = int(data.get("expires_in", 7200)) + self._access_token = token + self._token_expires_at = time.time() + expires_in + logger.info("[%s] Access token refreshed, expires in %ds", self.name, expires_in) + return self._access_token + + async def _get_gateway_url(self) -> str: + """Fetch the WebSocket gateway URL from the REST API.""" + token = await self._ensure_token() + try: + resp = await self._http_client.get( + f"{API_BASE}{GATEWAY_URL_PATH}", + headers={"Authorization": f"QQBot {token}"}, + timeout=DEFAULT_API_TIMEOUT, + ) + resp.raise_for_status() + data = resp.json() + except Exception as exc: + raise RuntimeError(f"Failed to get QQ Bot gateway URL: {exc}") from exc + + url = data.get("url") + if not url: + raise RuntimeError(f"QQ Bot gateway response missing url: {data}") + return url + + # ------------------------------------------------------------------ + # WebSocket lifecycle + # ------------------------------------------------------------------ + + async def _open_ws(self, gateway_url: str) -> None: + """Open a WebSocket connection to the QQ Bot gateway.""" + # Only clean up WebSocket resources — keep _http_client alive for REST API calls. + if self._ws and not self._ws.closed: + await self._ws.close() + self._ws = None + if self._session and not self._session.closed: + await self._session.close() + self._session = None + + self._session = aiohttp.ClientSession() + self._ws = await self._session.ws_connect( + gateway_url, + timeout=CONNECT_TIMEOUT_SECONDS, + ) + logger.info("[%s] WebSocket connected to %s", self.name, gateway_url) + + async def _listen_loop(self) -> None: + """Read WebSocket events and reconnect on errors. + + Close code handling follows the OpenClaw qqbot reference implementation: + 4004 → invalid token, refresh and reconnect + 4006/4007/4009 → session invalid, clear session and re-identify + 4008 → rate limited, back off 60s + 4914 → bot offline/sandbox, stop reconnecting + 4915 → bot banned, stop reconnecting + """ + backoff_idx = 0 + connect_time = 0.0 + quick_disconnect_count = 0 + + while self._running: + try: + connect_time = time.monotonic() + await self._read_events() + backoff_idx = 0 + quick_disconnect_count = 0 + except asyncio.CancelledError: + return + except QQCloseError as exc: + if not self._running: + return + + code = exc.code + logger.warning("[%s] WebSocket closed: code=%s reason=%s", + self.name, code, exc.reason) + + # Quick disconnect detection (permission issues, misconfiguration) + duration = time.monotonic() - connect_time + if duration < QUICK_DISCONNECT_THRESHOLD and connect_time > 0: + quick_disconnect_count += 1 + logger.info("[%s] Quick disconnect (%.1fs), count: %d", + self.name, duration, quick_disconnect_count) + if quick_disconnect_count >= MAX_QUICK_DISCONNECT_COUNT: + logger.error( + "[%s] Too many quick disconnects. " + "Check: 1) AppID/Secret correct 2) Bot permissions on QQ Open Platform", + self.name, + ) + self._set_fatal_error("qq_quick_disconnect", + "Too many quick disconnects — check bot permissions", retryable=True) + return + else: + quick_disconnect_count = 0 + + self._mark_disconnected() + self._fail_pending("Connection closed") + + # Stop reconnecting for fatal codes + if code in (4914, 4915): + desc = "offline/sandbox-only" if code == 4914 else "banned" + logger.error("[%s] Bot is %s. Check QQ Open Platform.", self.name, desc) + self._set_fatal_error(f"qq_{desc}", f"Bot is {desc}", retryable=False) + return + + # Rate limited + if code == 4008: + logger.info("[%s] Rate limited (4008), waiting %ds", self.name, RATE_LIMIT_DELAY) + if backoff_idx >= MAX_RECONNECT_ATTEMPTS: + return + await asyncio.sleep(RATE_LIMIT_DELAY) + if await self._reconnect(backoff_idx): + backoff_idx = 0 + quick_disconnect_count = 0 + else: + backoff_idx += 1 + continue + + # Token invalid → clear cached token so _ensure_token() refreshes + if code == 4004: + logger.info("[%s] Invalid token (4004), will refresh and reconnect", self.name) + self._access_token = None + self._token_expires_at = 0.0 + + # Session invalid → clear session, will re-identify on next Hello + if code in (4006, 4007, 4009, 4900, 4901, 4902, 4903, 4904, 4905, + 4906, 4907, 4908, 4909, 4910, 4911, 4912, 4913): + logger.info("[%s] Session error (%d), clearing session for re-identify", self.name, code) + self._session_id = None + self._last_seq = None + + if await self._reconnect(backoff_idx): + backoff_idx = 0 + quick_disconnect_count = 0 + else: + backoff_idx += 1 + + except Exception as exc: + if not self._running: + return + logger.warning("[%s] WebSocket error: %s", self.name, exc) + self._mark_disconnected() + self._fail_pending("Connection interrupted") + + if backoff_idx >= MAX_RECONNECT_ATTEMPTS: + logger.error("[%s] Max reconnect attempts reached", self.name) + return + + if await self._reconnect(backoff_idx): + backoff_idx = 0 + quick_disconnect_count = 0 + else: + backoff_idx += 1 + + async def _reconnect(self, backoff_idx: int) -> bool: + """Attempt to reconnect the WebSocket. Returns True on success.""" + delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)] + logger.info("[%s] Reconnecting in %ds (attempt %d)...", self.name, delay, backoff_idx + 1) + await asyncio.sleep(delay) + + self._heartbeat_interval = 30.0 # reset until Hello + try: + await self._ensure_token() + gateway_url = await self._get_gateway_url() + await self._open_ws(gateway_url) + self._mark_connected() + logger.info("[%s] Reconnected", self.name) + return True + except Exception as exc: + logger.warning("[%s] Reconnect failed: %s", self.name, exc) + return False + + async def _read_events(self) -> None: + """Read WebSocket frames until connection closes.""" + if not self._ws: + raise RuntimeError("WebSocket not connected") + + while self._running and self._ws and not self._ws.closed: + msg = await self._ws.receive() + if msg.type == aiohttp.WSMsgType.TEXT: + payload = self._parse_json(msg.data) + if payload: + self._dispatch_payload(payload) + elif msg.type in (aiohttp.WSMsgType.PING,): + # aiohttp auto-replies with PONG + pass + elif msg.type == aiohttp.WSMsgType.CLOSE: + raise QQCloseError(msg.data, msg.extra) + elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + raise RuntimeError("WebSocket closed") + + async def _heartbeat_loop(self) -> None: + """Send periodic heartbeats (QQ Gateway expects op 1 heartbeat with latest seq). + + The interval is set from the Hello (op 10) event's heartbeat_interval. + QQ's default is ~41s; we send at 80% of the interval to stay safe. + """ + try: + while self._running: + await asyncio.sleep(self._heartbeat_interval) + if not self._ws or self._ws.closed: + continue + try: + # d should be the latest sequence number received, or null + await self._ws.send_json({"op": 1, "d": self._last_seq}) + except Exception as exc: + logger.debug("[%s] Heartbeat failed: %s", self.name, exc) + except asyncio.CancelledError: + pass + + async def _send_identify(self) -> None: + """Send op 2 Identify to authenticate the WebSocket connection. + + After receiving op 10 Hello, the client must send op 2 Identify with + the bot token and intents. On success the server replies with a + READY dispatch event. + + Reference: https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/reference.html + """ + token = await self._ensure_token() + identify_payload = { + "op": 2, + "d": { + "token": f"QQBot {token}", + "intents": (1 << 25) | (1 << 30) | (1 << 12), # C2C_GROUP_AT_MESSAGES + PUBLIC_GUILD_MESSAGES + DIRECT_MESSAGE + "shard": [0, 1], + "properties": { + "$os": "macOS", + "$browser": "hermes-agent", + "$device": "hermes-agent", + }, + }, + } + try: + if self._ws and not self._ws.closed: + await self._ws.send_json(identify_payload) + logger.info("[%s] Identify sent", self.name) + else: + logger.warning("[%s] Cannot send Identify: WebSocket not connected", self.name) + except Exception as exc: + logger.error("[%s] Failed to send Identify: %s", self.name, exc) + + async def _send_resume(self) -> None: + """Send op 6 Resume to re-authenticate after a reconnection. + + Reference: https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/reference.html + """ + token = await self._ensure_token() + resume_payload = { + "op": 6, + "d": { + "token": f"QQBot {token}", + "session_id": self._session_id, + "seq": self._last_seq, + }, + } + try: + if self._ws and not self._ws.closed: + await self._ws.send_json(resume_payload) + logger.info("[%s] Resume sent (session_id=%s, seq=%s)", + self.name, self._session_id, self._last_seq) + else: + logger.warning("[%s] Cannot send Resume: WebSocket not connected", self.name) + except Exception as exc: + logger.error("[%s] Failed to send Resume: %s", self.name, exc) + # If resume fails, clear session and fall back to identify on next Hello + self._session_id = None + self._last_seq = None + + @staticmethod + def _create_task(coro): + """Schedule a coroutine, silently skipping if no event loop is running. + + This avoids ``RuntimeError: no running event loop`` when tests call + ``_dispatch_payload`` synchronously outside of ``asyncio.run()``. + """ + try: + loop = asyncio.get_running_loop() + return loop.create_task(coro) + except RuntimeError: + return None + + def _dispatch_payload(self, payload: Dict[str, Any]) -> None: + """Route inbound WebSocket payloads (dispatch synchronously, spawn async handlers).""" + op = payload.get("op") + t = payload.get("t") + s = payload.get("s") + d = payload.get("d") + if isinstance(s, int) and (self._last_seq is None or s > self._last_seq): + self._last_seq = s + + # op 10 = Hello (heartbeat interval) — must reply with Identify/Resume + if op == 10: + d_data = d if isinstance(d, dict) else {} + interval_ms = d_data.get("heartbeat_interval", 30000) + # Send heartbeats at 80% of the server interval to stay safe + self._heartbeat_interval = interval_ms / 1000.0 * 0.8 + logger.debug("[%s] Hello received, heartbeat_interval=%dms (sending every %.1fs)", + self.name, interval_ms, self._heartbeat_interval) + # Authenticate: send Resume if we have a session, else Identify. + # Use _create_task which is safe when no event loop is running (tests). + if self._session_id and self._last_seq is not None: + self._create_task(self._send_resume()) + else: + self._create_task(self._send_identify()) + return + + # op 0 = Dispatch + if op == 0 and t: + if t == "READY": + self._handle_ready(d) + elif t == "RESUMED": + logger.info("[%s] Session resumed", self.name) + elif t in ("C2C_MESSAGE_CREATE", "GROUP_AT_MESSAGE_CREATE", + "DIRECT_MESSAGE_CREATE", "GUILD_MESSAGE_CREATE", + "GUILD_AT_MESSAGE_CREATE"): + asyncio.create_task(self._on_message(t, d)) + else: + logger.debug("[%s] Unhandled dispatch: %s", self.name, t) + return + + # op 11 = Heartbeat ACK + if op == 11: + return + + logger.debug("[%s] Unknown op: %s", self.name, op) + + def _handle_ready(self, d: Any) -> None: + """Handle the READY event — store session_id for resume.""" + if isinstance(d, dict): + self._session_id = d.get("session_id") + logger.info("[%s] Ready, session_id=%s", self.name, self._session_id) + + # ------------------------------------------------------------------ + # JSON helpers + # ------------------------------------------------------------------ + + @staticmethod + def _parse_json(raw: Any) -> Optional[Dict[str, Any]]: + try: + payload = json.loads(raw) + except Exception: + logger.debug("[%s] Failed to parse JSON: %r", "QQBot", raw) + return None + return payload if isinstance(payload, dict) else None + + @staticmethod + def _next_msg_seq(msg_id: str) -> int: + """Generate a message sequence number in 0..65535 range.""" + time_part = int(time.time()) % 100000000 + rand = int(uuid.uuid4().hex[:4], 16) + return (time_part ^ rand) % 65536 + + # ------------------------------------------------------------------ + # Inbound message handling + # ------------------------------------------------------------------ + + async def _on_message(self, event_type: str, d: Any) -> None: + """Process an inbound QQ Bot message event.""" + if not isinstance(d, dict): + return + + # Extract common fields + msg_id = str(d.get("id", "")) + if not msg_id or self._is_duplicate(msg_id): + logger.debug("[%s] Duplicate or missing message id: %s", self.name, msg_id) + return + + timestamp = str(d.get("timestamp", "")) + content = str(d.get("content", "")).strip() + author = d.get("author") if isinstance(d.get("author"), dict) else {} + + # Route by event type + if event_type == "C2C_MESSAGE_CREATE": + await self._handle_c2c_message(d, msg_id, content, author, timestamp) + elif event_type in ("GROUP_AT_MESSAGE_CREATE",): + await self._handle_group_message(d, msg_id, content, author, timestamp) + elif event_type in ("GUILD_MESSAGE_CREATE", "GUILD_AT_MESSAGE_CREATE"): + await self._handle_guild_message(d, msg_id, content, author, timestamp) + elif event_type == "DIRECT_MESSAGE_CREATE": + await self._handle_dm_message(d, msg_id, content, author, timestamp) + + async def _handle_c2c_message( + self, d: Dict[str, Any], msg_id: str, content: str, author: Dict[str, Any], timestamp: str + ) -> None: + """Handle a C2C (private) message event.""" + user_openid = str(author.get("user_openid", "")) + if not user_openid: + return + if not self._is_dm_allowed(user_openid): + return + + text = content + attachments_raw = d.get("attachments") + logger.info("[QQ] C2C message: id=%s content=%r attachments=%s", + msg_id, content[:50] if content else "", + f"{len(attachments_raw) if isinstance(attachments_raw, list) else 0} items" + if attachments_raw else "None") + if attachments_raw and isinstance(attachments_raw, list): + for _i, _att in enumerate(attachments_raw): + if isinstance(_att, dict): + logger.info("[QQ] attachment[%d]: content_type=%s url=%s filename=%s", + _i, _att.get("content_type", ""), + str(_att.get("url", ""))[:80], + _att.get("filename", "")) + + # Process all attachments uniformly (images, voice, files) + att_result = await self._process_attachments(attachments_raw) + image_urls = att_result["image_urls"] + image_media_types = att_result["image_media_types"] + voice_transcripts = att_result["voice_transcripts"] + attachment_info = att_result["attachment_info"] + + # Append voice transcripts to the text body + if voice_transcripts: + voice_block = "\n".join(voice_transcripts) + text = (text + "\n\n" + voice_block).strip() if text.strip() else voice_block + # Append non-media attachment info + if attachment_info: + text = (text + "\n\n" + attachment_info).strip() if text.strip() else attachment_info + + logger.info("[QQ] After processing: images=%d, voice=%d", + len(image_urls), len(voice_transcripts)) + + if not text.strip() and not image_urls: + return + + self._chat_type_map[user_openid] = "c2c" + event = MessageEvent( + source=self.build_source( + chat_id=user_openid, + user_id=user_openid, + chat_type="dm", + ), + text=text, + message_type=self._detect_message_type(image_urls, image_media_types), + raw_message=d, + message_id=msg_id, + media_urls=image_urls, + media_types=image_media_types, + timestamp=self._parse_qq_timestamp(timestamp), + ) + await self.handle_message(event) + + async def _handle_group_message( + self, d: Dict[str, Any], msg_id: str, content: str, author: Dict[str, Any], timestamp: str + ) -> None: + """Handle a group @-message event.""" + group_openid = str(d.get("group_openid", "")) + if not group_openid: + return + if not self._is_group_allowed(group_openid, str(author.get("member_openid", ""))): + return + + # Strip the @bot mention prefix from content + text = self._strip_at_mention(content) + att_result = await self._process_attachments(d.get("attachments")) + image_urls = att_result["image_urls"] + image_media_types = att_result["image_media_types"] + voice_transcripts = att_result["voice_transcripts"] + attachment_info = att_result["attachment_info"] + + # Append voice transcripts + if voice_transcripts: + voice_block = "\n".join(voice_transcripts) + text = (text + "\n\n" + voice_block).strip() if text.strip() else voice_block + if attachment_info: + text = (text + "\n\n" + attachment_info).strip() if text.strip() else attachment_info + + if not text.strip() and not image_urls: + return + + self._chat_type_map[group_openid] = "group" + event = MessageEvent( + source=self.build_source( + chat_id=group_openid, + user_id=str(author.get("member_openid", "")), + chat_type="group", + ), + text=text, + message_type=self._detect_message_type(image_urls, image_media_types), + raw_message=d, + message_id=msg_id, + media_urls=image_urls, + media_types=image_media_types, + timestamp=self._parse_qq_timestamp(timestamp), + ) + await self.handle_message(event) + + async def _handle_guild_message( + self, d: Dict[str, Any], msg_id: str, content: str, author: Dict[str, Any], timestamp: str + ) -> None: + """Handle a guild/channel message event.""" + channel_id = str(d.get("channel_id", "")) + if not channel_id: + return + + member = d.get("member") if isinstance(d.get("member"), dict) else {} + nick = str(member.get("nick", "")) or str(author.get("username", "")) + + text = content + att_result = await self._process_attachments(d.get("attachments")) + image_urls = att_result["image_urls"] + image_media_types = att_result["image_media_types"] + voice_transcripts = att_result["voice_transcripts"] + attachment_info = att_result["attachment_info"] + + if voice_transcripts: + voice_block = "\n".join(voice_transcripts) + text = (text + "\n\n" + voice_block).strip() if text.strip() else voice_block + if attachment_info: + text = (text + "\n\n" + attachment_info).strip() if text.strip() else attachment_info + + if not text.strip() and not image_urls: + return + + self._chat_type_map[channel_id] = "guild" + event = MessageEvent( + source=self.build_source( + chat_id=channel_id, + user_id=str(author.get("id", "")), + user_name=nick or None, + chat_type="group", + ), + text=text, + message_type=self._detect_message_type(image_urls, image_media_types), + raw_message=d, + message_id=msg_id, + media_urls=image_urls, + media_types=image_media_types, + timestamp=self._parse_qq_timestamp(timestamp), + ) + await self.handle_message(event) + + async def _handle_dm_message( + self, d: Dict[str, Any], msg_id: str, content: str, author: Dict[str, Any], timestamp: str + ) -> None: + """Handle a guild DM message event.""" + guild_id = str(d.get("guild_id", "")) + if not guild_id: + return + + text = content + att_result = await self._process_attachments(d.get("attachments")) + image_urls = att_result["image_urls"] + image_media_types = att_result["image_media_types"] + voice_transcripts = att_result["voice_transcripts"] + attachment_info = att_result["attachment_info"] + + if voice_transcripts: + voice_block = "\n".join(voice_transcripts) + text = (text + "\n\n" + voice_block).strip() if text.strip() else voice_block + if attachment_info: + text = (text + "\n\n" + attachment_info).strip() if text.strip() else attachment_info + + if not text.strip() and not image_urls: + return + + self._chat_type_map[guild_id] = "dm" + event = MessageEvent( + source=self.build_source( + chat_id=guild_id, + user_id=str(author.get("id", "")), + chat_type="dm", + ), + text=text, + message_type=self._detect_message_type(image_urls, image_media_types), + raw_message=d, + message_id=msg_id, + media_urls=image_urls, + media_types=image_media_types, + timestamp=self._parse_qq_timestamp(timestamp), + ) + await self.handle_message(event) + + # ------------------------------------------------------------------ + # Attachment processing + # ------------------------------------------------------------------ + + + @staticmethod + def _detect_message_type(media_urls: list, media_types: list): + """Determine MessageType from attachment content types.""" + if not media_urls: + return MessageType.TEXT + if not media_types: + return MessageType.PHOTO + first_type = media_types[0].lower() if media_types else "" + if "audio" in first_type or "voice" in first_type or "silk" in first_type: + return MessageType.VOICE + if "video" in first_type: + return MessageType.VIDEO + if "image" in first_type or "photo" in first_type: + return MessageType.PHOTO + # Unknown content type with an attachment — don't assume PHOTO + # to prevent non-image files from being sent to vision analysis. + logger.debug("[QQ] Unknown media content_type '%s', defaulting to TEXT", first_type) + return MessageType.TEXT + + async def _process_attachments( + self, attachments: Any, + ) -> Dict[str, Any]: + """Process inbound attachments (all message types). + + Mirrors OpenClaw's ``processAttachments`` — handles images, voice, and + other files uniformly. + + Returns a dict with: + - image_urls: list[str] — cached local image paths + - image_media_types: list[str] — MIME types of cached images + - voice_transcripts: list[str] — STT transcripts for voice messages + - attachment_info: str — text description of non-image, non-voice attachments + """ + if not isinstance(attachments, list): + return {"image_urls": [], "image_media_types": [], + "voice_transcripts": [], "attachment_info": ""} + + image_urls: List[str] = [] + image_media_types: List[str] = [] + voice_transcripts: List[str] = [] + other_attachments: List[str] = [] + + for att in attachments: + if not isinstance(att, dict): + continue + + ct = str(att.get("content_type", "")).strip().lower() + url_raw = str(att.get("url", "")).strip() + filename = str(att.get("filename", "")) + if url_raw.startswith("//"): + url = f"https:{url_raw}" + elif url_raw: + url = url_raw + else: + url = "" + continue + + logger.debug("[QQ] Processing attachment: content_type=%s, url=%s, filename=%s", + ct, url[:80], filename) + + if self._is_voice_content_type(ct, filename): + # Voice: use QQ's asr_refer_text first, then voice_wav_url, then STT. + asr_refer = ( + str(att.get("asr_refer_text", "")).strip() + if isinstance(att.get("asr_refer_text"), str) else "" + ) + voice_wav_url = ( + str(att.get("voice_wav_url", "")).strip() + if isinstance(att.get("voice_wav_url"), str) else "" + ) + + transcript = await self._stt_voice_attachment( + url, ct, filename, + asr_refer_text=asr_refer or None, + voice_wav_url=voice_wav_url or None, + ) + if transcript: + voice_transcripts.append(f"[Voice] {transcript}") + logger.info("[QQ] Voice transcript: %s", transcript) + else: + logger.warning("[QQ] Voice STT failed for %s", url[:60]) + voice_transcripts.append("[Voice] [语音识别失败]") + elif ct.startswith("image/"): + # Image: download and cache locally. + try: + cached_path = await self._download_and_cache(url, ct) + if cached_path and os.path.isfile(cached_path): + image_urls.append(cached_path) + image_media_types.append(ct or "image/jpeg") + elif cached_path: + logger.warning("[QQ] Cached image path does not exist: %s", cached_path) + except Exception as exc: + logger.debug("[QQ] Failed to cache image: %s", exc) + else: + # Other attachments (video, file, etc.): record as text. + try: + cached_path = await self._download_and_cache(url, ct) + if cached_path: + other_attachments.append(f"[Attachment: {filename or ct}]") + except Exception as exc: + logger.debug("[QQ] Failed to cache attachment: %s", exc) + + attachment_info = "\n".join(other_attachments) if other_attachments else "" + return { + "image_urls": image_urls, + "image_media_types": image_media_types, + "voice_transcripts": voice_transcripts, + "attachment_info": attachment_info, + } + + async def _download_and_cache(self, url: str, content_type: str) -> Optional[str]: + """Download a URL and cache it locally.""" + from tools.url_safety import is_safe_url + if not is_safe_url(url): + raise ValueError(f"Blocked unsafe URL: {url[:80]}") + + if not self._http_client: + return None + + try: + resp = await self._http_client.get( + url, timeout=30.0, headers=self._qq_media_headers(), + ) + resp.raise_for_status() + data = resp.content + except Exception as exc: + logger.debug("[%s] Download failed for %s: %s", self.name, url[:80], exc) + return None + + if content_type.startswith("image/"): + ext = mimetypes.guess_extension(content_type) or ".jpg" + return cache_image_from_bytes(data, ext) + elif content_type == "voice" or content_type.startswith("audio/"): + # QQ voice messages are typically .amr or .silk format. + # Convert to .wav using ffmpeg so STT engines can process it. + return await self._convert_audio_to_wav(data, url) + else: + filename = Path(urlparse(url).path).name or "qq_attachment" + return cache_document_from_bytes(data, filename) + + @staticmethod + def _is_voice_content_type(content_type: str, filename: str) -> bool: + """Check if an attachment is a voice/audio message.""" + ct = content_type.strip().lower() + fn = filename.strip().lower() + if ct == "voice" or ct.startswith("audio/"): + return True + _VOICE_EXTENSIONS = (".silk", ".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac", ".speex", ".flac") + if any(fn.endswith(ext) for ext in _VOICE_EXTENSIONS): + return True + return False + + def _qq_media_headers(self) -> Dict[str, str]: + """Return Authorization headers for QQ multimedia CDN downloads. + + QQ's multimedia URLs (multimedia.nt.qq.com.cn) require the bot's + access token in an Authorization header, otherwise the download + returns a non-200 status. + """ + if self._access_token: + return {"Authorization": f"QQBot {self._access_token}"} + return {} + + async def _stt_voice_attachment( + self, + url: str, + content_type: str, + filename: str, + *, + asr_refer_text: Optional[str] = None, + voice_wav_url: Optional[str] = None, + ) -> Optional[str]: + """Download a voice attachment, convert to wav, and transcribe. + + Priority: + 1. QQ's built-in ``asr_refer_text`` (Tencent's own ASR — free, no API call). + 2. Self-hosted STT on ``voice_wav_url`` (pre-converted WAV from QQ, avoids SILK decoding). + 3. Self-hosted STT on the original attachment URL (requires SILK→WAV conversion). + + Returns the transcript text, or None on failure. + """ + # 1. Use QQ's built-in ASR text if available + if asr_refer_text: + logger.info("[QQ] STT: using QQ asr_refer_text: %r", asr_refer_text[:100]) + return asr_refer_text + + # Determine which URL to download (prefer voice_wav_url — already WAV) + download_url = url + is_pre_wav = False + if voice_wav_url: + if voice_wav_url.startswith("//"): + voice_wav_url = f"https:{voice_wav_url}" + download_url = voice_wav_url + is_pre_wav = True + logger.info("[QQ] STT: using voice_wav_url (pre-converted WAV)") + + try: + # 2. Download audio (QQ CDN requires Authorization header) + if not self._http_client: + logger.warning("[QQ] STT: no HTTP client") + return None + + download_headers = self._qq_media_headers() + logger.info("[QQ] STT: downloading voice from %s (pre_wav=%s, headers=%s)", + download_url[:80], is_pre_wav, bool(download_headers)) + resp = await self._http_client.get( + download_url, timeout=30.0, headers=download_headers, follow_redirects=True, + ) + resp.raise_for_status() + audio_data = resp.content + logger.info("[QQ] STT: downloaded %d bytes, content_type=%s", + len(audio_data), resp.headers.get("content-type", "unknown")) + + if len(audio_data) < 10: + logger.warning("[QQ] STT: downloaded data too small (%d bytes), skipping", len(audio_data)) + return None + + # 3. Convert to wav (skip if we already have a pre-converted WAV) + if is_pre_wav: + import tempfile + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + tmp.write(audio_data) + wav_path = tmp.name + logger.info("[QQ] STT: using pre-converted WAV directly (%d bytes)", len(audio_data)) + else: + logger.info("[QQ] STT: converting to wav, filename=%r", filename) + wav_path = await self._convert_audio_to_wav_file(audio_data, filename) + if not wav_path or not Path(wav_path).exists(): + logger.warning("[QQ] STT: ffmpeg conversion produced no output") + return None + + # 4. Call STT API + logger.info("[QQ] STT: calling ASR on %s", wav_path) + transcript = await self._call_stt(wav_path) + + # 5. Cleanup temp file + try: + os.unlink(wav_path) + except OSError: + pass + + if transcript: + logger.info("[QQ] STT success: %r", transcript[:100]) + else: + logger.warning("[QQ] STT: ASR returned empty transcript") + return transcript + except (httpx.HTTPStatusError, httpx.TransportError, IOError) as exc: + logger.warning("[QQ] STT failed for voice attachment: %s: %s", type(exc).__name__, exc) + return None + + async def _convert_audio_to_wav_file(self, audio_data: bytes, filename: str) -> Optional[str]: + """Convert audio bytes to a temp .wav file using pilk (SILK) or ffmpeg. + + QQ voice messages are typically SILK format which ffmpeg cannot decode. + Strategy: always try pilk first, fall back to ffmpeg if pilk fails. + + Returns the wav file path, or None on failure. + """ + import tempfile + + ext = Path(filename).suffix.lower() if Path(filename).suffix else self._guess_ext_from_data(audio_data) + logger.info("[QQ] STT: audio_data size=%d, ext=%r, first_20_bytes=%r", + len(audio_data), ext, audio_data[:20]) + + with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp_src: + tmp_src.write(audio_data) + src_path = tmp_src.name + + wav_path = src_path.rsplit(".", 1)[0] + ".wav" + + # Try pilk first (handles SILK and many other formats) + result = await self._convert_silk_to_wav(src_path, wav_path) + + # If pilk failed, try ffmpeg + if not result: + result = await self._convert_ffmpeg_to_wav(src_path, wav_path) + + # If ffmpeg also failed, try writing raw PCM as WAV (last resort) + if not result: + result = await self._convert_raw_to_wav(audio_data, wav_path) + + # Cleanup source file + try: + os.unlink(src_path) + except OSError: + pass + + return result + + @staticmethod + def _guess_ext_from_data(data: bytes) -> str: + """Guess file extension from magic bytes.""" + if data[:9] == b"#!SILK_V3" or data[:5] == b"#!SILK": + return ".silk" + if data[:2] == b"\x02!": + return ".silk" + if data[:4] == b"RIFF": + return ".wav" + if data[:4] == b"fLaC": + return ".flac" + if data[:2] in (b"\xff\xfb", b"\xff\xf3", b"\xff\xf2"): + return ".mp3" + if data[:4] == b"\x30\x26\xb2\x75" or data[:4] == b"\x4f\x67\x67\x53": + return ".ogg" + if data[:4] == b"\x00\x00\x00\x20" or data[:4] == b"\x00\x00\x00\x1c": + return ".amr" + # Default to .amr for unknown (QQ's most common voice format) + return ".amr" + + @staticmethod + def _looks_like_silk(data: bytes) -> bool: + """Check if bytes look like a SILK audio file.""" + return data[:4] == b"#!SILK" or data[:2] == b"\x02!" or data[:9] == b"#!SILK_V3" + + @staticmethod + async def _convert_silk_to_wav(src_path: str, wav_path: str) -> Optional[str]: + """Convert audio file to WAV using the pilk library. + + Tries the file as-is first, then as .silk if the extension differs. + pilk can handle SILK files with various headers (or no header). + """ + try: + import pilk + except ImportError: + logger.warning("[QQ] pilk not installed — cannot decode SILK audio. Run: pip install pilk") + return None + + # Try converting the file as-is + try: + pilk.silk_to_wav(src_path, wav_path, rate=16000) + if Path(wav_path).exists() and Path(wav_path).stat().st_size > 44: + logger.info("[QQ] pilk converted %s to wav (%d bytes)", + Path(src_path).name, Path(wav_path).stat().st_size) + return wav_path + except Exception as exc: + logger.debug("[QQ] pilk direct conversion failed: %s", exc) + + # Try renaming to .silk and converting (pilk checks the extension) + silk_path = src_path.rsplit(".", 1)[0] + ".silk" + try: + import shutil + shutil.copy2(src_path, silk_path) + pilk.silk_to_wav(silk_path, wav_path, rate=16000) + if Path(wav_path).exists() and Path(wav_path).stat().st_size > 44: + logger.info("[QQ] pilk converted %s (as .silk) to wav (%d bytes)", + Path(src_path).name, Path(wav_path).stat().st_size) + return wav_path + except Exception as exc: + logger.debug("[QQ] pilk .silk conversion failed: %s", exc) + finally: + try: + os.unlink(silk_path) + except OSError: + pass + + return None + + @staticmethod + async def _convert_raw_to_wav(audio_data: bytes, wav_path: str) -> Optional[str]: + """Last resort: try writing audio data as raw PCM 16-bit mono 16kHz WAV. + + This will produce garbage if the data isn't raw PCM, but at least + the ASR engine won't crash — it'll just return empty. + """ + try: + import wave + with wave.open(wav_path, "w") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(16000) + wf.writeframes(audio_data) + return wav_path + except Exception as exc: + logger.debug("[QQ] raw PCM fallback failed: %s", exc) + return None + + @staticmethod + async def _convert_ffmpeg_to_wav(src_path: str, wav_path: str) -> Optional[str]: + """Convert audio file to WAV using ffmpeg.""" + try: + proc = await asyncio.create_subprocess_exec( + "ffmpeg", "-y", "-i", src_path, "-ar", "16000", "-ac", "1", wav_path, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + await asyncio.wait_for(proc.wait(), timeout=30) + if proc.returncode != 0: + stderr = await proc.stderr.read() if proc.stderr else b"" + logger.warning("[QQ] ffmpeg failed for %s: %s", + Path(src_path).name, stderr[:200].decode(errors="replace")) + return None + except (asyncio.TimeoutError, FileNotFoundError) as exc: + logger.warning("[QQ] ffmpeg conversion error: %s", exc) + return None + + if not Path(wav_path).exists() or Path(wav_path).stat().st_size <= 44: + logger.warning("[QQ] ffmpeg produced no/small output for %s", Path(src_path).name) + return None + logger.info("[QQ] ffmpeg converted %s to wav (%d bytes)", + Path(src_path).name, Path(wav_path).stat().st_size) + return wav_path + + def _resolve_stt_config(self) -> Optional[Dict[str, str]]: + """Resolve STT backend configuration from config/environment. + + Priority: + 1. Plugin-specific: ``channels.qqbot.stt`` in config.yaml → ``self.config.extra["stt"]`` + 2. QQ-specific env vars: ``QQ_STT_API_KEY`` / ``QQ_STT_BASE_URL`` / ``QQ_STT_MODEL`` + 3. Return None if nothing is configured (STT will be skipped, QQ built-in ASR still works). + """ + extra = self.config.extra or {} + + # 1. Plugin-specific STT config (matches OpenClaw's channels.qqbot.stt) + stt_cfg = extra.get("stt") + if isinstance(stt_cfg, dict) and stt_cfg.get("enabled") is not False: + base_url = stt_cfg.get("baseUrl") or stt_cfg.get("base_url", "") + api_key = stt_cfg.get("apiKey") or stt_cfg.get("api_key", "") + model = stt_cfg.get("model", "") + if base_url and api_key: + return { + "base_url": base_url.rstrip("/"), + "api_key": api_key, + "model": model or "whisper-1", + } + # Provider-only config: just model name, use default provider + if api_key: + provider = stt_cfg.get("provider", "zai") + # Map provider to base URL + _PROVIDER_BASE_URLS = { + "zai": "https://open.bigmodel.cn/api/coding/paas/v4", + "openai": "https://api.openai.com/v1", + "glm": "https://open.bigmodel.cn/api/coding/paas/v4", + } + base_url = _PROVIDER_BASE_URLS.get(provider, "") + if base_url: + return { + "base_url": base_url, + "api_key": api_key, + "model": model or ("glm-asr" if provider in ("zai", "glm") else "whisper-1"), + } + + # 2. QQ-specific env vars (set by `hermes setup gateway` / `hermes gateway`) + qq_stt_key = os.getenv("QQ_STT_API_KEY", "") + if qq_stt_key: + base_url = os.getenv( + "QQ_STT_BASE_URL", + "https://open.bigmodel.cn/api/coding/paas/v4", + ) + model = os.getenv("QQ_STT_MODEL", "glm-asr") + return { + "base_url": base_url.rstrip("/"), + "api_key": qq_stt_key, + "model": model, + } + + return None + + async def _call_stt(self, wav_path: str) -> Optional[str]: + """Call an OpenAI-compatible STT API to transcribe a wav file. + + Uses the provider configured in ``channels.qqbot.stt`` config, + falling back to QQ's built-in ``asr_refer_text`` if not configured. + Returns None if STT is not configured or the call fails. + """ + stt_cfg = self._resolve_stt_config() + if not stt_cfg: + logger.warning("[QQ] STT not configured (no stt config or QQ_STT_API_KEY)") + return None + + base_url = stt_cfg["base_url"] + api_key = stt_cfg["api_key"] + model = stt_cfg["model"] + + try: + with open(wav_path, "rb") as f: + resp = await self._http_client.post( + f"{base_url}/audio/transcriptions", + headers={"Authorization": f"Bearer {api_key}"}, + files={"file": (Path(wav_path).name, f, "audio/wav")}, + data={"model": model}, + timeout=30.0, + ) + resp.raise_for_status() + result = resp.json() + # Zhipu/GLM format: {"choices": [{"message": {"content": "transcript text"}}]} + choices = result.get("choices", []) + if choices: + content = choices[0].get("message", {}).get("content", "") + if content.strip(): + return content.strip() + # OpenAI/Whisper format: {"text": "transcript text"} + text = result.get("text", "") + if text.strip(): + return text.strip() + return None + except (httpx.HTTPStatusError, IOError) as exc: + logger.warning("[QQ] STT API call failed (model=%s, base=%s): %s", + model, base_url[:50], exc) + return None + + async def _convert_audio_to_wav(self, audio_data: bytes, source_url: str) -> Optional[str]: + """Convert audio bytes to .wav using pilk (SILK) or ffmpeg, caching the result.""" + import tempfile + + # Determine source format from magic bytes or URL + ext = Path(urlparse(source_url).path).suffix.lower() if urlparse(source_url).path else "" + if not ext or ext not in (".silk", ".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"): + ext = self._guess_ext_from_data(audio_data) + + with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp_src: + tmp_src.write(audio_data) + src_path = tmp_src.name + + wav_path = src_path.rsplit(".", 1)[0] + ".wav" + try: + is_silk = ext == ".silk" or self._looks_like_silk(audio_data) + if is_silk: + result = await self._convert_silk_to_wav(src_path, wav_path) + else: + result = await self._convert_ffmpeg_to_wav(src_path, wav_path) + + if not result: + logger.warning("[%s] audio conversion failed for %s (format=%s)", + self.name, source_url[:60], ext) + return cache_document_from_bytes(audio_data, f"qq_voice{ext}") + except Exception: + return cache_document_from_bytes(audio_data, f"qq_voice{ext}") + finally: + try: + os.unlink(src_path) + except OSError: + pass + + # Verify output and cache + try: + wav_data = Path(wav_path).read_bytes() + os.unlink(wav_path) + return cache_document_from_bytes(wav_data, "qq_voice.wav") + except Exception as exc: + logger.debug("[%s] Failed to read converted wav: %s", self.name, exc) + return None + + # ------------------------------------------------------------------ + # Outbound messaging — REST API + # ------------------------------------------------------------------ + + async def _api_request( + self, + method: str, + path: str, + body: Optional[Dict[str, Any]] = None, + timeout: float = DEFAULT_API_TIMEOUT, + ) -> Dict[str, Any]: + """Make an authenticated REST API request to QQ Bot API.""" + if not self._http_client: + raise RuntimeError("HTTP client not initialized — not connected?") + + token = await self._ensure_token() + headers = { + "Authorization": f"QQBot {token}", + "Content-Type": "application/json", + } + + try: + resp = await self._http_client.request( + method, + f"{API_BASE}{path}", + headers=headers, + json=body, + timeout=timeout, + ) + data = resp.json() + if resp.status_code >= 400: + raise RuntimeError( + f"QQ Bot API error [{resp.status_code}] {path}: " + f"{data.get('message', data)}" + ) + return data + except httpx.TimeoutException as exc: + raise RuntimeError(f"QQ Bot API timeout [{path}]: {exc}") from exc + + async def _upload_media( + self, + target_type: str, + target_id: str, + file_type: int, + url: Optional[str] = None, + file_data: Optional[str] = None, + srv_send_msg: bool = False, + file_name: Optional[str] = None, + ) -> Dict[str, Any]: + """Upload media and return file_info.""" + path = f"/v2/users/{target_id}/files" if target_type == "c2c" else f"/v2/groups/{target_id}/files" + + body: Dict[str, Any] = { + "file_type": file_type, + "srv_send_msg": srv_send_msg, + } + if url: + body["url"] = url + elif file_data: + body["file_data"] = file_data + if file_type == MEDIA_TYPE_FILE and file_name: + body["file_name"] = file_name + + # Retry transient upload failures + last_exc = None + for attempt in range(3): + try: + return await self._api_request("POST", path, body, timeout=FILE_UPLOAD_TIMEOUT) + except RuntimeError as exc: + last_exc = exc + err_msg = str(exc) + if any(kw in err_msg for kw in ("400", "401", "Invalid", "timeout", "Timeout")): + raise + if attempt < 2: + await asyncio.sleep(1.5 * (attempt + 1)) + + raise last_exc # type: ignore[misc] + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a text or markdown message to a QQ user or group. + + Applies format_message(), splits long messages via truncate_message(), + and retries transient failures with exponential backoff. + """ + del metadata + + if not self.is_connected: + return SendResult(success=False, error="Not connected") + + if not content or not content.strip(): + return SendResult(success=True) + + formatted = self.format_message(content) + chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) + + last_result = SendResult(success=False, error="No chunks") + for chunk in chunks: + last_result = await self._send_chunk(chat_id, chunk, reply_to) + if not last_result.success: + return last_result + # Only reply_to the first chunk + reply_to = None + return last_result + + async def _send_chunk( + self, chat_id: str, content: str, reply_to: Optional[str] = None, + ) -> SendResult: + """Send a single chunk with retry + exponential backoff.""" + last_exc: Optional[Exception] = None + chat_type = self._guess_chat_type(chat_id) + + for attempt in range(3): + try: + if chat_type == "c2c": + return await self._send_c2c_text(chat_id, content, reply_to) + elif chat_type == "group": + return await self._send_group_text(chat_id, content, reply_to) + elif chat_type == "guild": + return await self._send_guild_text(chat_id, content, reply_to) + else: + return SendResult(success=False, error=f"Unknown chat type for {chat_id}") + except Exception as exc: + last_exc = exc + err = str(exc).lower() + # Permanent errors — don't retry + if any(k in err for k in ("invalid", "forbidden", "not found", "bad request")): + break + # Transient — back off and retry + if attempt < 2: + delay = 1.0 * (2 ** attempt) + logger.warning("[%s] send retry %d/3 after %.1fs: %s", + self.name, attempt + 1, delay, exc) + await asyncio.sleep(delay) + + error_msg = str(last_exc) if last_exc else "Unknown error" + logger.error("[%s] Send failed: %s", self.name, error_msg) + retryable = not any(k in error_msg.lower() + for k in ("invalid", "forbidden", "not found")) + return SendResult(success=False, error=error_msg, retryable=retryable) + + async def _send_c2c_text( + self, openid: str, content: str, reply_to: Optional[str] = None + ) -> SendResult: + """Send text to a C2C user via REST API.""" + msg_seq = self._next_msg_seq(reply_to or openid) + body = self._build_text_body(content, reply_to) + if reply_to: + body["msg_id"] = reply_to + + data = await self._api_request("POST", f"/v2/users/{openid}/messages", body) + msg_id = str(data.get("id", uuid.uuid4().hex[:12])) + return SendResult(success=True, message_id=msg_id, raw_response=data) + + async def _send_group_text( + self, group_openid: str, content: str, reply_to: Optional[str] = None + ) -> SendResult: + """Send text to a group via REST API.""" + msg_seq = self._next_msg_seq(reply_to or group_openid) + body = self._build_text_body(content, reply_to) + if reply_to: + body["msg_id"] = reply_to + + data = await self._api_request("POST", f"/v2/groups/{group_openid}/messages", body) + msg_id = str(data.get("id", uuid.uuid4().hex[:12])) + return SendResult(success=True, message_id=msg_id, raw_response=data) + + async def _send_guild_text( + self, channel_id: str, content: str, reply_to: Optional[str] = None + ) -> SendResult: + """Send text to a guild channel via REST API.""" + body: Dict[str, Any] = {"content": content[:self.MAX_MESSAGE_LENGTH]} + if reply_to: + body["msg_id"] = reply_to + + data = await self._api_request("POST", f"/channels/{channel_id}/messages", body) + msg_id = str(data.get("id", uuid.uuid4().hex[:12])) + return SendResult(success=True, message_id=msg_id, raw_response=data) + + def _build_text_body(self, content: str, reply_to: Optional[str] = None) -> Dict[str, Any]: + """Build the message body for C2C/group text sending.""" + msg_seq = self._next_msg_seq(reply_to or "default") + + if self._markdown_support: + body: Dict[str, Any] = { + "markdown": {"content": content[:self.MAX_MESSAGE_LENGTH]}, + "msg_type": MSG_TYPE_MARKDOWN, + "msg_seq": msg_seq, + } + else: + body = { + "content": content[:self.MAX_MESSAGE_LENGTH], + "msg_type": MSG_TYPE_TEXT, + "msg_seq": msg_seq, + } + + if reply_to: + # For non-markdown mode, add message_reference + if not self._markdown_support: + body["message_reference"] = {"message_id": reply_to} + + return body + + # ------------------------------------------------------------------ + # Native media sending + # ------------------------------------------------------------------ + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an image natively via QQ Bot API upload.""" + del metadata + + result = await self._send_media(chat_id, image_url, MEDIA_TYPE_IMAGE, "image", caption, reply_to) + if result.success or not self._is_url(image_url): + return result + + # Fallback to text URL + logger.warning("[%s] Image send failed, falling back to text: %s", self.name, result.error) + fallback = f"{caption}\n{image_url}" if caption else image_url + return await self.send(chat_id=chat_id, content=fallback, reply_to=reply_to) + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + """Send a local image file natively.""" + del kwargs + return await self._send_media(chat_id, image_path, MEDIA_TYPE_IMAGE, "image", caption, reply_to) + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + """Send a voice message natively.""" + del kwargs + return await self._send_media(chat_id, audio_path, MEDIA_TYPE_VOICE, "voice", caption, reply_to) + + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + """Send a video natively.""" + del kwargs + return await self._send_media(chat_id, video_path, MEDIA_TYPE_VIDEO, "video", caption, reply_to) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + """Send a file/document natively.""" + del kwargs + return await self._send_media(chat_id, file_path, MEDIA_TYPE_FILE, "file", caption, reply_to, + file_name=file_name) + + async def _send_media( + self, + chat_id: str, + media_source: str, + file_type: int, + kind: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + file_name: Optional[str] = None, + ) -> SendResult: + """Upload media and send as a native message.""" + if not self.is_connected: + return SendResult(success=False, error="Not connected") + + try: + # Resolve media source + data, content_type, resolved_name = await self._load_media(media_source, file_name) + + # Route + chat_type = self._guess_chat_type(chat_id) + target_path = f"/v2/users/{chat_id}/files" if chat_type == "c2c" else f"/v2/groups/{chat_id}/files" + + if chat_type == "guild": + # Guild channels don't support native media upload in the same way + # Send as URL fallback + return SendResult(success=False, error="Guild media send not supported via this path") + + # Upload + upload = await self._upload_media( + chat_type, chat_id, file_type, + file_data=data if not self._is_url(media_source) else None, + url=media_source if self._is_url(media_source) else None, + srv_send_msg=False, + file_name=resolved_name if file_type == MEDIA_TYPE_FILE else None, + ) + + file_info = upload.get("file_info") + if not file_info: + return SendResult(success=False, error=f"Upload returned no file_info: {upload}") + + # Send media message + msg_seq = self._next_msg_seq(chat_id) + body: Dict[str, Any] = { + "msg_type": MSG_TYPE_MEDIA, + "media": {"file_info": file_info}, + "msg_seq": msg_seq, + } + if caption: + body["content"] = caption[:self.MAX_MESSAGE_LENGTH] + if reply_to: + body["msg_id"] = reply_to + + send_data = await self._api_request( + "POST", + f"/v2/users/{chat_id}/messages" if chat_type == "c2c" else f"/v2/groups/{chat_id}/messages", + body, + ) + return SendResult( + success=True, + message_id=str(send_data.get("id", uuid.uuid4().hex[:12])), + raw_response=send_data, + ) + except Exception as exc: + logger.error("[%s] Media send failed: %s", self.name, exc) + return SendResult(success=False, error=str(exc)) + + async def _load_media( + self, source: str, file_name: Optional[str] = None + ) -> Tuple[str, str, str]: + """Load media from URL or local path. Returns (base64_or_url, content_type, filename).""" + source = str(source).strip() + if not source: + raise ValueError("Media source is required") + + parsed = urlparse(source) + if parsed.scheme in ("http", "https"): + # For URLs, pass through directly to the upload API + content_type = mimetypes.guess_type(source)[0] or "application/octet-stream" + resolved_name = file_name or Path(parsed.path).name or "media" + return source, content_type, resolved_name + + # Local file — encode as raw base64 for QQ Bot API file_data field. + # The QQ API expects plain base64, NOT a data URI. + local_path = Path(source).expanduser() + if not local_path.is_absolute(): + local_path = (Path.cwd() / local_path).resolve() + + if not local_path.exists() or not local_path.is_file(): + # Guard against placeholder paths like "<path>" that the LLM + # sometimes emits instead of real file paths. + if source.startswith("<") or len(source) < 3: + raise ValueError( + f"Invalid media source (looks like a placeholder): {source!r}" + ) + raise FileNotFoundError(f"Media file not found: {local_path}") + + raw = local_path.read_bytes() + resolved_name = file_name or local_path.name + content_type = mimetypes.guess_type(str(local_path))[0] or "application/octet-stream" + b64 = base64.b64encode(raw).decode("ascii") + return b64, content_type, resolved_name + + # ------------------------------------------------------------------ + # Typing indicator + # ------------------------------------------------------------------ + + async def send_typing(self, chat_id: str, metadata=None) -> None: + """Send an input notify to a C2C user (only supported for C2C).""" + del metadata + + if not self.is_connected: + return + + # Only C2C supports input notify + chat_type = self._guess_chat_type(chat_id) + if chat_type != "c2c": + return + + try: + msg_seq = self._next_msg_seq(chat_id) + body = { + "msg_type": MSG_TYPE_INPUT_NOTIFY, + "input_notify": {"input_type": 1, "input_second": 60}, + "msg_seq": msg_seq, + } + await self._api_request("POST", f"/v2/users/{chat_id}/messages", body) + except Exception as exc: + logger.debug("[%s] send_typing failed: %s", self.name, exc) + + # ------------------------------------------------------------------ + # Format + # ------------------------------------------------------------------ + + def format_message(self, content: str) -> str: + """Format message for QQ. + + When markdown_support is enabled, content is sent as-is (QQ renders it). + When disabled, strip markdown via shared helper (same as BlueBubbles/SMS). + """ + if self._markdown_support: + return content + return strip_markdown(content) + + # ------------------------------------------------------------------ + # Chat info + # ------------------------------------------------------------------ + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Return chat info based on chat type heuristics.""" + chat_type = self._guess_chat_type(chat_id) + return { + "name": chat_id, + "type": "group" if chat_type in ("group", "guild") else "dm", + } + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _is_url(source: str) -> bool: + return urlparse(str(source)).scheme in ("http", "https") + + def _guess_chat_type(self, chat_id: str) -> str: + """Determine chat type from stored inbound metadata, fallback to 'c2c'.""" + if chat_id in self._chat_type_map: + return self._chat_type_map[chat_id] + return "c2c" + + @staticmethod + def _strip_at_mention(content: str) -> str: + """Strip the @bot mention prefix from group message content.""" + # QQ group @-messages may have the bot's QQ/ID as prefix + import re + stripped = re.sub(r'^@\S+\s*', '', content.strip()) + return stripped + + def _is_dm_allowed(self, user_id: str) -> bool: + if self._dm_policy == "disabled": + return False + if self._dm_policy == "allowlist": + return self._entry_matches(self._allow_from, user_id) + return True + + def _is_group_allowed(self, group_id: str, user_id: str) -> bool: + if self._group_policy == "disabled": + return False + if self._group_policy == "allowlist": + return self._entry_matches(self._group_allow_from, group_id) + return True + + @staticmethod + def _entry_matches(entries: List[str], target: str) -> bool: + normalized_target = str(target).strip().lower() + for entry in entries: + normalized = str(entry).strip().lower() + if normalized == "*" or normalized == normalized_target: + return True + return False + + def _parse_qq_timestamp(self, raw: str) -> datetime: + """Parse QQ API timestamp (ISO 8601 string or integer ms). + + The QQ API changed from integer milliseconds to ISO 8601 strings. + This handles both formats gracefully. + """ + if not raw: + return datetime.now(tz=timezone.utc) + try: + return datetime.fromisoformat(raw) + except (ValueError, TypeError): + pass + try: + return datetime.fromtimestamp(int(raw) / 1000, tz=timezone.utc) + except (ValueError, TypeError): + pass + return datetime.now(tz=timezone.utc) + + def _is_duplicate(self, msg_id: str) -> bool: + now = time.time() + if len(self._seen_messages) > DEDUP_MAX_SIZE: + cutoff = now - DEDUP_WINDOW_SECONDS + self._seen_messages = { + key: ts for key, ts in self._seen_messages.items() if ts > cutoff + } + if msg_id in self._seen_messages: + return True + self._seen_messages[msg_id] = now + return False diff --git a/mindcli/_vendor/gateway/platforms/signal.py b/mindcli/_vendor/gateway/platforms/signal.py new file mode 100644 index 0000000..617713a --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/signal.py @@ -0,0 +1,825 @@ +"""Signal messenger platform adapter. + +Connects to a signal-cli daemon running in HTTP mode. +Inbound messages arrive via SSE (Server-Sent Events) streaming. +Outbound messages and actions use JSON-RPC 2.0 over HTTP. + +Based on PR #268 by ibhagwan, rebuilt with bug fixes. + +Requires: + - signal-cli installed and running: signal-cli daemon --http 127.0.0.1:8080 + - SIGNAL_HTTP_URL and SIGNAL_ACCOUNT environment variables set +""" + +import asyncio +import base64 +import json +import logging +import os +import random +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, List, Optional, Any +from urllib.parse import quote, unquote + +import httpx + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, + cache_image_from_bytes, + cache_audio_from_bytes, + cache_document_from_bytes, + cache_image_from_url, +) +from gateway.platforms.helpers import redact_phone + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +SIGNAL_MAX_ATTACHMENT_SIZE = 100 * 1024 * 1024 # 100 MB +MAX_MESSAGE_LENGTH = 8000 # Signal message size limit +TYPING_INTERVAL = 8.0 # seconds between typing indicator refreshes +SSE_RETRY_DELAY_INITIAL = 2.0 +SSE_RETRY_DELAY_MAX = 60.0 +HEALTH_CHECK_INTERVAL = 30.0 # seconds between health checks +HEALTH_CHECK_STALE_THRESHOLD = 120.0 # seconds without SSE activity before concern + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _parse_comma_list(value: str) -> List[str]: + """Split a comma-separated string into a list, stripping whitespace.""" + return [v.strip() for v in value.split(",") if v.strip()] + + +def _guess_extension(data: bytes) -> str: + """Guess file extension from magic bytes.""" + if data[:4] == b"\x89PNG": + return ".png" + if data[:2] == b"\xff\xd8": + return ".jpg" + if data[:4] == b"GIF8": + return ".gif" + if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return ".webp" + if data[:4] == b"%PDF": + return ".pdf" + if len(data) >= 8 and data[4:8] == b"ftyp": + return ".mp4" + if data[:4] == b"OggS": + return ".ogg" + if len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0: + return ".mp3" + if data[:2] == b"PK": + return ".zip" + return ".bin" + + +def _is_image_ext(ext: str) -> bool: + return ext.lower() in (".jpg", ".jpeg", ".png", ".gif", ".webp") + + +def _is_audio_ext(ext: str) -> bool: + return ext.lower() in (".mp3", ".wav", ".ogg", ".m4a", ".aac") + + +_EXT_TO_MIME = { + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", + ".gif": "image/gif", ".webp": "image/webp", + ".ogg": "audio/ogg", ".mp3": "audio/mpeg", ".wav": "audio/wav", + ".m4a": "audio/mp4", ".aac": "audio/aac", + ".mp4": "video/mp4", ".pdf": "application/pdf", ".zip": "application/zip", +} + + +def _ext_to_mime(ext: str) -> str: + """Map file extension to MIME type.""" + return _EXT_TO_MIME.get(ext.lower(), "application/octet-stream") + + +def _render_mentions(text: str, mentions: list) -> str: + """Replace Signal mention placeholders (\\uFFFC) with readable @identifiers. + + Signal encodes @mentions as the Unicode object replacement character + with out-of-band metadata containing the mentioned user's UUID/number. + """ + if not mentions or "\uFFFC" not in text: + return text + # Sort mentions by start position (reverse) to replace from end to start + # so indices don't shift as we replace + sorted_mentions = sorted(mentions, key=lambda m: m.get("start", 0), reverse=True) + for mention in sorted_mentions: + start = mention.get("start", 0) + length = mention.get("length", 1) + # Use the mention's number or UUID as the replacement + identifier = mention.get("number") or mention.get("uuid") or "user" + replacement = f"@{identifier}" + text = text[:start] + replacement + text[start + length:] + return text + + +def check_signal_requirements() -> bool: + """Check if Signal is configured (has URL and account).""" + return bool(os.getenv("SIGNAL_HTTP_URL") and os.getenv("SIGNAL_ACCOUNT")) + + +# --------------------------------------------------------------------------- +# Signal Adapter +# --------------------------------------------------------------------------- + +class SignalAdapter(BasePlatformAdapter): + """Signal messenger adapter using signal-cli HTTP daemon.""" + + platform = Platform.SIGNAL + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.SIGNAL) + + extra = config.extra or {} + self.http_url = extra.get("http_url", "http://127.0.0.1:8080").rstrip("/") + self.account = extra.get("account", "") + self.ignore_stories = extra.get("ignore_stories", True) + + # Parse allowlists — group policy is derived from presence of group allowlist + group_allowed_str = os.getenv("SIGNAL_GROUP_ALLOWED_USERS", "") + self.group_allow_from = set(_parse_comma_list(group_allowed_str)) + + # HTTP client + self.client: Optional[httpx.AsyncClient] = None + + # Background tasks + self._sse_task: Optional[asyncio.Task] = None + self._health_monitor_task: Optional[asyncio.Task] = None + self._typing_tasks: Dict[str, asyncio.Task] = {} + self._running = False + self._last_sse_activity = 0.0 + self._sse_response: Optional[httpx.Response] = None + + # Normalize account for self-message filtering + self._account_normalized = self.account.strip() + + # Track recently sent message timestamps to prevent echo-back loops + # in Note to Self / self-chat mode (mirrors WhatsApp recentlySentIds) + self._recent_sent_timestamps: set = set() + self._max_recent_timestamps = 50 + + logger.info("Signal adapter initialized: url=%s account=%s groups=%s", + self.http_url, redact_phone(self.account), + "enabled" if self.group_allow_from else "disabled") + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def connect(self) -> bool: + """Connect to signal-cli daemon and start SSE listener.""" + if not self.http_url or not self.account: + logger.error("Signal: SIGNAL_HTTP_URL and SIGNAL_ACCOUNT are required") + return False + + # Acquire scoped lock to prevent duplicate Signal listeners for the same phone + try: + if not self._acquire_platform_lock('signal-phone', self.account, 'Signal account'): + return False + except Exception as e: + logger.warning("Signal: Could not acquire phone lock (non-fatal): %s", e) + + self.client = httpx.AsyncClient(timeout=30.0) + + # Health check — verify signal-cli daemon is reachable + try: + resp = await self.client.get(f"{self.http_url}/api/v1/check", timeout=10.0) + if resp.status_code != 200: + logger.error("Signal: health check failed (status %d)", resp.status_code) + return False + except Exception as e: + logger.error("Signal: cannot reach signal-cli at %s: %s", self.http_url, e) + return False + + self._running = True + self._last_sse_activity = time.time() + self._sse_task = asyncio.create_task(self._sse_listener()) + self._health_monitor_task = asyncio.create_task(self._health_monitor()) + + logger.info("Signal: connected to %s", self.http_url) + return True + + async def disconnect(self) -> None: + """Stop SSE listener and clean up.""" + self._running = False + + if self._sse_task: + self._sse_task.cancel() + try: + await self._sse_task + except asyncio.CancelledError: + pass + + if self._health_monitor_task: + self._health_monitor_task.cancel() + try: + await self._health_monitor_task + except asyncio.CancelledError: + pass + + # Cancel all typing tasks + for task in self._typing_tasks.values(): + task.cancel() + self._typing_tasks.clear() + + if self.client: + await self.client.aclose() + self.client = None + + self._release_platform_lock() + + logger.info("Signal: disconnected") + + # ------------------------------------------------------------------ + # SSE Streaming (inbound messages) + # ------------------------------------------------------------------ + + async def _sse_listener(self) -> None: + """Listen for SSE events from signal-cli daemon.""" + url = f"{self.http_url}/api/v1/events?account={quote(self.account, safe='')}" + backoff = SSE_RETRY_DELAY_INITIAL + + while self._running: + try: + logger.debug("Signal SSE: connecting to %s", url) + async with self.client.stream( + "GET", url, + headers={"Accept": "text/event-stream"}, + timeout=None, + ) as response: + self._sse_response = response + backoff = SSE_RETRY_DELAY_INITIAL # Reset on successful connection + self._last_sse_activity = time.time() + logger.info("Signal SSE: connected") + + buffer = "" + async for chunk in response.aiter_text(): + if not self._running: + break + buffer += chunk + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if not line: + continue + # SSE keepalive comments (":") prove the connection + # is alive — update activity so the health monitor + # doesn't report false idle warnings. + if line.startswith(":"): + self._last_sse_activity = time.time() + continue + # Parse SSE data lines + if line.startswith("data:"): + data_str = line[5:].strip() + if not data_str: + continue + self._last_sse_activity = time.time() + try: + data = json.loads(data_str) + await self._handle_envelope(data) + except json.JSONDecodeError: + logger.debug("Signal SSE: invalid JSON: %s", data_str[:100]) + except Exception: + logger.exception("Signal SSE: error handling event") + + except asyncio.CancelledError: + break + except httpx.HTTPError as e: + if self._running: + logger.warning("Signal SSE: HTTP error: %s (reconnecting in %.0fs)", e, backoff) + except Exception as e: + if self._running: + logger.warning("Signal SSE: error: %s (reconnecting in %.0fs)", e, backoff) + + if self._running: + # Add 20% jitter to prevent thundering herd on reconnection + jitter = backoff * 0.2 * random.random() + await asyncio.sleep(backoff + jitter) + backoff = min(backoff * 2, SSE_RETRY_DELAY_MAX) + + self._sse_response = None + + # ------------------------------------------------------------------ + # Health Monitor + # ------------------------------------------------------------------ + + async def _health_monitor(self) -> None: + """Monitor SSE connection health and force reconnect if stale.""" + while self._running: + await asyncio.sleep(HEALTH_CHECK_INTERVAL) + if not self._running: + break + + elapsed = time.time() - self._last_sse_activity + if elapsed > HEALTH_CHECK_STALE_THRESHOLD: + logger.warning("Signal: SSE idle for %.0fs, checking daemon health", elapsed) + try: + resp = await self.client.get( + f"{self.http_url}/api/v1/check", timeout=10.0 + ) + if resp.status_code == 200: + # Daemon is alive but SSE is idle — update activity to + # avoid repeated warnings (connection may just be quiet) + self._last_sse_activity = time.time() + logger.debug("Signal: daemon healthy, SSE idle") + else: + logger.warning("Signal: health check failed (%d), forcing reconnect", resp.status_code) + self._force_reconnect() + except Exception as e: + logger.warning("Signal: health check error: %s, forcing reconnect", e) + self._force_reconnect() + + def _force_reconnect(self) -> None: + """Force SSE reconnection by closing the current response.""" + if self._sse_response and not self._sse_response.is_stream_consumed: + try: + task = asyncio.create_task(self._sse_response.aclose()) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + except Exception: + pass + self._sse_response = None + + # ------------------------------------------------------------------ + # Message Handling + # ------------------------------------------------------------------ + + async def _handle_envelope(self, envelope: dict) -> None: + """Process an incoming signal-cli envelope.""" + # Unwrap nested envelope if present + envelope_data = envelope.get("envelope", envelope) + + # Handle syncMessage: extract "Note to Self" messages (sent to own account) + # while still filtering other sync events (read receipts, typing, etc.) + is_note_to_self = False + if "syncMessage" in envelope_data: + sync_msg = envelope_data.get("syncMessage") + if sync_msg and isinstance(sync_msg, dict): + sent_msg = sync_msg.get("sentMessage") + if sent_msg and isinstance(sent_msg, dict): + dest = sent_msg.get("destinationNumber") or sent_msg.get("destination") + sent_ts = sent_msg.get("timestamp") + if dest == self._account_normalized: + # Check if this is an echo of our own outbound reply + if sent_ts and sent_ts in self._recent_sent_timestamps: + self._recent_sent_timestamps.discard(sent_ts) + return + # Genuine user Note to Self — promote to dataMessage + is_note_to_self = True + envelope_data = {**envelope_data, "dataMessage": sent_msg} + if not is_note_to_self: + return + + # Extract sender info + sender = ( + envelope_data.get("sourceNumber") + or envelope_data.get("sourceUuid") + or envelope_data.get("source") + ) + sender_name = envelope_data.get("sourceName", "") + sender_uuid = envelope_data.get("sourceUuid", "") + + if not sender: + logger.debug("Signal: ignoring envelope with no sender") + return + + # Self-message filtering — prevent reply loops (but allow Note to Self) + if self._account_normalized and sender == self._account_normalized and not is_note_to_self: + return + + # Filter stories + if self.ignore_stories and envelope_data.get("storyMessage"): + return + + # Get data message — also check editMessage (edited messages contain + # their updated dataMessage inside editMessage.dataMessage) + data_message = ( + envelope_data.get("dataMessage") + or (envelope_data.get("editMessage") or {}).get("dataMessage") + ) + if not data_message: + return + + # Check for group message + group_info = data_message.get("groupInfo") + group_id = group_info.get("groupId") if group_info else None + is_group = bool(group_id) + + # Group message filtering — derived from SIGNAL_GROUP_ALLOWED_USERS: + # - No env var set → groups disabled (default safe behavior) + # - Env var set with group IDs → only those groups allowed + # - Env var set with "*" → all groups allowed + # DM auth is fully handled by run.py (_is_user_authorized) + if is_group: + if not self.group_allow_from: + logger.debug("Signal: ignoring group message (no SIGNAL_GROUP_ALLOWED_USERS)") + return + if "*" not in self.group_allow_from and group_id not in self.group_allow_from: + logger.debug("Signal: group %s not in allowlist", group_id[:8] if group_id else "?") + return + + # Build chat info + chat_id = sender if not is_group else f"group:{group_id}" + chat_type = "group" if is_group else "dm" + + # Extract text and render mentions + text = data_message.get("message", "") + mentions = data_message.get("mentions", []) + if text and mentions: + text = _render_mentions(text, mentions) + + # Process attachments + attachments_data = data_message.get("attachments", []) + media_urls = [] + media_types = [] + + if attachments_data and not getattr(self, "ignore_attachments", False): + for att in attachments_data: + att_id = att.get("id") + att_size = att.get("size", 0) + if not att_id: + continue + if att_size > SIGNAL_MAX_ATTACHMENT_SIZE: + logger.warning("Signal: attachment too large (%d bytes), skipping", att_size) + continue + try: + cached_path, ext = await self._fetch_attachment(att_id) + if cached_path: + # Use contentType from Signal if available, else map from extension + content_type = att.get("contentType") or _ext_to_mime(ext) + media_urls.append(cached_path) + media_types.append(content_type) + except Exception: + logger.exception("Signal: failed to fetch attachment %s", att_id) + + # Build session source + source = self.build_source( + chat_id=chat_id, + chat_name=group_info.get("groupName") if group_info else sender_name, + chat_type=chat_type, + user_id=sender, + user_name=sender_name or sender, + user_id_alt=sender_uuid if sender_uuid else None, + chat_id_alt=group_id if is_group else None, + ) + + # Determine message type from media + msg_type = MessageType.TEXT + if media_types: + if any(mt.startswith("audio/") for mt in media_types): + msg_type = MessageType.VOICE + elif any(mt.startswith("image/") for mt in media_types): + msg_type = MessageType.PHOTO + + # Parse timestamp from envelope data (milliseconds since epoch) + ts_ms = envelope_data.get("timestamp", 0) + if ts_ms: + try: + timestamp = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc) + except (ValueError, OSError): + timestamp = datetime.now(tz=timezone.utc) + else: + timestamp = datetime.now(tz=timezone.utc) + + # Build and dispatch event + event = MessageEvent( + source=source, + text=text or "", + message_type=msg_type, + media_urls=media_urls, + media_types=media_types, + timestamp=timestamp, + ) + + logger.debug("Signal: message from %s in %s: %s", + redact_phone(sender), chat_id[:20], (text or "")[:50]) + + await self.handle_message(event) + + # ------------------------------------------------------------------ + # Attachment Handling + # ------------------------------------------------------------------ + + async def _fetch_attachment(self, attachment_id: str) -> tuple: + """Fetch an attachment via JSON-RPC and cache it. Returns (path, ext).""" + result = await self._rpc("getAttachment", { + "account": self.account, + "id": attachment_id, + }) + + if not result: + return None, "" + + # Handle dict response (signal-cli returns {"data": "base64..."}) + if isinstance(result, dict): + result = result.get("data") + if not result: + logger.warning("Signal: attachment response missing 'data' key") + return None, "" + + # Result is base64-encoded file content + raw_data = base64.b64decode(result) + ext = _guess_extension(raw_data) + + if _is_image_ext(ext): + path = cache_image_from_bytes(raw_data, ext) + elif _is_audio_ext(ext): + path = cache_audio_from_bytes(raw_data, ext) + else: + path = cache_document_from_bytes(raw_data, ext) + + return path, ext + + # ------------------------------------------------------------------ + # JSON-RPC Communication + # ------------------------------------------------------------------ + + async def _rpc(self, method: str, params: dict, rpc_id: str = None) -> Any: + """Send a JSON-RPC 2.0 request to signal-cli daemon.""" + if not self.client: + logger.warning("Signal: RPC called but client not connected") + return None + + if rpc_id is None: + rpc_id = f"{method}_{int(time.time() * 1000)}" + + payload = { + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": rpc_id, + } + + try: + resp = await self.client.post( + f"{self.http_url}/api/v1/rpc", + json=payload, + timeout=30.0, + ) + resp.raise_for_status() + data = resp.json() + + if "error" in data: + logger.warning("Signal RPC error (%s): %s", method, data["error"]) + return None + + return data.get("result") + + except Exception as e: + logger.warning("Signal RPC %s failed: %s", method, e) + return None + + # ------------------------------------------------------------------ + # Sending + # ------------------------------------------------------------------ + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a text message.""" + await self._stop_typing_indicator(chat_id) + + params: Dict[str, Any] = { + "account": self.account, + "message": content, + } + + if chat_id.startswith("group:"): + params["groupId"] = chat_id[6:] + else: + params["recipient"] = [chat_id] + + result = await self._rpc("send", params) + + if result is not None: + self._track_sent_timestamp(result) + # Use the timestamp from the RPC result as a pseudo message_id. + # Signal doesn't have real message IDs, but the stream consumer + # needs a truthy value to follow its edit→fallback path correctly. + _msg_id = str(result.get("timestamp", "")) if isinstance(result, dict) else None + return SendResult(success=True, message_id=_msg_id or None) + return SendResult(success=False, error="RPC send failed") + + def _track_sent_timestamp(self, rpc_result) -> None: + """Record outbound message timestamp for echo-back filtering.""" + ts = rpc_result.get("timestamp") if isinstance(rpc_result, dict) else None + if ts: + self._recent_sent_timestamps.add(ts) + if len(self._recent_sent_timestamps) > self._max_recent_timestamps: + self._recent_sent_timestamps.pop() + + async def send_typing(self, chat_id: str, metadata=None) -> None: + """Send a typing indicator.""" + params: Dict[str, Any] = { + "account": self.account, + } + + if chat_id.startswith("group:"): + params["groupId"] = chat_id[6:] + else: + params["recipient"] = [chat_id] + + await self._rpc("sendTyping", params, rpc_id="typing") + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + **kwargs, + ) -> SendResult: + """Send an image. Supports http(s):// and file:// URLs.""" + await self._stop_typing_indicator(chat_id) + + # Resolve image to local path + if image_url.startswith("file://"): + file_path = unquote(image_url[7:]) + else: + # Download remote image to cache + try: + file_path = await cache_image_from_url(image_url) + except Exception as e: + logger.warning("Signal: failed to download image: %s", e) + return SendResult(success=False, error=str(e)) + + if not file_path or not Path(file_path).exists(): + return SendResult(success=False, error="Image file not found") + + # Validate size + file_size = Path(file_path).stat().st_size + if file_size > SIGNAL_MAX_ATTACHMENT_SIZE: + return SendResult(success=False, error=f"Image too large ({file_size} bytes)") + + params: Dict[str, Any] = { + "account": self.account, + "message": caption or "", + "attachments": [file_path], + } + + if chat_id.startswith("group:"): + params["groupId"] = chat_id[6:] + else: + params["recipient"] = [chat_id] + + result = await self._rpc("send", params) + if result is not None: + self._track_sent_timestamp(result) + return SendResult(success=True) + return SendResult(success=False, error="RPC send with attachment failed") + + async def _send_attachment( + self, + chat_id: str, + file_path: str, + media_label: str, + caption: Optional[str] = None, + ) -> SendResult: + """Send any file as a Signal attachment via RPC. + + Shared implementation for send_document, send_image_file, send_voice, + and send_video — avoids duplicating the validation/routing/RPC logic. + """ + await self._stop_typing_indicator(chat_id) + + try: + file_size = Path(file_path).stat().st_size + except FileNotFoundError: + return SendResult(success=False, error=f"{media_label} file not found: {file_path}") + + if file_size > SIGNAL_MAX_ATTACHMENT_SIZE: + return SendResult(success=False, error=f"{media_label} too large ({file_size} bytes)") + + params: Dict[str, Any] = { + "account": self.account, + "message": caption or "", + "attachments": [file_path], + } + + if chat_id.startswith("group:"): + params["groupId"] = chat_id[6:] + else: + params["recipient"] = [chat_id] + + result = await self._rpc("send", params) + if result is not None: + self._track_sent_timestamp(result) + return SendResult(success=True) + return SendResult(success=False, error=f"RPC send {media_label.lower()} failed") + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + filename: Optional[str] = None, + **kwargs, + ) -> SendResult: + """Send a document/file attachment.""" + return await self._send_attachment(chat_id, file_path, "File", caption) + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + """Send a local image file as a native Signal attachment. + + Called by the gateway media delivery flow when MEDIA: tags containing + image paths are extracted from agent responses. + """ + return await self._send_attachment(chat_id, image_path, "Image", caption) + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + """Send an audio file as a Signal attachment. + + Signal does not distinguish voice messages from file attachments at + the API level, so this routes through the same RPC send path. + """ + return await self._send_attachment(chat_id, audio_path, "Audio", caption) + + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + """Send a video file as a Signal attachment.""" + return await self._send_attachment(chat_id, video_path, "Video", caption) + + # ------------------------------------------------------------------ + # Typing Indicators + # ------------------------------------------------------------------ + + async def _stop_typing_indicator(self, chat_id: str) -> None: + """Stop a typing indicator loop for a chat.""" + task = self._typing_tasks.pop(chat_id, None) + if task: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + async def stop_typing(self, chat_id: str) -> None: + """Public interface for stopping typing — called by base adapter's + _keep_typing finally block to clean up platform-level typing tasks.""" + await self._stop_typing_indicator(chat_id) + + # ------------------------------------------------------------------ + # Chat Info + # ------------------------------------------------------------------ + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Get information about a chat/contact.""" + if chat_id.startswith("group:"): + return { + "name": chat_id, + "type": "group", + "chat_id": chat_id, + } + + # Try to resolve contact name + result = await self._rpc("getContact", { + "account": self.account, + "contactAddress": chat_id, + }) + + name = chat_id + if result and isinstance(result, dict): + name = result.get("name") or result.get("profileName") or chat_id + + return { + "name": name, + "type": "dm", + "chat_id": chat_id, + } diff --git a/mindcli/_vendor/gateway/platforms/slack.py b/mindcli/_vendor/gateway/platforms/slack.py new file mode 100644 index 0000000..8f9934c --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/slack.py @@ -0,0 +1,1670 @@ +""" +Slack platform adapter. + +Uses slack-bolt (Python) with Socket Mode for: +- Receiving messages from channels and DMs +- Sending responses back +- Handling slash commands +- Thread support +""" + +import asyncio +import json +import logging +import os +import re +import time +from dataclasses import dataclass, field +from typing import Dict, Optional, Any, Tuple + +try: + from slack_bolt.async_app import AsyncApp + from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + from slack_sdk.web.async_client import AsyncWebClient + SLACK_AVAILABLE = True +except ImportError: + SLACK_AVAILABLE = False + AsyncApp = Any + AsyncSocketModeHandler = Any + AsyncWebClient = Any + +import sys +from pathlib import Path as _Path +sys.path.insert(0, str(_Path(__file__).resolve().parents[2])) + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.helpers import MessageDeduplicator +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, + SUPPORTED_DOCUMENT_TYPES, + safe_url_for_log, + cache_document_from_bytes, +) + + +logger = logging.getLogger(__name__) + + +@dataclass +class _ThreadContextCache: + """Cache entry for fetched thread context.""" + content: str + fetched_at: float = field(default_factory=time.monotonic) + message_count: int = 0 + + +def check_slack_requirements() -> bool: + """Check if Slack dependencies are available.""" + return SLACK_AVAILABLE + + +class SlackAdapter(BasePlatformAdapter): + """ + Slack bot adapter using Socket Mode. + + Requires two tokens: + - SLACK_BOT_TOKEN (xoxb-...) for API calls + - SLACK_APP_TOKEN (xapp-...) for Socket Mode connection + + Features: + - DMs and channel messages (mention-gated in channels) + - Thread support + - File/image/audio attachments + - Slash commands (/hermes) + - Typing indicators (not natively supported by Slack bots) + """ + + MAX_MESSAGE_LENGTH = 39000 # Slack API allows 40,000 chars; leave margin + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.SLACK) + self._app: Optional[AsyncApp] = None + self._handler: Optional[AsyncSocketModeHandler] = None + self._bot_user_id: Optional[str] = None + self._user_name_cache: Dict[str, str] = {} # user_id → display name + self._socket_mode_task: Optional[asyncio.Task] = None + # Multi-workspace support + self._team_clients: Dict[str, AsyncWebClient] = {} # team_id → WebClient + self._team_bot_user_ids: Dict[str, str] = {} # team_id → bot_user_id + self._channel_team: Dict[str, str] = {} # channel_id → team_id + # Dedup cache: prevents duplicate bot responses when Socket Mode + # reconnects redeliver events. + self._dedup = MessageDeduplicator() + # Track pending approval message_ts → resolved flag to prevent + # double-clicks on approval buttons. + self._approval_resolved: Dict[str, bool] = {} + # Track timestamps of messages sent by the bot so we can respond + # to thread replies even without an explicit @mention. + self._bot_message_ts: set = set() + self._BOT_TS_MAX = 5000 # cap to avoid unbounded growth + # Track threads where the bot has been @mentioned — once mentioned, + # respond to ALL subsequent messages in that thread automatically. + self._mentioned_threads: set = set() + self._MENTIONED_THREADS_MAX = 5000 + # Assistant thread metadata keyed by (channel_id, thread_ts). Slack's + # AI Assistant lifecycle events can arrive before/alongside message + # events, and they carry the user/thread identity needed for stable + # session + memory scoping. + self._assistant_threads: Dict[Tuple[str, str], Dict[str, str]] = {} + self._ASSISTANT_THREADS_MAX = 5000 + # Cache for _fetch_thread_context results: cache_key → _ThreadContextCache + self._thread_context_cache: Dict[str, _ThreadContextCache] = {} + self._THREAD_CACHE_TTL = 60.0 + + async def connect(self) -> bool: + """Connect to Slack via Socket Mode.""" + if not SLACK_AVAILABLE: + logger.error( + "[Slack] slack-bolt not installed. Run: pip install slack-bolt", + ) + return False + + raw_token = self.config.token + app_token = os.getenv("SLACK_APP_TOKEN") + + if not raw_token: + logger.error("[Slack] SLACK_BOT_TOKEN not set") + return False + if not app_token: + logger.error("[Slack] SLACK_APP_TOKEN not set") + return False + + # Support comma-separated bot tokens for multi-workspace + bot_tokens = [t.strip() for t in raw_token.split(",") if t.strip()] + + # Also load tokens from OAuth token file + from hermes_constants import get_hermes_home + tokens_file = get_hermes_home() / "slack_tokens.json" + if tokens_file.exists(): + try: + saved = json.loads(tokens_file.read_text(encoding="utf-8")) + for team_id, entry in saved.items(): + tok = entry.get("token", "") if isinstance(entry, dict) else "" + if tok and tok not in bot_tokens: + bot_tokens.append(tok) + team_label = entry.get("team_name", team_id) if isinstance(entry, dict) else team_id + logger.info("[Slack] Loaded saved token for workspace %s", team_label) + except Exception as e: + logger.warning("[Slack] Failed to read %s: %s", tokens_file, e) + + try: + if not self._acquire_platform_lock('slack-app-token', app_token, 'Slack app token'): + return False + + # First token is the primary — used for AsyncApp / Socket Mode + primary_token = bot_tokens[0] + self._app = AsyncApp(token=primary_token) + + # Register each bot token and map team_id → client + for token in bot_tokens: + client = AsyncWebClient(token=token) + auth_response = await client.auth_test() + team_id = auth_response.get("team_id", "") + bot_user_id = auth_response.get("user_id", "") + bot_name = auth_response.get("user", "unknown") + team_name = auth_response.get("team", "unknown") + + self._team_clients[team_id] = client + self._team_bot_user_ids[team_id] = bot_user_id + + # First token sets the primary bot_user_id (backward compat) + if self._bot_user_id is None: + self._bot_user_id = bot_user_id + + logger.info( + "[Slack] Authenticated as @%s in workspace %s (team: %s)", + bot_name, team_name, team_id, + ) + + # Register message event handler + @self._app.event("message") + async def handle_message_event(event, say): + await self._handle_slack_message(event) + + # Acknowledge app_mention events to prevent Bolt 404 errors. + # The "message" handler above already processes @mentions in + # channels, so this is intentionally a no-op to avoid duplicates. + @self._app.event("app_mention") + async def handle_app_mention(event, say): + pass + + @self._app.event("assistant_thread_started") + async def handle_assistant_thread_started(event, say): + await self._handle_assistant_thread_lifecycle_event(event) + + @self._app.event("assistant_thread_context_changed") + async def handle_assistant_thread_context_changed(event, say): + await self._handle_assistant_thread_lifecycle_event(event) + + # Register slash command handler + @self._app.command("/hermes") + async def handle_hermes_command(ack, command): + await ack() + await self._handle_slash_command(command) + + # Register Block Kit action handlers for approval buttons + for _action_id in ( + "hermes_approve_once", + "hermes_approve_session", + "hermes_approve_always", + "hermes_deny", + ): + self._app.action(_action_id)(self._handle_approval_action) + + # Start Socket Mode handler in background + self._handler = AsyncSocketModeHandler(self._app, app_token) + self._socket_mode_task = asyncio.create_task(self._handler.start_async()) + + self._running = True + logger.info( + "[Slack] Socket Mode connected (%d workspace(s))", + len(self._team_clients), + ) + return True + + except Exception as e: # pragma: no cover - defensive logging + logger.error("[Slack] Connection failed: %s", e, exc_info=True) + return False + + async def disconnect(self) -> None: + """Disconnect from Slack.""" + if self._handler: + try: + await self._handler.close_async() + except Exception as e: # pragma: no cover - defensive logging + logger.warning("[Slack] Error while closing Socket Mode handler: %s", e, exc_info=True) + self._running = False + + self._release_platform_lock() + + logger.info("[Slack] Disconnected") + + def _get_client(self, chat_id: str) -> AsyncWebClient: + """Return the workspace-specific WebClient for a channel.""" + team_id = self._channel_team.get(chat_id) + if team_id and team_id in self._team_clients: + return self._team_clients[team_id] + return self._app.client # fallback to primary + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a message to a Slack channel or DM.""" + if not self._app: + return SendResult(success=False, error="Not connected") + + try: + # Convert standard markdown → Slack mrkdwn + formatted = self.format_message(content) + + # Split long messages, preserving code block boundaries + chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) + + thread_ts = self._resolve_thread_ts(reply_to, metadata) + last_result = None + + # reply_broadcast: also post thread replies to the main channel. + # Controlled via platform config: gateway.slack.reply_broadcast + broadcast = self.config.extra.get("reply_broadcast", False) + + for i, chunk in enumerate(chunks): + kwargs = { + "channel": chat_id, + "text": chunk, + "mrkdwn": True, + } + if thread_ts: + kwargs["thread_ts"] = thread_ts + # Only broadcast the first chunk of the first reply + if broadcast and i == 0: + kwargs["reply_broadcast"] = True + + last_result = await self._get_client(chat_id).chat_postMessage(**kwargs) + + # Track the sent message ts so we can auto-respond to thread + # replies without requiring @mention. + sent_ts = last_result.get("ts") if last_result else None + if sent_ts: + self._bot_message_ts.add(sent_ts) + # Also register the thread root so replies-to-my-replies work + if thread_ts: + self._bot_message_ts.add(thread_ts) + if len(self._bot_message_ts) > self._BOT_TS_MAX: + excess = len(self._bot_message_ts) - self._BOT_TS_MAX // 2 + for old_ts in list(self._bot_message_ts)[:excess]: + self._bot_message_ts.discard(old_ts) + + return SendResult( + success=True, + message_id=sent_ts, + raw_response=last_result, + ) + + except Exception as e: # pragma: no cover - defensive logging + logger.error("[Slack] Send error: %s", e, exc_info=True) + return SendResult(success=False, error=str(e)) + + async def edit_message( + self, + chat_id: str, + message_id: str, + content: str, + ) -> SendResult: + """Edit a previously sent Slack message.""" + if not self._app: + return SendResult(success=False, error="Not connected") + try: + formatted = self.format_message(content) + await self._get_client(chat_id).chat_update( + channel=chat_id, + ts=message_id, + text=formatted, + ) + return SendResult(success=True, message_id=message_id) + except Exception as e: # pragma: no cover - defensive logging + logger.error( + "[Slack] Failed to edit message %s in channel %s: %s", + message_id, + chat_id, + e, + exc_info=True, + ) + return SendResult(success=False, error=str(e)) + + async def send_typing(self, chat_id: str, metadata=None) -> None: + """Show a typing/status indicator using assistant.threads.setStatus. + + Displays "is thinking..." next to the bot name in a thread. + Requires the assistant:write or chat:write scope. + Auto-clears when the bot sends a reply to the thread. + """ + if not self._app: + return + + thread_ts = None + if metadata: + thread_ts = metadata.get("thread_id") or metadata.get("thread_ts") + + if not thread_ts: + return # Can only set status in a thread context + + try: + await self._get_client(chat_id).assistant_threads_setStatus( + channel_id=chat_id, + thread_ts=thread_ts, + status="is thinking...", + ) + except Exception as e: + # Silently ignore — may lack assistant:write scope or not be + # in an assistant-enabled context. Falls back to reactions. + logger.debug("[Slack] assistant.threads.setStatus failed: %s", e) + + def _resolve_thread_ts( + self, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> Optional[str]: + """Resolve the correct thread_ts for a Slack API call. + + Prefers metadata thread_id (the thread parent's ts, set by the + gateway) over reply_to (which may be a child message's ts). + + When ``reply_in_thread`` is ``false`` in the platform extra config, + top-level channel messages receive direct channel replies instead of + thread replies. Messages that originate inside an existing thread are + always replied to in-thread to preserve conversation context. + """ + # When reply_in_thread is disabled (default: True for backward compat), + # only thread messages that are already part of an existing thread. + if not self.config.extra.get("reply_in_thread", True): + existing_thread = (metadata or {}).get("thread_id") or (metadata or {}).get("thread_ts") + return existing_thread or None + + if metadata: + if metadata.get("thread_id"): + return metadata["thread_id"] + if metadata.get("thread_ts"): + return metadata["thread_ts"] + return reply_to + + async def _upload_file( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Upload a local file to Slack.""" + if not self._app: + return SendResult(success=False, error="Not connected") + + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + + result = await self._get_client(chat_id).files_upload_v2( + channel=chat_id, + file=file_path, + filename=os.path.basename(file_path), + initial_comment=caption or "", + thread_ts=self._resolve_thread_ts(reply_to, metadata), + ) + return SendResult(success=True, raw_response=result) + + # ----- Markdown → mrkdwn conversion ----- + + def format_message(self, content: str) -> str: + """Convert standard markdown to Slack mrkdwn format. + + Protected regions (code blocks, inline code) are extracted first so + their contents are never modified. Standard markdown constructs + (headers, bold, italic, links) are translated to mrkdwn syntax. + """ + if not content: + return content + + placeholders: dict = {} + counter = [0] + + def _ph(value: str) -> str: + """Stash value behind a placeholder that survives later passes.""" + key = f"\x00SL{counter[0]}\x00" + counter[0] += 1 + placeholders[key] = value + return key + + text = content + + # 1) Protect fenced code blocks (``` ... ```) + text = re.sub( + r'(```(?:[^\n]*\n)?[\s\S]*?```)', + lambda m: _ph(m.group(0)), + text, + ) + + # 2) Protect inline code (`...`) + text = re.sub(r'(`[^`]+`)', lambda m: _ph(m.group(0)), text) + + # 3) Convert markdown links [text](url) → <url|text> + def _convert_markdown_link(m): + label = m.group(1) + url = m.group(2).strip() + if url.startswith('<') and url.endswith('>'): + url = url[1:-1].strip() + return _ph(f'<{url}|{label}>') + + text = re.sub( + r'\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)', + _convert_markdown_link, + text, + ) + + # 4) Protect existing Slack entities/manual links so escaping and later + # formatting passes don't break them. + text = re.sub( + r'(<(?:[@#!]|(?:https?|mailto|tel):)[^>\n]+>)', + lambda m: _ph(m.group(1)), + text, + ) + + # 5) Protect blockquote markers before escaping + text = re.sub(r'^(>+\s)', lambda m: _ph(m.group(0)), text, flags=re.MULTILINE) + + # 6) Escape Slack control characters in remaining plain text. + # Unescape first so already-escaped input doesn't get double-escaped. + text = text.replace('&', '&').replace('<', '<').replace('>', '>') + text = text.replace('&', '&').replace('<', '<').replace('>', '>') + + # 7) Convert headers (## Title) → *Title* (bold) + def _convert_header(m): + inner = m.group(1).strip() + # Strip redundant bold markers inside a header + inner = re.sub(r'\*\*(.+?)\*\*', r'\1', inner) + return _ph(f'*{inner}*') + + text = re.sub( + r'^#{1,6}\s+(.+)$', _convert_header, text, flags=re.MULTILINE + ) + + # 8) Convert bold+italic: ***text*** → *_text_* (Slack bold wrapping italic) + text = re.sub( + r'\*\*\*(.+?)\*\*\*', + lambda m: _ph(f'*_{m.group(1)}_*'), + text, + ) + + # 9) Convert bold: **text** → *text* (Slack bold) + text = re.sub( + r'\*\*(.+?)\*\*', + lambda m: _ph(f'*{m.group(1)}*'), + text, + ) + + # 10) Convert italic: _text_ stays as _text_ (already Slack italic) + # Single *text* → _text_ (Slack italic) + text = re.sub( + r'(?<!\*)\*([^*\n]+)\*(?!\*)', + lambda m: _ph(f'_{m.group(1)}_'), + text, + ) + + # 11) Convert strikethrough: ~~text~~ → ~text~ + text = re.sub( + r'~~(.+?)~~', + lambda m: _ph(f'~{m.group(1)}~'), + text, + ) + + # 12) Blockquotes: > prefix is already protected by step 5 above. + + # 13) Restore placeholders in reverse order + for key in reversed(placeholders): + text = text.replace(key, placeholders[key]) + + return text + + # ----- Reactions ----- + + async def _add_reaction( + self, channel: str, timestamp: str, emoji: str + ) -> bool: + """Add an emoji reaction to a message. Returns True on success.""" + if not self._app: + return False + try: + await self._get_client(channel).reactions_add( + channel=channel, timestamp=timestamp, name=emoji + ) + return True + except Exception as e: + # Don't log as error — may fail if already reacted or missing scope + logger.debug("[Slack] reactions.add failed (%s): %s", emoji, e) + return False + + async def _remove_reaction( + self, channel: str, timestamp: str, emoji: str + ) -> bool: + """Remove an emoji reaction from a message. Returns True on success.""" + if not self._app: + return False + try: + await self._get_client(channel).reactions_remove( + channel=channel, timestamp=timestamp, name=emoji + ) + return True + except Exception as e: + logger.debug("[Slack] reactions.remove failed (%s): %s", emoji, e) + return False + + # ----- User identity resolution ----- + + async def _resolve_user_name(self, user_id: str, chat_id: str = "") -> str: + """Resolve a Slack user ID to a display name, with caching.""" + if not user_id: + return "" + if user_id in self._user_name_cache: + return self._user_name_cache[user_id] + + if not self._app: + return user_id + + try: + client = self._get_client(chat_id) if chat_id else self._app.client + result = await client.users_info(user=user_id) + user = result.get("user", {}) + # Prefer display_name → real_name → user_id + profile = user.get("profile", {}) + name = ( + profile.get("display_name") + or profile.get("real_name") + or user.get("real_name") + or user.get("name") + or user_id + ) + self._user_name_cache[user_id] = name + return name + except Exception as e: + logger.debug("[Slack] users.info failed for %s: %s", user_id, e) + self._user_name_cache[user_id] = user_id + return user_id + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a local image file to Slack by uploading it.""" + try: + return await self._upload_file(chat_id, image_path, caption, reply_to, metadata) + except FileNotFoundError: + return SendResult(success=False, error=f"Image file not found: {image_path}") + except Exception as e: # pragma: no cover - defensive logging + logger.error( + "[%s] Failed to send local Slack image %s: %s", + self.name, + image_path, + e, + exc_info=True, + ) + text = f"🖼️ Image: {image_path}" + if caption: + text = f"{caption}\n{text}" + return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata) + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an image to Slack by uploading the URL as a file.""" + if not self._app: + return SendResult(success=False, error="Not connected") + + from tools.url_safety import is_safe_url + if not is_safe_url(image_url): + logger.warning("[Slack] Blocked unsafe image URL (SSRF protection)") + return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata) + + try: + import httpx + + async def _ssrf_redirect_guard(response): + """Re-check redirect targets so public URLs cannot bounce into private IPs.""" + if response.is_redirect and response.next_request: + redirect_url = str(response.next_request.url) + if not is_safe_url(redirect_url): + raise ValueError("Blocked redirect to private/internal address") + + # Download the image first + async with httpx.AsyncClient( + timeout=30.0, + follow_redirects=True, + event_hooks={"response": [_ssrf_redirect_guard]}, + ) as client: + response = await client.get(image_url) + response.raise_for_status() + + result = await self._get_client(chat_id).files_upload_v2( + channel=chat_id, + content=response.content, + filename="image.png", + initial_comment=caption or "", + thread_ts=self._resolve_thread_ts(reply_to, metadata), + ) + + return SendResult(success=True, raw_response=result) + + except Exception as e: # pragma: no cover - defensive logging + logger.warning( + "[Slack] Failed to upload image from URL %s, falling back to text: %s", + safe_url_for_log(image_url), + e, + exc_info=True, + ) + # Fall back to sending the URL as text + text = f"{caption}\n{image_url}" if caption else image_url + return await self.send(chat_id=chat_id, content=text, reply_to=reply_to) + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + """Send an audio file to Slack.""" + try: + return await self._upload_file(chat_id, audio_path, caption, reply_to, metadata) + except FileNotFoundError: + return SendResult(success=False, error=f"Audio file not found: {audio_path}") + except Exception as e: # pragma: no cover - defensive logging + logger.error( + "[Slack] Failed to send audio file %s: %s", + audio_path, + e, + exc_info=True, + ) + return SendResult(success=False, error=str(e)) + + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a video file to Slack.""" + if not self._app: + return SendResult(success=False, error="Not connected") + + if not os.path.exists(video_path): + return SendResult(success=False, error=f"Video file not found: {video_path}") + + try: + result = await self._get_client(chat_id).files_upload_v2( + channel=chat_id, + file=video_path, + filename=os.path.basename(video_path), + initial_comment=caption or "", + thread_ts=self._resolve_thread_ts(reply_to, metadata), + ) + return SendResult(success=True, raw_response=result) + + except Exception as e: # pragma: no cover - defensive logging + logger.error( + "[%s] Failed to send video %s: %s", + self.name, + video_path, + e, + exc_info=True, + ) + text = f"🎬 Video: {video_path}" + if caption: + text = f"{caption}\n{text}" + return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a document/file attachment to Slack.""" + if not self._app: + return SendResult(success=False, error="Not connected") + + if not os.path.exists(file_path): + return SendResult(success=False, error=f"File not found: {file_path}") + + display_name = file_name or os.path.basename(file_path) + + try: + result = await self._get_client(chat_id).files_upload_v2( + channel=chat_id, + file=file_path, + filename=display_name, + initial_comment=caption or "", + thread_ts=self._resolve_thread_ts(reply_to, metadata), + ) + return SendResult(success=True, raw_response=result) + + except Exception as e: # pragma: no cover - defensive logging + logger.error( + "[%s] Failed to send document %s: %s", + self.name, + file_path, + e, + exc_info=True, + ) + text = f"📎 File: {file_path}" + if caption: + text = f"{caption}\n{text}" + return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata) + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Get information about a Slack channel.""" + if not self._app: + return {"name": chat_id, "type": "unknown"} + + try: + result = await self._get_client(chat_id).conversations_info(channel=chat_id) + channel = result.get("channel", {}) + is_dm = channel.get("is_im", False) + return { + "name": channel.get("name", chat_id), + "type": "dm" if is_dm else "group", + } + except Exception as e: # pragma: no cover - defensive logging + logger.error( + "[Slack] Failed to fetch chat info for %s: %s", + chat_id, + e, + exc_info=True, + ) + return {"name": chat_id, "type": "unknown"} + + # ----- Internal handlers ----- + + def _assistant_thread_key(self, channel_id: str, thread_ts: str) -> Optional[Tuple[str, str]]: + """Return a stable cache key for Slack assistant thread metadata.""" + if not channel_id or not thread_ts: + return None + return (str(channel_id), str(thread_ts)) + + def _extract_assistant_thread_metadata(self, event: dict) -> Dict[str, str]: + """Extract Slack Assistant thread identity data from an event payload.""" + assistant_thread = event.get("assistant_thread") or {} + context = assistant_thread.get("context") or event.get("context") or {} + + channel_id = ( + assistant_thread.get("channel_id") + or event.get("channel") + or context.get("channel_id") + or "" + ) + thread_ts = ( + assistant_thread.get("thread_ts") + or event.get("thread_ts") + or event.get("message_ts") + or "" + ) + user_id = ( + assistant_thread.get("user_id") + or event.get("user") + or context.get("user_id") + or "" + ) + team_id = ( + event.get("team") + or event.get("team_id") + or assistant_thread.get("team_id") + or "" + ) + context_channel_id = context.get("channel_id") or "" + + return { + "channel_id": str(channel_id) if channel_id else "", + "thread_ts": str(thread_ts) if thread_ts else "", + "user_id": str(user_id) if user_id else "", + "team_id": str(team_id) if team_id else "", + "context_channel_id": str(context_channel_id) if context_channel_id else "", + } + + def _cache_assistant_thread_metadata(self, metadata: Dict[str, str]) -> None: + """Remember assistant thread identity data for later message events.""" + channel_id = metadata.get("channel_id", "") + thread_ts = metadata.get("thread_ts", "") + key = self._assistant_thread_key(channel_id, thread_ts) + if not key: + return + + existing = self._assistant_threads.get(key, {}) + merged = dict(existing) + merged.update({k: v for k, v in metadata.items() if v}) + self._assistant_threads[key] = merged + + # Evict oldest entries when the cache exceeds the limit + if len(self._assistant_threads) > self._ASSISTANT_THREADS_MAX: + excess = len(self._assistant_threads) - self._ASSISTANT_THREADS_MAX // 2 + for old_key in list(self._assistant_threads)[:excess]: + del self._assistant_threads[old_key] + + team_id = merged.get("team_id", "") + if team_id and channel_id: + self._channel_team[channel_id] = team_id + + def _lookup_assistant_thread_metadata( + self, + event: dict, + channel_id: str = "", + thread_ts: str = "", + ) -> Dict[str, str]: + """Load cached assistant-thread metadata that matches the current event.""" + metadata = self._extract_assistant_thread_metadata(event) + if channel_id and not metadata.get("channel_id"): + metadata["channel_id"] = channel_id + if thread_ts and not metadata.get("thread_ts"): + metadata["thread_ts"] = thread_ts + + key = self._assistant_thread_key( + metadata.get("channel_id", ""), + metadata.get("thread_ts", ""), + ) + cached = self._assistant_threads.get(key, {}) if key else {} + if cached: + merged = dict(cached) + merged.update({k: v for k, v in metadata.items() if v}) + return merged + return metadata + + def _seed_assistant_thread_session(self, metadata: Dict[str, str]) -> None: + """Prime the session store so assistant threads get stable user scoping.""" + session_store = getattr(self, "_session_store", None) + if not session_store: + return + + channel_id = metadata.get("channel_id", "") + thread_ts = metadata.get("thread_ts", "") + user_id = metadata.get("user_id", "") + if not channel_id or not thread_ts or not user_id: + return + + source = self.build_source( + chat_id=channel_id, + chat_name=channel_id, + chat_type="dm", + user_id=user_id, + thread_id=thread_ts, + chat_topic=metadata.get("context_channel_id") or None, + ) + + try: + session_store.get_or_create_session(source) + except Exception: + logger.debug( + "[Slack] Failed to seed assistant thread session for %s/%s", + channel_id, + thread_ts, + exc_info=True, + ) + + async def _handle_assistant_thread_lifecycle_event(self, event: dict) -> None: + """Handle Slack Assistant lifecycle events that carry user/thread identity.""" + metadata = self._extract_assistant_thread_metadata(event) + self._cache_assistant_thread_metadata(metadata) + self._seed_assistant_thread_session(metadata) + + async def _handle_slack_message(self, event: dict) -> None: + """Handle an incoming Slack message event.""" + # Dedup: Slack Socket Mode can redeliver events after reconnects (#4777) + event_ts = event.get("ts", "") + if event_ts and self._dedup.is_duplicate(event_ts): + return + + # Bot message filtering (SLACK_ALLOW_BOTS / config allow_bots): + # "none" — ignore all bot messages (default, backward-compatible) + # "mentions" — accept bot messages only when they @mention us + # "all" — accept all bot messages (except our own) + if event.get("bot_id") or event.get("subtype") == "bot_message": + allow_bots = self.config.extra.get("allow_bots", "") + if not allow_bots: + allow_bots = os.getenv("SLACK_ALLOW_BOTS", "none") + allow_bots = str(allow_bots).lower().strip() + if allow_bots == "none": + return + elif allow_bots == "mentions": + text_check = event.get("text", "") + if self._bot_user_id and f"<@{self._bot_user_id}>" not in text_check: + return + # "all" falls through to process the message + # Always ignore our own messages to prevent echo loops + msg_user = event.get("user", "") + if msg_user and self._bot_user_id and msg_user == self._bot_user_id: + return + + # Ignore message edits and deletions + subtype = event.get("subtype") + if subtype in ("message_changed", "message_deleted"): + return + + text = event.get("text", "") + channel_id = event.get("channel", "") + ts = event.get("ts", "") + assistant_meta = self._lookup_assistant_thread_metadata( + event, + channel_id=channel_id, + thread_ts=event.get("thread_ts", ""), + ) + user_id = event.get("user") or assistant_meta.get("user_id", "") + if not channel_id: + channel_id = assistant_meta.get("channel_id", "") + team_id = ( + event.get("team") + or event.get("team_id") + or assistant_meta.get("team_id", "") + ) + + # Track which workspace owns this channel + if team_id and channel_id: + self._channel_team[channel_id] = team_id + + # Determine if this is a DM or channel message + channel_type = event.get("channel_type", "") + if not channel_type and channel_id.startswith("D"): + channel_type = "im" + is_dm = channel_type in ("im", "mpim") # Both 1:1 and group DMs + + # Build thread_ts for session keying. + # In channels: fall back to ts so each top-level @mention starts a + # new thread/session (the bot always replies in a thread). + # In DMs: only use the real thread_ts — top-level DMs should share + # one continuous session, threaded DMs get their own session. + if is_dm: + thread_ts = event.get("thread_ts") or assistant_meta.get("thread_ts") # None for top-level DMs + else: + thread_ts = event.get("thread_ts") or ts # ts fallback for channels + + # In channels, respond if: + # 0. Channel is in free_response_channels, OR require_mention is + # disabled — always process regardless of mention. + # 1. The bot is @mentioned in this message, OR + # 2. The message is a reply in a thread the bot started/participated in, OR + # 3. The message is in a thread where the bot was previously @mentioned, OR + # 4. There's an existing session for this thread (survives restarts) + bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id) + is_mentioned = bot_uid and f"<@{bot_uid}>" in text + event_thread_ts = event.get("thread_ts") + is_thread_reply = bool(event_thread_ts and event_thread_ts != ts) + + if not is_dm and bot_uid: + if channel_id in self._slack_free_response_channels(): + pass # Free-response channel — always process + elif not self._slack_require_mention(): + pass # Mention requirement disabled globally for Slack + elif not is_mentioned: + reply_to_bot_thread = ( + is_thread_reply and event_thread_ts in self._bot_message_ts + ) + in_mentioned_thread = ( + event_thread_ts is not None + and event_thread_ts in self._mentioned_threads + ) + has_session = ( + is_thread_reply + and self._has_active_session_for_thread( + channel_id=channel_id, + thread_ts=event_thread_ts, + user_id=user_id, + ) + ) + if not reply_to_bot_thread and not in_mentioned_thread and not has_session: + return + + if is_mentioned: + # Strip the bot mention from the text + text = text.replace(f"<@{bot_uid}>", "").strip() + # Register this thread so all future messages auto-trigger the bot + if event_thread_ts: + self._mentioned_threads.add(event_thread_ts) + if len(self._mentioned_threads) > self._MENTIONED_THREADS_MAX: + to_remove = list(self._mentioned_threads)[:self._MENTIONED_THREADS_MAX // 2] + for t in to_remove: + self._mentioned_threads.discard(t) + + # When entering a thread for the first time (no existing session), + # fetch thread context so the agent understands the conversation. + if is_thread_reply and not self._has_active_session_for_thread( + channel_id=channel_id, + thread_ts=event_thread_ts, + user_id=user_id, + ): + thread_context = await self._fetch_thread_context( + channel_id=channel_id, + thread_ts=event_thread_ts, + current_ts=ts, + team_id=team_id, + ) + if thread_context: + text = thread_context + text + + # Determine message type + msg_type = MessageType.TEXT + if text.startswith("/"): + msg_type = MessageType.COMMAND + + # Handle file attachments + media_urls = [] + media_types = [] + files = event.get("files", []) + for f in files: + mimetype = f.get("mimetype", "unknown") + url = f.get("url_private_download") or f.get("url_private", "") + if mimetype.startswith("image/") and url: + try: + ext = "." + mimetype.split("/")[-1].split(";")[0] + if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"): + ext = ".jpg" + # Slack private URLs require the bot token as auth header + cached = await self._download_slack_file(url, ext, team_id=team_id) + media_urls.append(cached) + media_types.append(mimetype) + msg_type = MessageType.PHOTO + except Exception as e: # pragma: no cover - defensive logging + logger.warning("[Slack] Failed to cache image from %s: %s", url, e, exc_info=True) + elif mimetype.startswith("audio/") and url: + try: + ext = "." + mimetype.split("/")[-1].split(";")[0] + if ext not in (".ogg", ".mp3", ".wav", ".webm", ".m4a"): + ext = ".ogg" + cached = await self._download_slack_file(url, ext, audio=True, team_id=team_id) + media_urls.append(cached) + media_types.append(mimetype) + msg_type = MessageType.VOICE + except Exception as e: # pragma: no cover - defensive logging + logger.warning("[Slack] Failed to cache audio from %s: %s", url, e, exc_info=True) + elif url: + # Try to handle as a document attachment + try: + original_filename = f.get("name", "") + ext = "" + if original_filename: + _, ext = os.path.splitext(original_filename) + ext = ext.lower() + + # Fallback: reverse-lookup from MIME type + if not ext and mimetype: + mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()} + ext = mime_to_ext.get(mimetype, "") + + if ext not in SUPPORTED_DOCUMENT_TYPES: + continue # Skip unsupported file types silently + + # Check file size (Slack limit: 20 MB for bots) + file_size = f.get("size", 0) + MAX_DOC_BYTES = 20 * 1024 * 1024 + if not file_size or file_size > MAX_DOC_BYTES: + logger.warning("[Slack] Document too large or unknown size: %s", file_size) + continue + + # Download and cache + raw_bytes = await self._download_slack_file_bytes(url, team_id=team_id) + cached_path = cache_document_from_bytes( + raw_bytes, original_filename or f"document{ext}" + ) + doc_mime = SUPPORTED_DOCUMENT_TYPES[ext] + media_urls.append(cached_path) + media_types.append(doc_mime) + msg_type = MessageType.DOCUMENT + logger.debug("[Slack] Cached user document: %s", cached_path) + + # Inject text content for .txt/.md files (capped at 100 KB) + MAX_TEXT_INJECT_BYTES = 100 * 1024 + if ext in (".md", ".txt") and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: + try: + text_content = raw_bytes.decode("utf-8") + display_name = original_filename or f"document{ext}" + display_name = re.sub(r'[^\w.\- ]', '_', display_name) + injection = f"[Content of {display_name}]:\n{text_content}" + if text: + text = f"{injection}\n\n{text}" + else: + text = injection + except UnicodeDecodeError: + pass # Binary content, skip injection + + except Exception as e: # pragma: no cover - defensive logging + logger.warning("[Slack] Failed to cache document from %s: %s", url, e, exc_info=True) + + # Resolve user display name (cached after first lookup) + user_name = await self._resolve_user_name(user_id, chat_id=channel_id) + + # Build source + source = self.build_source( + chat_id=channel_id, + chat_name=channel_id, # Will be resolved later if needed + chat_type="dm" if is_dm else "group", + user_id=user_id, + user_name=user_name, + thread_id=thread_ts, + ) + + msg_event = MessageEvent( + text=text, + message_type=msg_type, + source=source, + raw_message=event, + message_id=ts, + media_urls=media_urls, + media_types=media_types, + reply_to_message_id=thread_ts if thread_ts != ts else None, + ) + + # Only react when bot is directly addressed (DM or @mention). + # In listen-all channels (require_mention=false), reacting to every + # casual message would be noisy. + _should_react = is_dm or is_mentioned + + if _should_react: + await self._add_reaction(channel_id, ts, "eyes") + + await self.handle_message(msg_event) + + if _should_react: + await self._remove_reaction(channel_id, ts, "eyes") + await self._add_reaction(channel_id, ts, "white_check_mark") + + # ----- Approval button support (Block Kit) ----- + + async def send_exec_approval( + self, chat_id: str, command: str, session_key: str, + description: str = "dangerous command", + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a Block Kit approval prompt with interactive buttons. + + The buttons call ``resolve_gateway_approval()`` to unblock the waiting + agent thread — same mechanism as the text ``/approve`` flow. + """ + if not self._app: + return SendResult(success=False, error="Not connected") + + try: + cmd_preview = command[:2900] + "..." if len(command) > 2900 else command + thread_ts = self._resolve_thread_ts(None, metadata) + + blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + f":warning: *Command Approval Required*\n" + f"```{cmd_preview}```\n" + f"Reason: {description}" + ), + }, + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Allow Once"}, + "style": "primary", + "action_id": "hermes_approve_once", + "value": session_key, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Allow Session"}, + "action_id": "hermes_approve_session", + "value": session_key, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Always Allow"}, + "action_id": "hermes_approve_always", + "value": session_key, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "Deny"}, + "style": "danger", + "action_id": "hermes_deny", + "value": session_key, + }, + ], + }, + ] + + kwargs: Dict[str, Any] = { + "channel": chat_id, + "text": f"⚠️ Command approval required: {cmd_preview[:100]}", + "blocks": blocks, + } + if thread_ts: + kwargs["thread_ts"] = thread_ts + + result = await self._get_client(chat_id).chat_postMessage(**kwargs) + msg_ts = result.get("ts", "") + if msg_ts: + self._approval_resolved[msg_ts] = False + + return SendResult(success=True, message_id=msg_ts, raw_response=result) + except Exception as e: + logger.error("[Slack] send_exec_approval failed: %s", e, exc_info=True) + return SendResult(success=False, error=str(e)) + + async def _handle_approval_action(self, ack, body, action) -> None: + """Handle an approval button click from Block Kit.""" + await ack() + + action_id = action.get("action_id", "") + session_key = action.get("value", "") + message = body.get("message", {}) + msg_ts = message.get("ts", "") + channel_id = body.get("channel", {}).get("id", "") + user_name = body.get("user", {}).get("name", "unknown") + user_id = body.get("user", {}).get("id", "") + + # Only authorized users may click approval buttons. Button clicks + # bypass the normal message auth flow in gateway/run.py, so we must + # check here as well. + allowed_csv = os.getenv("SLACK_ALLOWED_USERS", "").strip() + if allowed_csv: + allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()} + if "*" not in allowed_ids and user_id not in allowed_ids: + logger.warning( + "[Slack] Unauthorized approval click by %s (%s) — ignoring", + user_name, user_id, + ) + return + + # Map action_id to approval choice + choice_map = { + "hermes_approve_once": "once", + "hermes_approve_session": "session", + "hermes_approve_always": "always", + "hermes_deny": "deny", + } + choice = choice_map.get(action_id, "deny") + + # Prevent double-clicks — atomic pop; first caller gets False, others get True (default) + if self._approval_resolved.pop(msg_ts, True): + return + + # Update the message to show the decision and remove buttons + label_map = { + "once": f"✅ Approved once by {user_name}", + "session": f"✅ Approved for session by {user_name}", + "always": f"✅ Approved permanently by {user_name}", + "deny": f"❌ Denied by {user_name}", + } + decision_text = label_map.get(choice, f"Resolved by {user_name}") + + # Get original text from the section block + original_text = "" + for block in message.get("blocks", []): + if block.get("type") == "section": + original_text = block.get("text", {}).get("text", "") + break + + updated_blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": original_text or "Command approval request", + }, + }, + { + "type": "context", + "elements": [ + {"type": "mrkdwn", "text": decision_text}, + ], + }, + ] + + try: + await self._get_client(channel_id).chat_update( + channel=channel_id, + ts=msg_ts, + text=decision_text, + blocks=updated_blocks, + ) + except Exception as e: + logger.warning("[Slack] Failed to update approval message: %s", e) + + # Resolve the approval — this unblocks the agent thread + try: + from tools.approval import resolve_gateway_approval + count = resolve_gateway_approval(session_key, choice) + logger.info( + "Slack button resolved %d approval(s) for session %s (choice=%s, user=%s)", + count, session_key, choice, user_name, + ) + except Exception as exc: + logger.error("Failed to resolve gateway approval from Slack button: %s", exc) + + # (approval state already consumed by atomic pop above) + + # ----- Thread context fetching ----- + + async def _fetch_thread_context( + self, channel_id: str, thread_ts: str, current_ts: str, + team_id: str = "", limit: int = 30, + ) -> str: + """Fetch recent thread messages to provide context when the bot is + mentioned mid-thread for the first time. + + This method is only called when there is NO active session for the + thread (guarded at the call site by _has_active_session_for_thread). + That guard ensures thread messages are prepended only on the very + first turn — after that the session history already holds them, so + there is no duplication across subsequent turns. + + Results are cached for _THREAD_CACHE_TTL seconds per thread to avoid + hammering conversations.replies (Tier 3, ~50 req/min). + + Returns a formatted string with prior thread history, or empty string + on failure or if the thread has no prior messages. + """ + cache_key = f"{channel_id}:{thread_ts}" + now = time.monotonic() + cached = self._thread_context_cache.get(cache_key) + if cached and (now - cached.fetched_at) < self._THREAD_CACHE_TTL: + return cached.content + + try: + client = self._get_client(channel_id) + + # Retry with exponential backoff for Tier-3 rate limits (429). + result = None + for attempt in range(3): + try: + result = await client.conversations_replies( + channel=channel_id, + ts=thread_ts, + limit=limit + 1, # +1 because it includes the current message + inclusive=True, + ) + break + except Exception as exc: + # Check for rate-limit error from slack_sdk + err_str = str(exc).lower() + is_rate_limit = ( + "ratelimited" in err_str + or "429" in err_str + or "rate_limited" in err_str + ) + if is_rate_limit and attempt < 2: + retry_after = 1.0 * (2 ** attempt) # 1s, 2s + logger.warning( + "[Slack] conversations.replies rate limited; retrying in %.1fs (attempt %d/3)", + retry_after, attempt + 1, + ) + await asyncio.sleep(retry_after) + continue + raise + + if result is None: + return "" + + messages = result.get("messages", []) + if not messages: + return "" + + bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id) + context_parts = [] + for msg in messages: + msg_ts = msg.get("ts", "") + # Exclude the current triggering message — it will be delivered + # as the user message itself, so including it here would duplicate it. + if msg_ts == current_ts: + continue + # Exclude our own bot messages to avoid circular context. + if msg.get("bot_id") or msg.get("subtype") == "bot_message": + continue + + msg_text = msg.get("text", "").strip() + if not msg_text: + continue + + # Strip bot mentions from context messages + if bot_uid: + msg_text = msg_text.replace(f"<@{bot_uid}>", "").strip() + + msg_user = msg.get("user", "unknown") + is_parent = msg_ts == thread_ts + prefix = "[thread parent] " if is_parent else "" + name = await self._resolve_user_name(msg_user, chat_id=channel_id) + context_parts.append(f"{prefix}{name}: {msg_text}") + + content = "" + if context_parts: + content = ( + "[Thread context — prior messages in this thread (not yet in conversation history):]\n" + + "\n".join(context_parts) + + "\n[End of thread context]\n\n" + ) + + self._thread_context_cache[cache_key] = _ThreadContextCache( + content=content, + fetched_at=now, + message_count=len(context_parts), + ) + return content + + except Exception as e: + logger.warning("[Slack] Failed to fetch thread context: %s", e) + return "" + + async def _handle_slash_command(self, command: dict) -> None: + """Handle /hermes slash command.""" + text = command.get("text", "").strip() + user_id = command.get("user_id", "") + channel_id = command.get("channel_id", "") + team_id = command.get("team_id", "") + + # Track which workspace owns this channel + if team_id and channel_id: + self._channel_team[channel_id] = team_id + + # Map subcommands to gateway commands — derived from central registry. + # Also keep "compact" as a Slack-specific alias for /compress. + from hermes_cli.commands import slack_subcommand_map + subcommand_map = slack_subcommand_map() + subcommand_map["compact"] = "/compress" + first_word = text.split()[0] if text else "" + if first_word in subcommand_map: + # Preserve arguments after the subcommand + rest = text[len(first_word):].strip() + text = f"{subcommand_map[first_word]} {rest}".strip() if rest else subcommand_map[first_word] + elif text: + pass # Treat as a regular question + else: + text = "/help" + + source = self.build_source( + chat_id=channel_id, + chat_type="dm", # Slash commands are always in DM-like context + user_id=user_id, + ) + + event = MessageEvent( + text=text, + message_type=MessageType.COMMAND if text.startswith("/") else MessageType.TEXT, + source=source, + raw_message=command, + ) + + await self.handle_message(event) + + def _has_active_session_for_thread( + self, + channel_id: str, + thread_ts: str, + user_id: str, + ) -> bool: + """Check if there's an active session for a thread. + + Used to determine if thread replies without @mentions should be + processed (they should if there's an active session). + + Uses ``build_session_key()`` as the single source of truth for key + construction — avoids the bug where manual key building didn't + respect ``thread_sessions_per_user`` and ``group_sessions_per_user`` + settings correctly. + """ + session_store = getattr(self, "_session_store", None) + if not session_store: + return False + + try: + from gateway.session import SessionSource, build_session_key + + source = SessionSource( + platform=Platform.SLACK, + chat_id=channel_id, + chat_type="group", + user_id=user_id, + thread_id=thread_ts, + ) + + # Read session isolation settings from the store's config + store_cfg = getattr(session_store, "config", None) + gspu = getattr(store_cfg, "group_sessions_per_user", True) if store_cfg else True + tspu = getattr(store_cfg, "thread_sessions_per_user", False) if store_cfg else False + + session_key = build_session_key( + source, + group_sessions_per_user=gspu, + thread_sessions_per_user=tspu, + ) + + session_store._ensure_loaded() + return session_key in session_store._entries + except Exception: + return False + + async def _download_slack_file(self, url: str, ext: str, audio: bool = False, team_id: str = "") -> str: + """Download a Slack file using the bot token for auth, with retry.""" + import asyncio + import httpx + + bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token + last_exc = None + + async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: + for attempt in range(3): + try: + response = await client.get( + url, + headers={"Authorization": f"Bearer {bot_token}"}, + ) + response.raise_for_status() + + # Slack may return an HTML sign-in/redirect page + # instead of actual media bytes (e.g. expired token, + # restricted file access). Detect this early so we + # don't cache bogus data and confuse downstream tools. + ct = response.headers.get("content-type", "") + if "text/html" in ct: + raise ValueError( + "Slack returned HTML instead of media " + f"(content-type: {ct}); " + "check bot token scopes and file permissions" + ) + + if audio: + from gateway.platforms.base import cache_audio_from_bytes + return cache_audio_from_bytes(response.content, ext) + else: + from gateway.platforms.base import cache_image_from_bytes + return cache_image_from_bytes(response.content, ext) + except (httpx.TimeoutException, httpx.HTTPStatusError) as exc: + last_exc = exc + if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 429: + raise + if attempt < 2: + logger.debug("Slack file download retry %d/2 for %s: %s", + attempt + 1, url[:80], exc) + await asyncio.sleep(1.5 * (attempt + 1)) + continue + raise + raise last_exc + + async def _download_slack_file_bytes(self, url: str, team_id: str = "") -> bytes: + """Download a Slack file and return raw bytes, with retry.""" + import asyncio + import httpx + + bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token + last_exc = None + + async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: + for attempt in range(3): + try: + response = await client.get( + url, + headers={"Authorization": f"Bearer {bot_token}"}, + ) + response.raise_for_status() + return response.content + except (httpx.TimeoutException, httpx.HTTPStatusError) as exc: + last_exc = exc + if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 429: + raise + if attempt < 2: + logger.debug("Slack file download retry %d/2 for %s: %s", + attempt + 1, url[:80], exc) + await asyncio.sleep(1.5 * (attempt + 1)) + continue + raise + raise last_exc + + # ── Channel mention gating ───────────────────────────────────────────── + + def _slack_require_mention(self) -> bool: + """Return whether channel messages require an explicit bot mention. + + Uses explicit-false parsing (like Discord/Matrix) rather than + truthy parsing, since the safe default is True (gating on). + Unrecognised or empty values keep gating enabled. + """ + configured = self.config.extra.get("require_mention") + if configured is not None: + if isinstance(configured, str): + return configured.lower() not in ("false", "0", "no", "off") + return bool(configured) + return os.getenv("SLACK_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off") + + def _slack_free_response_channels(self) -> set: + """Return channel IDs where no @mention is required.""" + raw = self.config.extra.get("free_response_channels") + if raw is None: + raw = os.getenv("SLACK_FREE_RESPONSE_CHANNELS", "") + if isinstance(raw, list): + return {str(part).strip() for part in raw if str(part).strip()} + if isinstance(raw, str) and raw.strip(): + return {part.strip() for part in raw.split(",") if part.strip()} + return set() diff --git a/mindcli/_vendor/gateway/platforms/sms.py b/mindcli/_vendor/gateway/platforms/sms.py new file mode 100644 index 0000000..161949d --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/sms.py @@ -0,0 +1,373 @@ +"""SMS (Twilio) platform adapter. + +Connects to the Twilio REST API for outbound SMS and runs an aiohttp +webhook server to receive inbound messages. + +Shares credentials with the optional telephony skill — same env vars: + - TWILIO_ACCOUNT_SID + - TWILIO_AUTH_TOKEN + - TWILIO_PHONE_NUMBER (E.164 from-number, e.g. +15551234567) + +Gateway-specific env vars: + - SMS_WEBHOOK_PORT (default 8080) + - SMS_WEBHOOK_HOST (default 0.0.0.0) + - SMS_WEBHOOK_URL (public URL for Twilio signature validation — required) + - SMS_INSECURE_NO_SIGNATURE (true to disable signature validation — dev only) + - SMS_ALLOWED_USERS (comma-separated E.164 phone numbers) + - SMS_ALLOW_ALL_USERS (true/false) + - SMS_HOME_CHANNEL (phone number for cron delivery) +""" + +import asyncio +import base64 +import hashlib +import hmac +import logging +import os +import urllib.parse +from typing import Any, Dict, Optional + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, +) +from gateway.platforms.helpers import redact_phone, strip_markdown + +logger = logging.getLogger(__name__) + +TWILIO_API_BASE = "https://api.twilio.com/2010-04-01/Accounts" +MAX_SMS_LENGTH = 1600 # ~10 SMS segments +DEFAULT_WEBHOOK_PORT = 8080 +DEFAULT_WEBHOOK_HOST = "0.0.0.0" + + +def check_sms_requirements() -> bool: + """Check if SMS adapter dependencies are available.""" + try: + import aiohttp # noqa: F401 + except ImportError: + return False + return bool(os.getenv("TWILIO_ACCOUNT_SID") and os.getenv("TWILIO_AUTH_TOKEN")) + + +class SmsAdapter(BasePlatformAdapter): + """ + Twilio SMS <-> Hermes gateway adapter. + + Each inbound phone number gets its own Hermes session (multi-tenant). + Replies are always sent from the configured TWILIO_PHONE_NUMBER. + """ + + MAX_MESSAGE_LENGTH = MAX_SMS_LENGTH + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.SMS) + self._account_sid: str = os.environ["TWILIO_ACCOUNT_SID"] + self._auth_token: str = os.environ["TWILIO_AUTH_TOKEN"] + self._from_number: str = os.getenv("TWILIO_PHONE_NUMBER", "") + self._webhook_port: int = int( + os.getenv("SMS_WEBHOOK_PORT", str(DEFAULT_WEBHOOK_PORT)) + ) + self._webhook_host: str = os.getenv("SMS_WEBHOOK_HOST", DEFAULT_WEBHOOK_HOST) + self._webhook_url: str = os.getenv("SMS_WEBHOOK_URL", "").strip() + self._runner = None + self._http_session: Optional["aiohttp.ClientSession"] = None + + def _basic_auth_header(self) -> str: + """Build HTTP Basic auth header value for Twilio.""" + creds = f"{self._account_sid}:{self._auth_token}" + encoded = base64.b64encode(creds.encode("ascii")).decode("ascii") + return f"Basic {encoded}" + + # ------------------------------------------------------------------ + # Required abstract methods + # ------------------------------------------------------------------ + + async def connect(self) -> bool: + import aiohttp + from aiohttp import web + + if not self._from_number: + logger.error("[sms] TWILIO_PHONE_NUMBER not set — cannot send replies") + return False + + insecure_no_sig = os.getenv("SMS_INSECURE_NO_SIGNATURE", "").lower() == "true" + + if not self._webhook_url and not insecure_no_sig: + logger.error( + "[sms] Refusing to start: SMS_WEBHOOK_URL is required for Twilio " + "signature validation. Set it to the public URL configured in your " + "Twilio console (e.g. https://example.com/webhooks/twilio). " + "For local development without validation, set " + "SMS_INSECURE_NO_SIGNATURE=true (NOT recommended for production).", + ) + return False + + if insecure_no_sig and not self._webhook_url: + logger.warning( + "[sms] SMS_INSECURE_NO_SIGNATURE=true — Twilio signature validation " + "is DISABLED. Any client that can reach port %d can inject messages. " + "Do NOT use this in production.", + self._webhook_port, + ) + + app = web.Application() + app.router.add_post("/webhooks/twilio", self._handle_webhook) + app.router.add_get("/health", lambda _: web.Response(text="ok")) + + self._runner = web.AppRunner(app) + await self._runner.setup() + site = web.TCPSite(self._runner, self._webhook_host, self._webhook_port) + await site.start() + self._http_session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=30), + ) + self._running = True + + logger.info( + "[sms] Twilio webhook server listening on %s:%d, from: %s", + self._webhook_host, + self._webhook_port, + redact_phone(self._from_number), + ) + return True + + async def disconnect(self) -> None: + if self._http_session: + await self._http_session.close() + self._http_session = None + if self._runner: + await self._runner.cleanup() + self._runner = None + self._running = False + logger.info("[sms] Disconnected") + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + import aiohttp + + formatted = self.format_message(content) + chunks = self.truncate_message(formatted) + last_result = SendResult(success=True) + + url = f"{TWILIO_API_BASE}/{self._account_sid}/Messages.json" + headers = { + "Authorization": self._basic_auth_header(), + } + + session = self._http_session or aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=30), + ) + try: + for chunk in chunks: + form_data = aiohttp.FormData() + form_data.add_field("From", self._from_number) + form_data.add_field("To", chat_id) + form_data.add_field("Body", chunk) + + try: + async with session.post(url, data=form_data, headers=headers) as resp: + body = await resp.json() + if resp.status >= 400: + error_msg = body.get("message", str(body)) + logger.error( + "[sms] send failed to %s: %s %s", + redact_phone(chat_id), + resp.status, + error_msg, + ) + return SendResult( + success=False, + error=f"Twilio {resp.status}: {error_msg}", + ) + msg_sid = body.get("sid", "") + last_result = SendResult(success=True, message_id=msg_sid) + except Exception as e: + logger.error("[sms] send error to %s: %s", redact_phone(chat_id), e) + return SendResult(success=False, error=str(e)) + finally: + # Close session only if we created a fallback (no persistent session) + if not self._http_session and session: + await session.close() + + return last_result + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + return {"name": chat_id, "type": "dm"} + + # ------------------------------------------------------------------ + # SMS-specific formatting + # ------------------------------------------------------------------ + + def format_message(self, content: str) -> str: + """Strip markdown — SMS renders it as literal characters.""" + return strip_markdown(content) + + # ------------------------------------------------------------------ + # Twilio signature validation + # ------------------------------------------------------------------ + + def _validate_twilio_signature( + self, url: str, post_params: dict, signature: str, + ) -> bool: + """Validate ``X-Twilio-Signature`` header (HMAC-SHA1, base64). + + Tries both with and without the default port for the URL scheme, + since Twilio may sign with either variant. + + Algorithm: https://www.twilio.com/docs/usage/security#validating-requests + """ + if self._check_signature(url, post_params, signature): + return True + + variant = self._port_variant_url(url) + if variant and self._check_signature(variant, post_params, signature): + return True + + return False + + def _check_signature( + self, url: str, post_params: dict, signature: str, + ) -> bool: + """Compute and compare a single Twilio signature.""" + data_to_sign = url + for key in sorted(post_params.keys()): + data_to_sign += key + post_params[key] + mac = hmac.new( + self._auth_token.encode("utf-8"), + data_to_sign.encode("utf-8"), + hashlib.sha1, + ) + computed = base64.b64encode(mac.digest()).decode("utf-8") + return hmac.compare_digest(computed, signature) + + @staticmethod + def _port_variant_url(url: str) -> str | None: + """Return the URL with the default port toggled, or None. + + Only toggles default ports (443 for https, 80 for http). + Non-standard ports are never modified. + """ + parsed = urllib.parse.urlparse(url) + default_ports = {"https": 443, "http": 80} + default_port = default_ports.get(parsed.scheme) + if default_port is None: + return None + + if parsed.port == default_port: + # Has explicit default port → strip it + return urllib.parse.urlunparse( + (parsed.scheme, parsed.hostname, parsed.path, + parsed.params, parsed.query, parsed.fragment) + ) + elif parsed.port is None: + # No port → add default + netloc = f"{parsed.hostname}:{default_port}" + return urllib.parse.urlunparse( + (parsed.scheme, netloc, parsed.path, + parsed.params, parsed.query, parsed.fragment) + ) + + # Non-standard port — no variant + return None + + # ------------------------------------------------------------------ + # Twilio webhook handler + # ------------------------------------------------------------------ + + async def _handle_webhook(self, request) -> "aiohttp.web.Response": + from aiohttp import web + + try: + raw = await request.read() + # Twilio sends form-encoded data, not JSON + form = urllib.parse.parse_qs(raw.decode("utf-8"), keep_blank_values=True) + except Exception as e: + logger.error("[sms] webhook parse error: %s", e) + return web.Response( + text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>', + content_type="application/xml", + status=400, + ) + + # Validate Twilio request signature when SMS_WEBHOOK_URL is configured + if self._webhook_url: + twilio_sig = request.headers.get("X-Twilio-Signature", "") + if not twilio_sig: + logger.warning("[sms] Rejected: missing X-Twilio-Signature header") + return web.Response( + text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>', + content_type="application/xml", + status=403, + ) + flat_params = {k: v[0] for k, v in form.items() if v} + if not self._validate_twilio_signature( + self._webhook_url, flat_params, twilio_sig + ): + logger.warning("[sms] Rejected: invalid Twilio signature") + return web.Response( + text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>', + content_type="application/xml", + status=403, + ) + + # Extract fields (parse_qs returns lists) + from_number = (form.get("From", [""]))[0].strip() + to_number = (form.get("To", [""]))[0].strip() + text = (form.get("Body", [""]))[0].strip() + message_sid = (form.get("MessageSid", [""]))[0].strip() + + if not from_number or not text: + return web.Response( + text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>', + content_type="application/xml", + ) + + # Ignore messages from our own number (echo prevention) + if from_number == self._from_number: + logger.debug("[sms] ignoring echo from own number %s", redact_phone(from_number)) + return web.Response( + text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>', + content_type="application/xml", + ) + + logger.info( + "[sms] inbound from %s -> %s: %s", + redact_phone(from_number), + redact_phone(to_number), + text[:80], + ) + + source = self.build_source( + chat_id=from_number, + chat_name=from_number, + chat_type="dm", + user_id=from_number, + user_name=from_number, + ) + event = MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=source, + raw_message=form, + message_id=message_sid, + ) + + # Non-blocking: Twilio expects a fast response + task = asyncio.create_task(self.handle_message(event)) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + + # Return empty TwiML — we send replies via the REST API, not inline TwiML + return web.Response( + text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>', + content_type="application/xml", + ) diff --git a/mindcli/_vendor/gateway/platforms/telegram.py b/mindcli/_vendor/gateway/platforms/telegram.py new file mode 100644 index 0000000..8ff9299 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/telegram.py @@ -0,0 +1,2814 @@ +""" +Telegram platform adapter. + +Uses python-telegram-bot library for: +- Receiving messages from users/groups +- Sending responses back +- Handling media and commands +""" + +import asyncio +import json +import logging +import os +import re +from typing import Dict, List, Optional, Any + +logger = logging.getLogger(__name__) + +try: + from telegram import Update, Bot, Message, InlineKeyboardButton, InlineKeyboardMarkup + from telegram.ext import ( + Application, + CommandHandler, + CallbackQueryHandler, + MessageHandler as TelegramMessageHandler, + ContextTypes, + filters, + ) + from telegram.constants import ParseMode, ChatType + from telegram.request import HTTPXRequest + TELEGRAM_AVAILABLE = True +except ImportError: + TELEGRAM_AVAILABLE = False + Update = Any + Bot = Any + Message = Any + InlineKeyboardButton = Any + InlineKeyboardMarkup = Any + Application = Any + CommandHandler = Any + CallbackQueryHandler = Any + TelegramMessageHandler = Any + HTTPXRequest = Any + filters = None + ParseMode = None + ChatType = None + + # Mock ContextTypes so type annotations using ContextTypes.DEFAULT_TYPE + # don't crash during class definition when the library isn't installed. + class _MockContextTypes: + DEFAULT_TYPE = Any + ContextTypes = _MockContextTypes + +import sys +from pathlib import Path as _Path +sys.path.insert(0, str(_Path(__file__).resolve().parents[2])) + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + ProcessingOutcome, + SendResult, + cache_image_from_bytes, + cache_audio_from_bytes, + cache_document_from_bytes, + resolve_proxy_url, + SUPPORTED_DOCUMENT_TYPES, + utf16_len, + _prefix_within_utf16_limit, +) +from gateway.platforms.telegram_network import ( + TelegramFallbackTransport, + discover_fallback_ips, + parse_fallback_ip_env, +) + + +def check_telegram_requirements() -> bool: + """Check if Telegram dependencies are available.""" + return TELEGRAM_AVAILABLE + + +# Matches every character that MarkdownV2 requires to be backslash-escaped +# when it appears outside a code span or fenced code block. +_MDV2_ESCAPE_RE = re.compile(r'([_*\[\]()~`>#\+\-=|{}.!\\])') + + +def _escape_mdv2(text: str) -> str: + """Escape Telegram MarkdownV2 special characters with a preceding backslash.""" + return _MDV2_ESCAPE_RE.sub(r'\\\1', text) + + +def _strip_mdv2(text: str) -> str: + """Strip MarkdownV2 escape backslashes to produce clean plain text. + + Also removes MarkdownV2 formatting markers so the fallback + doesn't show stray syntax characters from format_message conversion. + """ + # Remove escape backslashes before special characters + cleaned = re.sub(r'\\([_*\[\]()~`>#\+\-=|{}.!\\])', r'\1', text) + # Remove MarkdownV2 bold markers that format_message converted from **bold** + cleaned = re.sub(r'\*([^*]+)\*', r'\1', cleaned) + # Remove MarkdownV2 italic markers that format_message converted from *italic* + # Use word boundary (\b) to avoid breaking snake_case like my_variable_name + cleaned = re.sub(r'(?<!\w)_([^_]+)_(?!\w)', r'\1', cleaned) + # Remove MarkdownV2 strikethrough markers (~text~ → text) + cleaned = re.sub(r'~([^~]+)~', r'\1', cleaned) + # Remove MarkdownV2 spoiler markers (||text|| → text) + cleaned = re.sub(r'\|\|([^|]+)\|\|', r'\1', cleaned) + return cleaned + + +class TelegramAdapter(BasePlatformAdapter): + """ + Telegram bot adapter. + + Handles: + - Receiving messages from users and groups + - Sending responses with Telegram markdown + - Forum topics (thread_id support) + - Media messages + """ + + # Telegram message limits + MAX_MESSAGE_LENGTH = 4096 + # Threshold for detecting Telegram client-side message splits. + # When a chunk is near this limit, a continuation is almost certain. + _SPLIT_THRESHOLD = 4000 + MEDIA_GROUP_WAIT_SECONDS = 0.8 + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.TELEGRAM) + self._app: Optional[Application] = None + self._bot: Optional[Bot] = None + self._webhook_mode: bool = False + self._mention_patterns = self._compile_mention_patterns() + self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first' + # Buffer rapid/album photo updates so Telegram image bursts are handled + # as a single MessageEvent instead of self-interrupting multiple turns. + self._media_batch_delay_seconds = float(os.getenv("HERMES_TELEGRAM_MEDIA_BATCH_DELAY_SECONDS", "0.8")) + self._pending_photo_batches: Dict[str, MessageEvent] = {} + self._pending_photo_batch_tasks: Dict[str, asyncio.Task] = {} + self._media_group_events: Dict[str, MessageEvent] = {} + self._media_group_tasks: Dict[str, asyncio.Task] = {} + # Buffer rapid text messages so Telegram client-side splits of long + # messages are aggregated into a single MessageEvent. + self._text_batch_delay_seconds = float(os.getenv("HERMES_TELEGRAM_TEXT_BATCH_DELAY_SECONDS", "0.6")) + self._text_batch_split_delay_seconds = float(os.getenv("HERMES_TELEGRAM_TEXT_BATCH_SPLIT_DELAY_SECONDS", "2.0")) + self._pending_text_batches: Dict[str, MessageEvent] = {} + self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {} + self._polling_error_task: Optional[asyncio.Task] = None + self._polling_conflict_count: int = 0 + self._polling_network_error_count: int = 0 + self._polling_error_callback_ref = None + # DM Topics: map of topic_name -> message_thread_id (populated at startup) + self._dm_topics: Dict[str, int] = {} + # DM Topics config from extra.dm_topics + self._dm_topics_config: List[Dict[str, Any]] = self.config.extra.get("dm_topics", []) + # Interactive model picker state per chat + self._model_picker_state: Dict[str, dict] = {} + # Approval button state: message_id → session_key + self._approval_state: Dict[int, str] = {} + + def _fallback_ips(self) -> list[str]: + """Return validated fallback IPs from config (populated by _apply_env_overrides).""" + configured = self.config.extra.get("fallback_ips", []) if getattr(self.config, "extra", None) else [] + if isinstance(configured, str): + configured = configured.split(",") + return parse_fallback_ip_env(",".join(str(v) for v in configured) if configured else None) + + @staticmethod + def _looks_like_polling_conflict(error: Exception) -> bool: + text = str(error).lower() + return ( + error.__class__.__name__.lower() == "conflict" + or "terminated by other getupdates request" in text + or "another bot instance is running" in text + ) + + @staticmethod + def _looks_like_network_error(error: Exception) -> bool: + """Return True for transient network errors that warrant a reconnect attempt.""" + name = error.__class__.__name__.lower() + if name in ("networkerror", "timedout", "connectionerror"): + return True + try: + from telegram.error import NetworkError, TimedOut + if isinstance(error, (NetworkError, TimedOut)): + return True + except ImportError: + pass + return isinstance(error, OSError) + + async def _handle_polling_network_error(self, error: Exception) -> None: + """Reconnect polling after a transient network interruption. + + Triggered by NetworkError/TimedOut in the polling error callback, which + happen when the host loses connectivity (Mac sleep, WiFi switch, VPN + reconnect, etc.). The gateway process stays alive but the long-poll + connection silently dies; without this handler the bot never recovers. + + Strategy: exponential back-off (5s, 10s, 20s, 40s, 60s cap) up to + MAX_NETWORK_RETRIES attempts, then mark the adapter retryable-fatal so + the supervisor restarts the gateway process. + """ + if self.has_fatal_error: + return + + MAX_NETWORK_RETRIES = 10 + BASE_DELAY = 5 + MAX_DELAY = 60 + + self._polling_network_error_count += 1 + attempt = self._polling_network_error_count + + if attempt > MAX_NETWORK_RETRIES: + message = ( + "Telegram polling could not reconnect after %d network error retries. " + "Restarting gateway." % MAX_NETWORK_RETRIES + ) + logger.error("[%s] %s Last error: %s", self.name, message, error) + self._set_fatal_error("telegram_network_error", message, retryable=True) + await self._notify_fatal_error() + return + + delay = min(BASE_DELAY * (2 ** (attempt - 1)), MAX_DELAY) + logger.warning( + "[%s] Telegram network error (attempt %d/%d), reconnecting in %ds. Error: %s", + self.name, attempt, MAX_NETWORK_RETRIES, delay, error, + ) + await asyncio.sleep(delay) + + try: + if self._app and self._app.updater and self._app.updater.running: + await self._app.updater.stop() + except Exception: + pass + + try: + await self._app.updater.start_polling( + allowed_updates=Update.ALL_TYPES, + drop_pending_updates=False, + error_callback=self._polling_error_callback_ref, + ) + logger.info( + "[%s] Telegram polling resumed after network error (attempt %d)", + self.name, attempt, + ) + self._polling_network_error_count = 0 + except Exception as retry_err: + logger.warning("[%s] Telegram polling reconnect failed: %s", self.name, retry_err) + # start_polling failed — polling is dead and no further error + # callbacks will fire, so schedule the next retry ourselves. + if not self.has_fatal_error: + task = asyncio.ensure_future( + self._handle_polling_network_error(retry_err) + ) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + + async def _handle_polling_conflict(self, error: Exception) -> None: + if self.has_fatal_error and self.fatal_error_code == "telegram_polling_conflict": + return + # Track consecutive conflicts — transient 409s can occur when a + # previous gateway instance hasn't fully released its long-poll + # session on Telegram's server (e.g. during --replace handoffs or + # systemd Restart=on-failure respawns). Retry a few times before + # giving up, so the old session has time to expire. + self._polling_conflict_count += 1 + + MAX_CONFLICT_RETRIES = 3 + RETRY_DELAY = 10 # seconds + + if self._polling_conflict_count <= MAX_CONFLICT_RETRIES: + logger.warning( + "[%s] Telegram polling conflict (%d/%d), will retry in %ds. Error: %s", + self.name, self._polling_conflict_count, MAX_CONFLICT_RETRIES, + RETRY_DELAY, error, + ) + try: + if self._app and self._app.updater and self._app.updater.running: + await self._app.updater.stop() + except Exception: + pass + await asyncio.sleep(RETRY_DELAY) + try: + await self._app.updater.start_polling( + allowed_updates=Update.ALL_TYPES, + drop_pending_updates=False, + error_callback=self._polling_error_callback_ref, + ) + logger.info("[%s] Telegram polling resumed after conflict retry %d", self.name, self._polling_conflict_count) + self._polling_conflict_count = 0 # reset on success + return + except Exception as retry_err: + logger.warning("[%s] Telegram polling retry failed: %s", self.name, retry_err) + # Don't fall through to fatal yet — wait for the next conflict + # to trigger another retry attempt (up to MAX_CONFLICT_RETRIES). + return + + # Exhausted retries — fatal + message = ( + "Another process is already polling this Telegram bot token " + "(possibly OpenClaw or another Hermes instance). " + "Hermes stopped Telegram polling after %d retries. " + "Only one poller can run per token — stop the other process " + "and restart with 'hermes start'." + % MAX_CONFLICT_RETRIES + ) + logger.error("[%s] %s Original error: %s", self.name, message, error) + self._set_fatal_error("telegram_polling_conflict", message, retryable=False) + try: + if self._app and self._app.updater: + await self._app.updater.stop() + except Exception as stop_error: + logger.warning("[%s] Failed stopping Telegram polling after conflict: %s", self.name, stop_error, exc_info=True) + await self._notify_fatal_error() + + async def _create_dm_topic( + self, + chat_id: int, + name: str, + icon_color: Optional[int] = None, + icon_custom_emoji_id: Optional[str] = None, + ) -> Optional[int]: + """Create a forum topic in a private (DM) chat. + + Uses Bot API 9.4's createForumTopic which now works for 1-on-1 chats. + Returns the message_thread_id on success, None on failure. + """ + if not self._bot: + return None + try: + kwargs: Dict[str, Any] = {"chat_id": chat_id, "name": name} + if icon_color is not None: + kwargs["icon_color"] = icon_color + if icon_custom_emoji_id: + kwargs["icon_custom_emoji_id"] = icon_custom_emoji_id + + topic = await self._bot.create_forum_topic(**kwargs) + thread_id = topic.message_thread_id + logger.info( + "[%s] Created DM topic '%s' in chat %s -> thread_id=%s", + self.name, name, chat_id, thread_id, + ) + return thread_id + except Exception as e: + error_text = str(e).lower() + # If topic already exists, try to find it via getForumTopicIconStickers + # or we just log and skip — Telegram doesn't provide a "list topics" API + if "topic_name_duplicate" in error_text or "already" in error_text: + logger.info( + "[%s] DM topic '%s' already exists in chat %s (will be mapped from incoming messages)", + self.name, name, chat_id, + ) + else: + logger.warning( + "[%s] Failed to create DM topic '%s' in chat %s: %s", + self.name, name, chat_id, e, + ) + return None + + def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id: int) -> None: + """Save a newly created thread_id back into config.yaml so it persists across restarts.""" + try: + from hermes_constants import get_hermes_home + config_path = get_hermes_home() / "config.yaml" + if not config_path.exists(): + logger.warning("[%s] Config file not found at %s, cannot persist thread_id", self.name, config_path) + return + + import yaml as _yaml + with open(config_path, "r") as f: + config = _yaml.safe_load(f) or {} + + # Navigate to platforms.telegram.extra.dm_topics + dm_topics = ( + config.get("platforms", {}) + .get("telegram", {}) + .get("extra", {}) + .get("dm_topics", []) + ) + if not dm_topics: + return + + changed = False + for chat_entry in dm_topics: + if int(chat_entry.get("chat_id", 0)) != int(chat_id): + continue + for t in chat_entry.get("topics", []): + if t.get("name") == topic_name and not t.get("thread_id"): + t["thread_id"] = thread_id + changed = True + break + + if changed: + with open(config_path, "w") as f: + _yaml.dump(config, f, default_flow_style=False, sort_keys=False) + logger.info( + "[%s] Persisted thread_id=%s for topic '%s' in config.yaml", + self.name, thread_id, topic_name, + ) + except Exception as e: + logger.warning("[%s] Failed to persist thread_id to config: %s", self.name, e, exc_info=True) + + async def _setup_dm_topics(self) -> None: + """Load or create configured DM topics for specified chats. + + Reads config.extra['dm_topics'] — a list of dicts: + [ + { + "chat_id": 123456789, + "topics": [ + {"name": "General", "icon_color": 7322096, "thread_id": 100}, + {"name": "Accessibility Auditor", "icon_color": 9367192, "skill": "accessibility-auditor"} + ] + } + ] + + If a topic already has a thread_id in the config (persisted from a previous + creation), it is loaded into the cache without calling createForumTopic. + Only topics without a thread_id are created via the API, and their thread_id + is then saved back to config.yaml for future restarts. + """ + if not self._dm_topics_config: + return + + for chat_entry in self._dm_topics_config: + chat_id = chat_entry.get("chat_id") + topics = chat_entry.get("topics", []) + if not chat_id or not topics: + continue + + logger.info( + "[%s] Setting up %d DM topic(s) for chat %s", + self.name, len(topics), chat_id, + ) + + for topic_conf in topics: + topic_name = topic_conf.get("name") + if not topic_name: + continue + + cache_key = f"{chat_id}:{topic_name}" + + # If thread_id is already persisted in config, just load into cache + existing_thread_id = topic_conf.get("thread_id") + if existing_thread_id: + self._dm_topics[cache_key] = int(existing_thread_id) + logger.info( + "[%s] DM topic loaded from config: %s -> thread_id=%s", + self.name, cache_key, existing_thread_id, + ) + continue + + # No persisted thread_id — create the topic via API + icon_color = topic_conf.get("icon_color") + icon_emoji = topic_conf.get("icon_custom_emoji_id") + + thread_id = await self._create_dm_topic( + chat_id=int(chat_id), + name=topic_name, + icon_color=icon_color, + icon_custom_emoji_id=icon_emoji, + ) + + if thread_id: + self._dm_topics[cache_key] = thread_id + logger.info( + "[%s] DM topic cached: %s -> thread_id=%s", + self.name, cache_key, thread_id, + ) + # Persist thread_id to config so we don't recreate on next restart + self._persist_dm_topic_thread_id(int(chat_id), topic_name, thread_id) + + async def connect(self) -> bool: + """Connect to Telegram via polling or webhook. + + By default, uses long polling (outbound connection to Telegram). + If ``TELEGRAM_WEBHOOK_URL`` is set, starts an HTTP webhook server + instead. Webhook mode is useful for cloud deployments (Fly.io, + Railway) where inbound HTTP can wake a suspended machine. + + Env vars for webhook mode:: + + TELEGRAM_WEBHOOK_URL Public HTTPS URL (e.g. https://app.fly.dev/telegram) + TELEGRAM_WEBHOOK_PORT Local listen port (default 8443) + TELEGRAM_WEBHOOK_SECRET Secret token for update verification + """ + if not TELEGRAM_AVAILABLE: + logger.error( + "[%s] python-telegram-bot not installed. Run: pip install python-telegram-bot", + self.name, + ) + return False + + if not self.config.token: + logger.error("[%s] No bot token configured", self.name) + return False + + try: + if not self._acquire_platform_lock('telegram-bot-token', self.config.token, 'Telegram bot token'): + return False + + # Build the application + builder = Application.builder().token(self.config.token) + custom_base_url = self.config.extra.get("base_url") + if custom_base_url: + builder = builder.base_url(custom_base_url) + builder = builder.base_file_url( + self.config.extra.get("base_file_url", custom_base_url) + ) + logger.info( + "[%s] Using custom Telegram base_url: %s", + self.name, custom_base_url, + ) + + # PTB defaults (pool_timeout=1s) are too aggressive on flaky networks and + # can trigger "Pool timeout: All connections in the connection pool are occupied" + # during reconnect/bootstrap. Use safer defaults and allow env overrides. + def _env_int(name: str, default: int) -> int: + try: + return int(os.getenv(name, str(default))) + except (TypeError, ValueError): + return default + + def _env_float(name: str, default: float) -> float: + try: + return float(os.getenv(name, str(default))) + except (TypeError, ValueError): + return default + + request_kwargs = { + "connection_pool_size": _env_int("HERMES_TELEGRAM_HTTP_POOL_SIZE", 512), + "pool_timeout": _env_float("HERMES_TELEGRAM_HTTP_POOL_TIMEOUT", 8.0), + "connect_timeout": _env_float("HERMES_TELEGRAM_HTTP_CONNECT_TIMEOUT", 10.0), + "read_timeout": _env_float("HERMES_TELEGRAM_HTTP_READ_TIMEOUT", 20.0), + "write_timeout": _env_float("HERMES_TELEGRAM_HTTP_WRITE_TIMEOUT", 20.0), + } + + proxy_url = resolve_proxy_url() + disable_fallback = (os.getenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "").strip().lower() in ("1", "true", "yes", "on")) + fallback_ips = self._fallback_ips() + if not fallback_ips: + fallback_ips = await discover_fallback_ips() + logger.info( + "[%s] Auto-discovered Telegram fallback IPs: %s", + self.name, + ", ".join(fallback_ips), + ) + + if fallback_ips and not proxy_url and not disable_fallback: + logger.info( + "[%s] Telegram fallback IPs active: %s", + self.name, + ", ".join(fallback_ips), + ) + # Keep request/update pools separate to reduce contention during + # polling reconnect + bot API bootstrap/delete_webhook calls. + request = HTTPXRequest( + **request_kwargs, + httpx_kwargs={"transport": TelegramFallbackTransport(fallback_ips)}, + ) + get_updates_request = HTTPXRequest( + **request_kwargs, + httpx_kwargs={"transport": TelegramFallbackTransport(fallback_ips)}, + ) + elif proxy_url: + logger.info("[%s] Proxy detected; passing explicitly to HTTPXRequest: %s", self.name, proxy_url) + request = HTTPXRequest(**request_kwargs, proxy=proxy_url) + get_updates_request = HTTPXRequest(**request_kwargs, proxy=proxy_url) + else: + if disable_fallback: + logger.info("[%s] Telegram fallback-IP transport disabled via env", self.name) + request = HTTPXRequest(**request_kwargs) + get_updates_request = HTTPXRequest(**request_kwargs) + + builder = builder.request(request).get_updates_request(get_updates_request) + self._app = builder.build() + self._bot = self._app.bot + + # Register handlers + self._app.add_handler(TelegramMessageHandler( + filters.TEXT & ~filters.COMMAND, + self._handle_text_message + )) + self._app.add_handler(TelegramMessageHandler( + filters.COMMAND, + self._handle_command + )) + self._app.add_handler(TelegramMessageHandler( + filters.LOCATION | getattr(filters, "VENUE", filters.LOCATION), + self._handle_location_message + )) + self._app.add_handler(TelegramMessageHandler( + filters.PHOTO | filters.VIDEO | filters.AUDIO | filters.VOICE | filters.Document.ALL | filters.Sticker.ALL, + self._handle_media_message + )) + # Handle inline keyboard button callbacks (update prompts) + self._app.add_handler(CallbackQueryHandler(self._handle_callback_query)) + + # Start polling — retry initialize() for transient TLS resets + try: + from telegram.error import NetworkError, TimedOut + except ImportError: + NetworkError = TimedOut = OSError # type: ignore[misc,assignment] + _max_connect = 3 + for _attempt in range(_max_connect): + try: + await self._app.initialize() + break + except (NetworkError, TimedOut, OSError) as init_err: + if _attempt < _max_connect - 1: + wait = 2 ** _attempt + logger.warning( + "[%s] Connect attempt %d/%d failed: %s — retrying in %ds", + self.name, _attempt + 1, _max_connect, init_err, wait, + ) + await asyncio.sleep(wait) + else: + raise + await self._app.start() + + # Decide between webhook and polling mode + webhook_url = os.getenv("TELEGRAM_WEBHOOK_URL", "").strip() + + if webhook_url: + # ── Webhook mode ───────────────────────────────────── + # Telegram pushes updates to our HTTP endpoint. This + # enables cloud platforms (Fly.io, Railway) to auto-wake + # suspended machines on inbound HTTP traffic. + webhook_port = int(os.getenv("TELEGRAM_WEBHOOK_PORT", "8443")) + webhook_secret = os.getenv("TELEGRAM_WEBHOOK_SECRET", "").strip() or None + from urllib.parse import urlparse + webhook_path = urlparse(webhook_url).path or "/telegram" + + await self._app.updater.start_webhook( + listen="0.0.0.0", + port=webhook_port, + url_path=webhook_path, + webhook_url=webhook_url, + secret_token=webhook_secret, + allowed_updates=Update.ALL_TYPES, + drop_pending_updates=True, + ) + self._webhook_mode = True + logger.info( + "[%s] Webhook server listening on 0.0.0.0:%d%s", + self.name, webhook_port, webhook_path, + ) + else: + # ── Polling mode (default) ─────────────────────────── + # Clear any stale webhook first so polling doesn't inherit a + # previous webhook registration and silently stop receiving updates. + delete_webhook = getattr(self._bot, "delete_webhook", None) + if callable(delete_webhook): + await delete_webhook(drop_pending_updates=False) + + loop = asyncio.get_running_loop() + + def _polling_error_callback(error: Exception) -> None: + if self._polling_error_task and not self._polling_error_task.done(): + return + if self._looks_like_polling_conflict(error): + self._polling_error_task = loop.create_task(self._handle_polling_conflict(error)) + elif self._looks_like_network_error(error): + logger.warning("[%s] Telegram network error, scheduling reconnect: %s", self.name, error) + self._polling_error_task = loop.create_task(self._handle_polling_network_error(error)) + else: + logger.error("[%s] Telegram polling error: %s", self.name, error, exc_info=True) + + # Store reference for retry use in _handle_polling_conflict + self._polling_error_callback_ref = _polling_error_callback + + await self._app.updater.start_polling( + allowed_updates=Update.ALL_TYPES, + drop_pending_updates=True, + error_callback=_polling_error_callback, + ) + + # Register bot commands so Telegram shows a hint menu when users type / + # List is derived from the central COMMAND_REGISTRY — adding a new + # gateway command there automatically adds it to the Telegram menu. + try: + from telegram import BotCommand + from hermes_cli.commands import telegram_menu_commands + # Telegram allows up to 100 commands but has an undocumented + # payload size limit. Skill descriptions are truncated to 40 + # chars in telegram_menu_commands() to fit 100 commands safely. + menu_commands, hidden_count = telegram_menu_commands(max_commands=100) + await self._bot.set_my_commands([ + BotCommand(name, desc) for name, desc in menu_commands + ]) + if hidden_count: + logger.info( + "[%s] Telegram menu: %d commands registered, %d hidden (over 100 limit). Use /commands for full list.", + self.name, len(menu_commands), hidden_count, + ) + except Exception as e: + logger.warning( + "[%s] Could not register Telegram command menu: %s", + self.name, + e, + exc_info=True, + ) + + self._mark_connected() + mode = "webhook" if self._webhook_mode else "polling" + logger.info("[%s] Connected to Telegram (%s mode)", self.name, mode) + + # Set up DM topics (Bot API 9.4 — Private Chat Topics) + # Runs after connection is established so the bot can call createForumTopic. + # Failures here are non-fatal — the bot works fine without topics. + try: + await self._setup_dm_topics() + except Exception as topics_err: + logger.warning( + "[%s] DM topics setup failed (non-fatal): %s", + self.name, topics_err, exc_info=True, + ) + + return True + + except Exception as e: + self._release_platform_lock() + message = f"Telegram startup failed: {e}" + self._set_fatal_error("telegram_connect_error", message, retryable=True) + logger.error("[%s] Failed to connect to Telegram: %s", self.name, e, exc_info=True) + return False + + async def disconnect(self) -> None: + """Stop polling/webhook, cancel pending album flushes, and disconnect.""" + pending_media_group_tasks = list(self._media_group_tasks.values()) + for task in pending_media_group_tasks: + task.cancel() + if pending_media_group_tasks: + await asyncio.gather(*pending_media_group_tasks, return_exceptions=True) + self._media_group_tasks.clear() + self._media_group_events.clear() + + if self._app: + try: + # Only stop the updater if it's running + if self._app.updater and self._app.updater.running: + await self._app.updater.stop() + if self._app.running: + await self._app.stop() + await self._app.shutdown() + except Exception as e: + logger.warning("[%s] Error during Telegram disconnect: %s", self.name, e, exc_info=True) + self._release_platform_lock() + + for task in self._pending_photo_batch_tasks.values(): + if task and not task.done(): + task.cancel() + self._pending_photo_batch_tasks.clear() + self._pending_photo_batches.clear() + + self._mark_disconnected() + self._app = None + self._bot = None + logger.info("[%s] Disconnected from Telegram", self.name) + + def _should_thread_reply(self, reply_to: Optional[str], chunk_index: int) -> bool: + """Determine if this message chunk should thread to the original message. + + Args: + reply_to: The original message ID to reply to + chunk_index: Index of this chunk (0 = first chunk) + + Returns: + True if this chunk should be threaded to the original message + """ + if not reply_to: + return False + mode = self._reply_to_mode + if mode == "off": + return False + elif mode == "all": + return True + else: # "first" (default) + return chunk_index == 0 + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> SendResult: + """Send a message to a Telegram chat.""" + if not self._bot: + return SendResult(success=False, error="Not connected") + + # Skip whitespace-only text to prevent Telegram 400 empty-text errors. + if not content or not content.strip(): + return SendResult(success=True, message_id=None) + + try: + # Format and split message if needed + formatted = self.format_message(content) + chunks = self.truncate_message( + formatted, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len, + ) + if len(chunks) > 1: + # truncate_message appends a raw " (1/2)" suffix. Escape the + # MarkdownV2-special parentheses so Telegram doesn't reject the + # chunk and fall back to plain text. + chunks = [ + re.sub(r" \((\d+)/(\d+)\)$", r" \\(\1/\2\\)", chunk) + for chunk in chunks + ] + + message_ids = [] + thread_id = metadata.get("thread_id") if metadata else None + + try: + from telegram.error import NetworkError as _NetErr + except ImportError: + _NetErr = OSError # type: ignore[misc,assignment] + + try: + from telegram.error import BadRequest as _BadReq + except ImportError: + _BadReq = None # type: ignore[assignment,misc] + + try: + from telegram.error import TimedOut as _TimedOut + except (ImportError, AttributeError): + _TimedOut = None # type: ignore[assignment,misc] + + for i, chunk in enumerate(chunks): + should_thread = self._should_thread_reply(reply_to, i) + reply_to_id = int(reply_to) if should_thread else None + effective_thread_id = int(thread_id) if thread_id else None + + msg = None + for _send_attempt in range(3): + try: + # Try Markdown first, fall back to plain text if it fails + try: + msg = await self._bot.send_message( + chat_id=int(chat_id), + text=chunk, + parse_mode=ParseMode.MARKDOWN_V2, + reply_to_message_id=reply_to_id, + message_thread_id=effective_thread_id, + ) + except Exception as md_error: + # Markdown parsing failed, try plain text + if "parse" in str(md_error).lower() or "markdown" in str(md_error).lower(): + logger.warning("[%s] MarkdownV2 parse failed, falling back to plain text: %s", self.name, md_error) + plain_chunk = _strip_mdv2(chunk) + msg = await self._bot.send_message( + chat_id=int(chat_id), + text=plain_chunk, + parse_mode=None, + reply_to_message_id=reply_to_id, + message_thread_id=effective_thread_id, + ) + else: + raise + break # success + except _NetErr as send_err: + # BadRequest is a subclass of NetworkError in + # python-telegram-bot but represents permanent errors + # (not transient network issues). Detect and handle + # specific cases instead of blindly retrying. + if _BadReq and isinstance(send_err, _BadReq): + err_lower = str(send_err).lower() + if "thread not found" in err_lower and effective_thread_id is not None: + # Thread doesn't exist — retry without + # message_thread_id so the message still + # reaches the chat. + logger.warning( + "[%s] Thread %s not found, retrying without message_thread_id", + self.name, effective_thread_id, + ) + effective_thread_id = None + continue + if "message to be replied not found" in err_lower and reply_to_id is not None: + # Original message was deleted before we + # could reply — clear reply target and retry + # so the response is still delivered. + logger.warning( + "[%s] Reply target deleted, retrying without reply_to: %s", + self.name, send_err, + ) + reply_to_id = None + continue + # Other BadRequest errors are permanent — don't retry + raise + # TimedOut is also a subclass of NetworkError but + # indicates the request may have reached the server — + # retrying risks duplicate message delivery. + if _TimedOut and isinstance(send_err, _TimedOut): + raise + if _send_attempt < 2: + wait = 2 ** _send_attempt + logger.warning("[%s] Network error on send (attempt %d/3), retrying in %ds: %s", + self.name, _send_attempt + 1, wait, send_err) + await asyncio.sleep(wait) + else: + raise + except Exception as send_err: + retry_after = getattr(send_err, "retry_after", None) + if retry_after is not None or "retry after" in str(send_err).lower(): + if _send_attempt < 2: + wait = float(retry_after) if retry_after is not None else 1.0 + logger.warning( + "[%s] Telegram flood control on send (attempt %d/3), retrying in %.1fs: %s", + self.name, + _send_attempt + 1, + wait, + send_err, + ) + await asyncio.sleep(wait) + continue + raise + message_ids.append(str(msg.message_id)) + + return SendResult( + success=True, + message_id=message_ids[0] if message_ids else None, + raw_response={"message_ids": message_ids} + ) + + except Exception as e: + logger.error("[%s] Failed to send Telegram message: %s", self.name, e, exc_info=True) + # TimedOut means the request may have reached Telegram — + # mark as non-retryable so _send_with_retry() doesn't re-send. + _to = locals().get("_TimedOut") + err_str = str(e).lower() + is_timeout = (_to and isinstance(e, _to)) or "timed out" in err_str + return SendResult(success=False, error=str(e), retryable=not is_timeout) + + async def edit_message( + self, + chat_id: str, + message_id: str, + content: str, + ) -> SendResult: + """Edit a previously sent Telegram message.""" + if not self._bot: + return SendResult(success=False, error="Not connected") + try: + formatted = self.format_message(content) + try: + await self._bot.edit_message_text( + chat_id=int(chat_id), + message_id=int(message_id), + text=formatted, + parse_mode=ParseMode.MARKDOWN_V2, + ) + except Exception as fmt_err: + # "Message is not modified" is a no-op, not an error + if "not modified" in str(fmt_err).lower(): + return SendResult(success=True, message_id=message_id) + # Fallback: retry without markdown formatting + await self._bot.edit_message_text( + chat_id=int(chat_id), + message_id=int(message_id), + text=content, + ) + return SendResult(success=True, message_id=message_id) + except Exception as e: + err_str = str(e).lower() + # "Message is not modified" — content identical, treat as success + if "not modified" in err_str: + return SendResult(success=True, message_id=message_id) + # Message too long — content exceeded 4096 chars (e.g. during + # streaming). Truncate and succeed so the stream consumer can + # split the overflow into a new message instead of dying. + if "message_too_long" in err_str or "too long" in err_str: + truncated = _prefix_within_utf16_limit( + content, self.MAX_MESSAGE_LENGTH - 20 + ) + "…" + try: + await self._bot.edit_message_text( + chat_id=int(chat_id), + message_id=int(message_id), + text=truncated, + ) + except Exception: + pass # best-effort truncation + return SendResult(success=True, message_id=message_id) + # Flood control / RetryAfter — short waits are retried inline, + # long waits return a failure immediately so streaming can fall back + # to a normal final send instead of leaving a truncated partial. + retry_after = getattr(e, "retry_after", None) + if retry_after is not None or "retry after" in err_str: + wait = retry_after if retry_after else 1.0 + logger.warning( + "[%s] Telegram flood control, waiting %.1fs", + self.name, wait, + ) + if wait > 5.0: + return SendResult(success=False, error=f"flood_control:{wait}") + await asyncio.sleep(wait) + try: + await self._bot.edit_message_text( + chat_id=int(chat_id), + message_id=int(message_id), + text=content, + ) + return SendResult(success=True, message_id=message_id) + except Exception as retry_err: + logger.error( + "[%s] Edit retry failed after flood wait: %s", + self.name, retry_err, + ) + return SendResult(success=False, error=str(retry_err)) + logger.error( + "[%s] Failed to edit Telegram message %s: %s", + self.name, + message_id, + e, + exc_info=True, + ) + return SendResult(success=False, error=str(e)) + + async def send_update_prompt( + self, chat_id: str, prompt: str, default: str = "", + session_key: str = "", + ) -> SendResult: + """Send an inline-keyboard update prompt (Yes / No buttons). + + Used by the gateway ``/update`` watcher when ``hermes update --gateway`` + needs user input (stash restore, config migration). + """ + if not self._bot: + return SendResult(success=False, error="Not connected") + try: + default_hint = f" (default: {default})" if default else "" + text = f"⚕ *Update needs your input:*\n\n{prompt}{default_hint}" + keyboard = InlineKeyboardMarkup([ + [ + InlineKeyboardButton("✓ Yes", callback_data="update_prompt:y"), + InlineKeyboardButton("✗ No", callback_data="update_prompt:n"), + ] + ]) + msg = await self._bot.send_message( + chat_id=int(chat_id), + text=text, + parse_mode=ParseMode.MARKDOWN, + reply_markup=keyboard, + ) + return SendResult(success=True, message_id=str(msg.message_id)) + except Exception as e: + logger.warning("[%s] send_update_prompt failed: %s", self.name, e) + return SendResult(success=False, error=str(e)) + + async def send_exec_approval( + self, chat_id: str, command: str, session_key: str, + description: str = "dangerous command", + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an inline-keyboard approval prompt with interactive buttons. + + The buttons call ``resolve_gateway_approval()`` to unblock the waiting + agent thread — same mechanism as the text ``/approve`` flow. + """ + if not self._bot: + return SendResult(success=False, error="Not connected") + + try: + cmd_preview = command[:3800] + "..." if len(command) > 3800 else command + text = ( + f"⚠️ *Command Approval Required*\n\n" + f"`{cmd_preview}`\n\n" + f"Reason: {description}" + ) + + # Resolve thread context for thread replies + thread_id = None + if metadata: + thread_id = metadata.get("thread_id") or metadata.get("message_thread_id") + + # We'll use the message_id as part of callback_data to look up session_key + # Send a placeholder first, then update — or use a counter. + # Simpler: use a monotonic counter to generate short IDs. + import itertools + if not hasattr(self, "_approval_counter"): + self._approval_counter = itertools.count(1) + approval_id = next(self._approval_counter) + + keyboard = InlineKeyboardMarkup([ + [ + InlineKeyboardButton("✅ Allow Once", callback_data=f"ea:once:{approval_id}"), + InlineKeyboardButton("✅ Session", callback_data=f"ea:session:{approval_id}"), + ], + [ + InlineKeyboardButton("✅ Always", callback_data=f"ea:always:{approval_id}"), + InlineKeyboardButton("❌ Deny", callback_data=f"ea:deny:{approval_id}"), + ], + ]) + + kwargs: Dict[str, Any] = { + "chat_id": int(chat_id), + "text": text, + "parse_mode": ParseMode.MARKDOWN, + "reply_markup": keyboard, + } + if thread_id: + kwargs["message_thread_id"] = int(thread_id) + + msg = await self._bot.send_message(**kwargs) + + # Store session_key keyed by approval_id for the callback handler + self._approval_state[approval_id] = session_key + + return SendResult(success=True, message_id=str(msg.message_id)) + except Exception as e: + logger.warning("[%s] send_exec_approval failed: %s", self.name, e) + return SendResult(success=False, error=str(e)) + + async def send_model_picker( + self, + chat_id: str, + providers: list, + current_model: str, + current_provider: str, + session_key: str, + on_model_selected, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an interactive inline-keyboard model picker. + + Two-step drill-down: provider selection → model selection. + Edits the same message in-place as the user navigates. + """ + if not self._bot: + return SendResult(success=False, error="Not connected") + + try: + from hermes_cli.providers import get_label + except ImportError: + def get_label(slug): + return slug + + try: + # Build provider buttons — 2 per row + buttons: list = [] + for p in providers: + count = p.get("total_models", len(p.get("models", []))) + label = f"{p['name']} ({count})" + if p.get("is_current"): + label = f"✓ {label}" + # Compact callback data: mp:<slug> (max 64 bytes) + buttons.append( + InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}") + ) + + rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)] + rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")]) + keyboard = InlineKeyboardMarkup(rows) + + provider_label = get_label(current_provider) + text = ( + f"⚙ *Model Configuration*\n\n" + f"Current model: `{current_model or 'unknown'}`\n" + f"Provider: {provider_label}\n\n" + f"Select a provider:" + ) + + thread_id = metadata.get("thread_id") if metadata else None + msg = await self._bot.send_message( + chat_id=int(chat_id), + text=text, + parse_mode=ParseMode.MARKDOWN, + reply_markup=keyboard, + message_thread_id=int(thread_id) if thread_id else None, + ) + + # Store picker state keyed by chat_id + self._model_picker_state[str(chat_id)] = { + "msg_id": msg.message_id, + "providers": providers, + "session_key": session_key, + "on_model_selected": on_model_selected, + "current_model": current_model, + "current_provider": current_provider, + } + + return SendResult(success=True, message_id=str(msg.message_id)) + except Exception as e: + logger.warning("[%s] send_model_picker failed: %s", self.name, e) + return SendResult(success=False, error=str(e)) + + _MODEL_PAGE_SIZE = 8 + + def _build_model_keyboard(self, models: list, page: int) -> tuple: + """Build paginated model buttons. Returns (keyboard, page_info_text).""" + page_size = self._MODEL_PAGE_SIZE + total = len(models) + total_pages = max(1, (total + page_size - 1) // page_size) + page = max(0, min(page, total_pages - 1)) + + start = page * page_size + end = min(start + page_size, total) + page_models = models[start:end] + + buttons: list = [] + for i, model_id in enumerate(page_models): + abs_idx = start + i + short = model_id.split("/")[-1] if "/" in model_id else model_id + if len(short) > 38: + short = short[:35] + "..." + buttons.append( + InlineKeyboardButton(short, callback_data=f"mm:{abs_idx}") + ) + + rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)] + + # Pagination row (if needed) + if total_pages > 1: + nav: list = [] + if page > 0: + nav.append(InlineKeyboardButton("◀ Prev", callback_data=f"mg:{page - 1}")) + nav.append(InlineKeyboardButton(f"{page + 1}/{total_pages}", callback_data="mx:noop")) + if page < total_pages - 1: + nav.append(InlineKeyboardButton("Next ▶", callback_data=f"mg:{page + 1}")) + rows.append(nav) + + rows.append([ + InlineKeyboardButton("◀ Back", callback_data="mb"), + InlineKeyboardButton("✗ Cancel", callback_data="mx"), + ]) + + page_info = f" ({start + 1}–{end} of {total})" if total_pages > 1 else "" + return InlineKeyboardMarkup(rows), page_info + + async def _handle_model_picker_callback( + self, query, data: str, chat_id: str + ) -> None: + """Handle model picker inline keyboard callbacks (mp:/mm:/mb:/mx:/mg:).""" + state = self._model_picker_state.get(chat_id) + if not state: + await query.answer(text="Picker expired — use /model again.") + return + + try: + from hermes_cli.providers import get_label + except ImportError: + def get_label(slug): + return slug + + if data.startswith("mp:"): + # --- Provider selected: show model buttons (page 0) --- + provider_slug = data[3:] + provider = next( + (p for p in state["providers"] if p["slug"] == provider_slug), + None, + ) + if not provider: + await query.answer(text="Provider not found.") + return + + models = provider.get("models", []) + state["selected_provider"] = provider_slug + state["selected_provider_name"] = provider.get("name", provider_slug) + state["model_list"] = models + state["model_page"] = 0 + + keyboard, page_info = self._build_model_keyboard(models, 0) + + pname = provider.get("name", provider_slug) + total = provider.get("total_models", len(models)) + shown = len(models) + extra = f"\n_{total - shown} more available — type `/model <name>` directly_" if total > shown else "" + + await query.edit_message_text( + text=( + f"⚙ *Model Configuration*\n\n" + f"Provider: *{pname}*{page_info}\n" + f"Select a model:{extra}" + ), + parse_mode=ParseMode.MARKDOWN, + reply_markup=keyboard, + ) + await query.answer() + + elif data.startswith("mg:"): + # --- Page navigation --- + try: + page = int(data[3:]) + except ValueError: + await query.answer(text="Invalid page.") + return + + models = state.get("model_list", []) + state["model_page"] = page + + keyboard, page_info = self._build_model_keyboard(models, page) + + pname = state.get("selected_provider_name", "") + provider_slug = state.get("selected_provider", "") + provider = next( + (p for p in state["providers"] if p["slug"] == provider_slug), + None, + ) + total = provider.get("total_models", len(models)) if provider else len(models) + shown = len(models) + extra = f"\n_{total - shown} more available — type `/model <name>` directly_" if total > shown else "" + + await query.edit_message_text( + text=( + f"⚙ *Model Configuration*\n\n" + f"Provider: *{pname}*{page_info}\n" + f"Select a model:{extra}" + ), + parse_mode=ParseMode.MARKDOWN, + reply_markup=keyboard, + ) + await query.answer() + + elif data.startswith("mm:"): + # --- Model selected: perform the switch --- + try: + idx = int(data[3:]) + except ValueError: + await query.answer(text="Invalid selection.") + return + + model_list = state.get("model_list", []) + if idx < 0 or idx >= len(model_list): + await query.answer(text="Invalid model index.") + return + + model_id = model_list[idx] + provider_slug = state.get("selected_provider", "") + callback = state.get("on_model_selected") + + if not callback: + await query.answer(text="Picker expired.") + return + + try: + result_text = await callback(chat_id, model_id, provider_slug) + except Exception as exc: + logger.error("Model picker switch failed: %s", exc) + result_text = f"Error switching model: {exc}" + + # Edit message to show confirmation, remove buttons + try: + await query.edit_message_text( + text=result_text, + parse_mode=ParseMode.MARKDOWN, + reply_markup=None, + ) + except Exception: + # Markdown parse failure — retry as plain text + try: + await query.edit_message_text( + text=result_text, + parse_mode=None, + reply_markup=None, + ) + except Exception: + pass + await query.answer(text="Model switched!") + + # Clean up state + self._model_picker_state.pop(chat_id, None) + + elif data == "mb": + # --- Back to provider list --- + buttons = [] + for p in state["providers"]: + count = p.get("total_models", len(p.get("models", []))) + label = f"{p['name']} ({count})" + if p.get("is_current"): + label = f"✓ {label}" + buttons.append( + InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}") + ) + + rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)] + rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")]) + keyboard = InlineKeyboardMarkup(rows) + + try: + provider_label = get_label(state["current_provider"]) + except Exception: + provider_label = state["current_provider"] + + await query.edit_message_text( + text=( + f"⚙ *Model Configuration*\n\n" + f"Current model: `{state['current_model'] or 'unknown'}`\n" + f"Provider: {provider_label}\n\n" + f"Select a provider:" + ), + parse_mode=ParseMode.MARKDOWN, + reply_markup=keyboard, + ) + await query.answer() + + elif data == "mx": + # --- Cancel --- + self._model_picker_state.pop(chat_id, None) + await query.edit_message_text( + text="Model selection cancelled.", + reply_markup=None, + ) + await query.answer() + + else: + # Catch-all (e.g. page counter button "mx:noop") + await query.answer() + + async def _handle_callback_query( + self, update: "Update", context: "ContextTypes.DEFAULT_TYPE" + ) -> None: + """Handle inline keyboard button clicks.""" + query = update.callback_query + if not query or not query.data: + return + data = query.data + + # --- Model picker callbacks --- + if data.startswith(("mp:", "mm:", "mb", "mx", "mg:")): + chat_id = str(query.message.chat_id) if query.message else None + if chat_id: + await self._handle_model_picker_callback(query, data, chat_id) + return + + # --- Exec approval callbacks (ea:choice:id) --- + if data.startswith("ea:"): + parts = data.split(":", 2) + if len(parts) == 3: + choice = parts[1] # once, session, always, deny + try: + approval_id = int(parts[2]) + except (ValueError, IndexError): + await query.answer(text="Invalid approval data.") + return + + # Only authorized users may click approval buttons. + caller_id = str(getattr(query.from_user, "id", "")) + allowed_csv = os.getenv("TELEGRAM_ALLOWED_USERS", "").strip() + if allowed_csv: + allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()} + if "*" not in allowed_ids and caller_id not in allowed_ids: + await query.answer(text="⛔ You are not authorized to approve commands.") + return + + session_key = self._approval_state.pop(approval_id, None) + if not session_key: + await query.answer(text="This approval has already been resolved.") + return + + # Map choice to human-readable label + label_map = { + "once": "✅ Approved once", + "session": "✅ Approved for session", + "always": "✅ Approved permanently", + "deny": "❌ Denied", + } + user_display = getattr(query.from_user, "first_name", "User") + label = label_map.get(choice, "Resolved") + + await query.answer(text=label) + + # Edit message to show decision, remove buttons + try: + await query.edit_message_text( + text=f"{label} by {user_display}", + parse_mode=ParseMode.MARKDOWN, + reply_markup=None, + ) + except Exception: + pass # non-fatal if edit fails + + # Resolve the approval — unblocks the agent thread + try: + from tools.approval import resolve_gateway_approval + count = resolve_gateway_approval(session_key, choice) + logger.info( + "Telegram button resolved %d approval(s) for session %s (choice=%s, user=%s)", + count, session_key, choice, user_display, + ) + except Exception as exc: + logger.error("Failed to resolve gateway approval from Telegram button: %s", exc) + return + + # --- Update prompt callbacks --- + if not data.startswith("update_prompt:"): + return + answer = data.split(":", 1)[1] # "y" or "n" + await query.answer(text=f"Sent '{answer}' to the update process.") + # Edit the message to show the choice and remove buttons + label = "Yes" if answer == "y" else "No" + try: + await query.edit_message_text( + text=f"⚕ Update prompt answered: *{label}*", + parse_mode=ParseMode.MARKDOWN, + reply_markup=None, + ) + except Exception: + pass # non-fatal if edit fails + # Write the response file + try: + from hermes_constants import get_hermes_home + home = get_hermes_home() + response_path = home / ".update_response" + tmp = response_path.with_suffix(".tmp") + tmp.write_text(answer) + tmp.replace(response_path) + logger.info("Telegram update prompt answered '%s' by user %s", + answer, getattr(query.from_user, "id", "unknown")) + except Exception as exc: + logger.error("Failed to write update response from callback: %s", exc) + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + """Send audio as a native Telegram voice message or audio file.""" + if not self._bot: + return SendResult(success=False, error="Not connected") + + try: + import os + if not os.path.exists(audio_path): + return SendResult(success=False, error=f"Audio file not found: {audio_path}") + + with open(audio_path, "rb") as audio_file: + # .ogg files -> send as voice (round playable bubble) + if audio_path.endswith((".ogg", ".opus")): + _voice_thread = metadata.get("thread_id") if metadata else None + msg = await self._bot.send_voice( + chat_id=int(chat_id), + voice=audio_file, + caption=caption[:1024] if caption else None, + reply_to_message_id=int(reply_to) if reply_to else None, + message_thread_id=int(_voice_thread) if _voice_thread else None, + ) + else: + # .mp3 and others -> send as audio file + _audio_thread = metadata.get("thread_id") if metadata else None + msg = await self._bot.send_audio( + chat_id=int(chat_id), + audio=audio_file, + caption=caption[:1024] if caption else None, + reply_to_message_id=int(reply_to) if reply_to else None, + message_thread_id=int(_audio_thread) if _audio_thread else None, + ) + return SendResult(success=True, message_id=str(msg.message_id)) + except Exception as e: + logger.error( + "[%s] Failed to send Telegram voice/audio, falling back to base adapter: %s", + self.name, + e, + exc_info=True, + ) + return await super().send_voice(chat_id, audio_path, caption, reply_to) + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + """Send a local image file natively as a Telegram photo.""" + if not self._bot: + return SendResult(success=False, error="Not connected") + + try: + import os + if not os.path.exists(image_path): + return SendResult(success=False, error=f"Image file not found: {image_path}") + + _thread = metadata.get("thread_id") if metadata else None + with open(image_path, "rb") as image_file: + msg = await self._bot.send_photo( + chat_id=int(chat_id), + photo=image_file, + caption=caption[:1024] if caption else None, + reply_to_message_id=int(reply_to) if reply_to else None, + message_thread_id=int(_thread) if _thread else None, + ) + return SendResult(success=True, message_id=str(msg.message_id)) + except Exception as e: + logger.error( + "[%s] Failed to send Telegram local image, falling back to base adapter: %s", + self.name, + e, + exc_info=True, + ) + return await super().send_image_file(chat_id, image_path, caption, reply_to) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + """Send a document/file natively as a Telegram file attachment.""" + if not self._bot: + return SendResult(success=False, error="Not connected") + + try: + if not os.path.exists(file_path): + return SendResult(success=False, error=f"File not found: {file_path}") + + display_name = file_name or os.path.basename(file_path) + _thread = metadata.get("thread_id") if metadata else None + + with open(file_path, "rb") as f: + msg = await self._bot.send_document( + chat_id=int(chat_id), + document=f, + filename=display_name, + caption=caption[:1024] if caption else None, + reply_to_message_id=int(reply_to) if reply_to else None, + message_thread_id=int(_thread) if _thread else None, + ) + return SendResult(success=True, message_id=str(msg.message_id)) + except Exception as e: + print(f"[{self.name}] Failed to send document: {e}") + return await super().send_document(chat_id, file_path, caption, file_name, reply_to) + + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + """Send a video natively as a Telegram video message.""" + if not self._bot: + return SendResult(success=False, error="Not connected") + + try: + if not os.path.exists(video_path): + return SendResult(success=False, error=f"Video file not found: {video_path}") + + _thread = metadata.get("thread_id") if metadata else None + with open(video_path, "rb") as f: + msg = await self._bot.send_video( + chat_id=int(chat_id), + video=f, + caption=caption[:1024] if caption else None, + reply_to_message_id=int(reply_to) if reply_to else None, + message_thread_id=int(_thread) if _thread else None, + ) + return SendResult(success=True, message_id=str(msg.message_id)) + except Exception as e: + print(f"[{self.name}] Failed to send video: {e}") + return await super().send_video(chat_id, video_path, caption, reply_to) + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an image natively as a Telegram photo. + + Tries URL-based send first (fast, works for <5MB images). + Falls back to downloading and uploading as file (supports up to 10MB). + """ + if not self._bot: + return SendResult(success=False, error="Not connected") + + from tools.url_safety import is_safe_url + if not is_safe_url(image_url): + logger.warning("[%s] Blocked unsafe image URL (SSRF protection)", self.name) + return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata) + + try: + # Telegram can send photos directly from URLs (up to ~5MB) + _photo_thread = metadata.get("thread_id") if metadata else None + msg = await self._bot.send_photo( + chat_id=int(chat_id), + photo=image_url, + caption=caption[:1024] if caption else None, # Telegram caption limit + reply_to_message_id=int(reply_to) if reply_to else None, + message_thread_id=int(_photo_thread) if _photo_thread else None, + ) + return SendResult(success=True, message_id=str(msg.message_id)) + except Exception as e: + logger.warning( + "[%s] URL-based send_photo failed, trying file upload: %s", + self.name, + e, + exc_info=True, + ) + # Fallback: download and upload as file (supports up to 10MB) + try: + import httpx + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.get(image_url) + resp.raise_for_status() + image_data = resp.content + + msg = await self._bot.send_photo( + chat_id=int(chat_id), + photo=image_data, + caption=caption[:1024] if caption else None, + reply_to_message_id=int(reply_to) if reply_to else None, + ) + return SendResult(success=True, message_id=str(msg.message_id)) + except Exception as e2: + logger.error( + "[%s] File upload send_photo also failed: %s", + self.name, + e2, + exc_info=True, + ) + # Final fallback: send URL as text + return await super().send_image(chat_id, image_url, caption, reply_to) + + async def send_animation( + self, + chat_id: str, + animation_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an animated GIF natively as a Telegram animation (auto-plays inline).""" + if not self._bot: + return SendResult(success=False, error="Not connected") + + try: + _anim_thread = metadata.get("thread_id") if metadata else None + msg = await self._bot.send_animation( + chat_id=int(chat_id), + animation=animation_url, + caption=caption[:1024] if caption else None, + reply_to_message_id=int(reply_to) if reply_to else None, + message_thread_id=int(_anim_thread) if _anim_thread else None, + ) + return SendResult(success=True, message_id=str(msg.message_id)) + except Exception as e: + logger.error( + "[%s] Failed to send Telegram animation, falling back to photo: %s", + self.name, + e, + exc_info=True, + ) + # Fallback: try as a regular photo + return await self.send_image(chat_id, animation_url, caption, reply_to) + + async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None: + """Send typing indicator.""" + if self._bot: + try: + _typing_thread = metadata.get("thread_id") if metadata else None + await self._bot.send_chat_action( + chat_id=int(chat_id), + action="typing", + message_thread_id=int(_typing_thread) if _typing_thread else None, + ) + except Exception as e: + # Typing failures are non-fatal; log at debug level only. + logger.debug( + "[%s] Failed to send Telegram typing indicator: %s", + self.name, + e, + exc_info=True, + ) + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Get information about a Telegram chat.""" + if not self._bot: + return {"name": "Unknown", "type": "dm"} + + try: + chat = await self._bot.get_chat(int(chat_id)) + + chat_type = "dm" + if chat.type == ChatType.GROUP: + chat_type = "group" + elif chat.type == ChatType.SUPERGROUP: + chat_type = "group" + if chat.is_forum: + chat_type = "forum" + elif chat.type == ChatType.CHANNEL: + chat_type = "channel" + + return { + "name": chat.title or chat.full_name or str(chat_id), + "type": chat_type, + "username": chat.username, + "is_forum": getattr(chat, "is_forum", False), + } + except Exception as e: + logger.error( + "[%s] Failed to get Telegram chat info for %s: %s", + self.name, + chat_id, + e, + exc_info=True, + ) + return {"name": str(chat_id), "type": "dm", "error": str(e)} + + def format_message(self, content: str) -> str: + """ + Convert standard markdown to Telegram MarkdownV2 format. + + Protected regions (code blocks, inline code) are extracted first so + their contents are never modified. Standard markdown constructs + (headers, bold, italic, links) are translated to MarkdownV2 syntax, + and all remaining special characters are escaped. + """ + if not content: + return content + + placeholders: dict = {} + counter = [0] + + def _ph(value: str) -> str: + """Stash *value* behind a placeholder token that survives escaping.""" + key = f"\x00PH{counter[0]}\x00" + counter[0] += 1 + placeholders[key] = value + return key + + text = content + + # 1) Protect fenced code blocks (``` ... ```) + # Per MarkdownV2 spec, \ and ` inside pre/code must be escaped. + def _protect_fenced(m): + raw = m.group(0) + # Split off opening ``` (with optional language) and closing ``` + open_end = raw.index('\n') + 1 if '\n' in raw[3:] else 3 + opening = raw[:open_end] + body_and_close = raw[open_end:] + body = body_and_close[:-3] + body = body.replace('\\', '\\\\').replace('`', '\\`') + return _ph(opening + body + '```') + + text = re.sub( + r'(```(?:[^\n]*\n)?[\s\S]*?```)', + _protect_fenced, + text, + ) + + # 2) Protect inline code (`...`) + # Escape \ inside inline code per MarkdownV2 spec. + text = re.sub( + r'(`[^`]+`)', + lambda m: _ph(m.group(0).replace('\\', '\\\\')), + text, + ) + + # 3) Convert markdown links – escape the display text; inside the URL + # only ')' and '\' need escaping per the MarkdownV2 spec. + def _convert_link(m): + display = _escape_mdv2(m.group(1)) + url = m.group(2).replace('\\', '\\\\').replace(')', '\\)') + return _ph(f'[{display}]({url})') + + text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', _convert_link, text) + + # 4) Convert markdown headers (## Title) → bold *Title* + def _convert_header(m): + inner = m.group(1).strip() + # Strip redundant bold markers that may appear inside a header + inner = re.sub(r'\*\*(.+?)\*\*', r'\1', inner) + return _ph(f'*{_escape_mdv2(inner)}*') + + text = re.sub( + r'^#{1,6}\s+(.+)$', _convert_header, text, flags=re.MULTILINE + ) + + # 5) Convert bold: **text** → *text* (MarkdownV2 bold) + text = re.sub( + r'\*\*(.+?)\*\*', + lambda m: _ph(f'*{_escape_mdv2(m.group(1))}*'), + text, + ) + + # 6) Convert italic: *text* (single asterisk) → _text_ (MarkdownV2 italic) + # [^*\n]+ prevents matching across newlines (which would corrupt + # bullet lists using * markers and multi-line content). + text = re.sub( + r'\*([^*\n]+)\*', + lambda m: _ph(f'_{_escape_mdv2(m.group(1))}_'), + text, + ) + + # 7) Convert strikethrough: ~~text~~ → ~text~ (MarkdownV2) + text = re.sub( + r'~~(.+?)~~', + lambda m: _ph(f'~{_escape_mdv2(m.group(1))}~'), + text, + ) + + # 8) Convert spoiler: ||text|| → ||text|| (protect from | escaping) + text = re.sub( + r'\|\|(.+?)\|\|', + lambda m: _ph(f'||{_escape_mdv2(m.group(1))}||'), + text, + ) + + # 9) Convert blockquotes: > at line start → protect > from escaping + text = re.sub( + r'^(>{1,3}) (.+)$', + lambda m: _ph(m.group(1) + ' ' + _escape_mdv2(m.group(2))), + text, + flags=re.MULTILINE, + ) + + # 10) Escape remaining special characters in plain text + text = _escape_mdv2(text) + + # 11) Restore placeholders in reverse insertion order so that + # nested references (a placeholder inside another) resolve correctly. + for key in reversed(list(placeholders.keys())): + text = text.replace(key, placeholders[key]) + + # 12) Safety net: escape unescaped ( ) { } that slipped through + # placeholder processing. Split the text into code/non-code + # segments so we never touch content inside ``` or ` spans. + _code_split = re.split(r'(```[\s\S]*?```|`[^`]+`)', text) + _safe_parts = [] + for _idx, _seg in enumerate(_code_split): + if _idx % 2 == 1: + # Inside code span/block — leave untouched + _safe_parts.append(_seg) + else: + # Outside code — escape bare ( ) { } + def _esc_bare(m, _seg=_seg): + s = m.start() + ch = m.group(0) + # Already escaped + if s > 0 and _seg[s - 1] == '\\': + return ch + # ( that opens a MarkdownV2 link [text](url) + if ch == '(' and s > 0 and _seg[s - 1] == ']': + return ch + # ) that closes a link URL + if ch == ')': + before = _seg[:s] + if '](http' in before or '](' in before: + # Check depth + depth = 0 + for j in range(s - 1, max(s - 2000, -1), -1): + if _seg[j] == '(': + depth -= 1 + if depth < 0: + if j > 0 and _seg[j - 1] == ']': + return ch + break + elif _seg[j] == ')': + depth += 1 + return '\\' + ch + _safe_parts.append(re.sub(r'[(){}]', _esc_bare, _seg)) + text = ''.join(_safe_parts) + + return text + + # ── Group mention gating ────────────────────────────────────────────── + + def _telegram_require_mention(self) -> bool: + """Return whether group chats should require an explicit bot trigger.""" + configured = self.config.extra.get("require_mention") + if configured is not None: + if isinstance(configured, str): + return configured.lower() in ("true", "1", "yes", "on") + return bool(configured) + return os.getenv("TELEGRAM_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on") + + def _telegram_free_response_chats(self) -> set[str]: + raw = self.config.extra.get("free_response_chats") + if raw is None: + raw = os.getenv("TELEGRAM_FREE_RESPONSE_CHATS", "") + if isinstance(raw, list): + return {str(part).strip() for part in raw if str(part).strip()} + return {part.strip() for part in str(raw).split(",") if part.strip()} + + def _telegram_ignored_threads(self) -> set[int]: + raw = self.config.extra.get("ignored_threads") + if raw is None: + raw = os.getenv("TELEGRAM_IGNORED_THREADS", "") + + if isinstance(raw, list): + values = raw + else: + values = str(raw).split(",") + + ignored: set[int] = set() + for value in values: + text = str(value).strip() + if not text: + continue + try: + ignored.add(int(text)) + except (TypeError, ValueError): + logger.warning("[%s] Ignoring invalid Telegram thread id: %r", self.name, value) + return ignored + + def _compile_mention_patterns(self) -> List[re.Pattern]: + """Compile optional regex wake-word patterns for group triggers.""" + patterns = self.config.extra.get("mention_patterns") + if patterns is None: + raw = os.getenv("TELEGRAM_MENTION_PATTERNS", "").strip() + if raw: + try: + loaded = json.loads(raw) + except Exception: + loaded = [part.strip() for part in raw.splitlines() if part.strip()] + if not loaded: + loaded = [part.strip() for part in raw.split(",") if part.strip()] + patterns = loaded + + if patterns is None: + return [] + if isinstance(patterns, str): + patterns = [patterns] + if not isinstance(patterns, list): + logger.warning( + "[%s] telegram mention_patterns must be a list or string; got %s", + self.name, + type(patterns).__name__, + ) + return [] + + compiled: List[re.Pattern] = [] + for pattern in patterns: + if not isinstance(pattern, str) or not pattern.strip(): + continue + try: + compiled.append(re.compile(pattern, re.IGNORECASE)) + except re.error as exc: + logger.warning("[%s] Invalid Telegram mention pattern %r: %s", self.name, pattern, exc) + if compiled: + logger.info("[%s] Loaded %d Telegram mention pattern(s)", self.name, len(compiled)) + return compiled + + def _is_group_chat(self, message: Message) -> bool: + chat = getattr(message, "chat", None) + if not chat: + return False + chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower() + return chat_type in ("group", "supergroup") + + def _is_reply_to_bot(self, message: Message) -> bool: + if not self._bot or not getattr(message, "reply_to_message", None): + return False + reply_user = getattr(message.reply_to_message, "from_user", None) + return bool(reply_user and getattr(reply_user, "id", None) == getattr(self._bot, "id", None)) + + def _message_mentions_bot(self, message: Message) -> bool: + if not self._bot: + return False + + bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower() + bot_id = getattr(self._bot, "id", None) + + def _iter_sources(): + yield getattr(message, "text", None) or "", getattr(message, "entities", None) or [] + yield getattr(message, "caption", None) or "", getattr(message, "caption_entities", None) or [] + + for source_text, entities in _iter_sources(): + if bot_username and f"@{bot_username}" in source_text.lower(): + return True + for entity in entities: + entity_type = str(getattr(entity, "type", "")).split(".")[-1].lower() + if entity_type == "mention" and bot_username: + offset = int(getattr(entity, "offset", -1)) + length = int(getattr(entity, "length", 0)) + if offset < 0 or length <= 0: + continue + if source_text[offset:offset + length].strip().lower() == f"@{bot_username}": + return True + elif entity_type == "text_mention": + user = getattr(entity, "user", None) + if user and getattr(user, "id", None) == bot_id: + return True + return False + + def _message_matches_mention_patterns(self, message: Message) -> bool: + if not self._mention_patterns: + return False + for candidate in (getattr(message, "text", None), getattr(message, "caption", None)): + if not candidate: + continue + for pattern in self._mention_patterns: + if pattern.search(candidate): + return True + return False + + def _clean_bot_trigger_text(self, text: Optional[str]) -> Optional[str]: + if not text or not self._bot or not getattr(self._bot, "username", None): + return text + username = re.escape(self._bot.username) + cleaned = re.sub(rf"(?i)@{username}\b[,:\-]*\s*", "", text).strip() + return cleaned or text + + def _should_process_message(self, message: Message, *, is_command: bool = False) -> bool: + """Apply Telegram group trigger rules. + + DMs remain unrestricted. Group/supergroup messages are accepted when: + - the chat is explicitly allowlisted in ``free_response_chats`` + - ``require_mention`` is disabled + - the message is a command + - the message replies to the bot + - the bot is @mentioned + - the text/caption matches a configured regex wake-word pattern + """ + if not self._is_group_chat(message): + return True + thread_id = getattr(message, "message_thread_id", None) + if thread_id is not None: + try: + if int(thread_id) in self._telegram_ignored_threads(): + return False + except (TypeError, ValueError): + logger.warning("[%s] Ignoring non-numeric Telegram message_thread_id: %r", self.name, thread_id) + if str(getattr(getattr(message, "chat", None), "id", "")) in self._telegram_free_response_chats(): + return True + if not self._telegram_require_mention(): + return True + if is_command: + return True + if self._is_reply_to_bot(message): + return True + if self._message_mentions_bot(message): + return True + return self._message_matches_mention_patterns(message) + + async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle incoming text messages. + + Telegram clients split long messages into multiple updates. Buffer + rapid successive text messages from the same user/chat and aggregate + them into a single MessageEvent before dispatching. + """ + if not update.message or not update.message.text: + return + if not self._should_process_message(update.message): + return + + event = self._build_message_event(update.message, MessageType.TEXT) + event.text = self._clean_bot_trigger_text(event.text) + self._enqueue_text_event(event) + + async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle incoming command messages.""" + if not update.message or not update.message.text: + return + if not self._should_process_message(update.message, is_command=True): + return + + event = self._build_message_event(update.message, MessageType.COMMAND) + await self.handle_message(event) + + async def _handle_location_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle incoming location/venue pin messages.""" + if not update.message: + return + if not self._should_process_message(update.message): + return + + msg = update.message + venue = getattr(msg, "venue", None) + location = getattr(venue, "location", None) if venue else getattr(msg, "location", None) + + if not location: + return + + lat = getattr(location, "latitude", None) + lon = getattr(location, "longitude", None) + if lat is None or lon is None: + return + + # Build a text message with coordinates and context + parts = ["[The user shared a location pin.]"] + if venue: + title = getattr(venue, "title", None) + address = getattr(venue, "address", None) + if title: + parts.append(f"Venue: {title}") + if address: + parts.append(f"Address: {address}") + parts.append(f"latitude: {lat}") + parts.append(f"longitude: {lon}") + parts.append(f"Map: https://www.google.com/maps/search/?api=1&query={lat},{lon}") + parts.append("Ask what they'd like to find nearby (restaurants, cafes, etc.) and any preferences.") + + event = self._build_message_event(msg, MessageType.LOCATION) + event.text = "\n".join(parts) + await self.handle_message(event) + + # ------------------------------------------------------------------ + # Text message aggregation (handles Telegram client-side splits) + # ------------------------------------------------------------------ + + def _text_batch_key(self, event: MessageEvent) -> str: + """Session-scoped key for text message batching.""" + from gateway.session import build_session_key + return build_session_key( + event.source, + group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), + thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + ) + + def _enqueue_text_event(self, event: MessageEvent) -> None: + """Buffer a text event and reset the flush timer. + + When Telegram splits a long user message into multiple updates, + they arrive within a few hundred milliseconds. This method + concatenates them and waits for a short quiet period before + dispatching the combined message. + """ + key = self._text_batch_key(event) + existing = self._pending_text_batches.get(key) + chunk_len = len(event.text or "") + if existing is None: + event._last_chunk_len = chunk_len # type: ignore[attr-defined] + self._pending_text_batches[key] = event + else: + # Append text from the follow-up chunk + if event.text: + existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text + existing._last_chunk_len = chunk_len # type: ignore[attr-defined] + # Merge any media that might be attached + if event.media_urls: + existing.media_urls.extend(event.media_urls) + existing.media_types.extend(event.media_types) + + # Cancel any pending flush and restart the timer + prior_task = self._pending_text_batch_tasks.get(key) + if prior_task and not prior_task.done(): + prior_task.cancel() + self._pending_text_batch_tasks[key] = asyncio.create_task( + self._flush_text_batch(key) + ) + + async def _flush_text_batch(self, key: str) -> None: + """Wait for the quiet period then dispatch the aggregated text. + + Uses a longer delay when the latest chunk is near Telegram's 4096-char + split point, since a continuation chunk is almost certain. + """ + current_task = asyncio.current_task() + try: + # Adaptive delay: if the latest chunk is near Telegram's 4096-char + # split point, a continuation is almost certain — wait longer. + pending = self._pending_text_batches.get(key) + last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0 + if last_len >= self._SPLIT_THRESHOLD: + delay = self._text_batch_split_delay_seconds + else: + delay = self._text_batch_delay_seconds + await asyncio.sleep(delay) + event = self._pending_text_batches.pop(key, None) + if not event: + return + logger.info( + "[Telegram] Flushing text batch %s (%d chars)", + key, len(event.text or ""), + ) + await self.handle_message(event) + finally: + if self._pending_text_batch_tasks.get(key) is current_task: + self._pending_text_batch_tasks.pop(key, None) + + # ------------------------------------------------------------------ + # Photo batching + # ------------------------------------------------------------------ + + def _photo_batch_key(self, event: MessageEvent, msg: Message) -> str: + """Return a batching key for Telegram photos/albums.""" + from gateway.session import build_session_key + session_key = build_session_key( + event.source, + group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), + thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + ) + media_group_id = getattr(msg, "media_group_id", None) + if media_group_id: + return f"{session_key}:album:{media_group_id}" + return f"{session_key}:photo-burst" + + async def _flush_photo_batch(self, batch_key: str) -> None: + """Send a buffered photo burst/album as a single MessageEvent.""" + current_task = asyncio.current_task() + try: + await asyncio.sleep(self._media_batch_delay_seconds) + event = self._pending_photo_batches.pop(batch_key, None) + if not event: + return + logger.info("[Telegram] Flushing photo batch %s with %d image(s)", batch_key, len(event.media_urls)) + await self.handle_message(event) + finally: + if self._pending_photo_batch_tasks.get(batch_key) is current_task: + self._pending_photo_batch_tasks.pop(batch_key, None) + + def _enqueue_photo_event(self, batch_key: str, event: MessageEvent) -> None: + """Merge photo events into a pending batch and schedule flush.""" + existing = self._pending_photo_batches.get(batch_key) + if existing is None: + self._pending_photo_batches[batch_key] = event + else: + existing.media_urls.extend(event.media_urls) + existing.media_types.extend(event.media_types) + if event.text: + existing.text = self._merge_caption(existing.text, event.text) + + prior_task = self._pending_photo_batch_tasks.get(batch_key) + if prior_task and not prior_task.done(): + prior_task.cancel() + + self._pending_photo_batch_tasks[batch_key] = asyncio.create_task(self._flush_photo_batch(batch_key)) + + async def _handle_media_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle incoming media messages, downloading images to local cache.""" + if not update.message: + return + if not self._should_process_message(update.message): + return + + msg = update.message + + # Determine media type + if msg.sticker: + msg_type = MessageType.STICKER + elif msg.photo: + msg_type = MessageType.PHOTO + elif msg.video: + msg_type = MessageType.VIDEO + elif msg.audio: + msg_type = MessageType.AUDIO + elif msg.voice: + msg_type = MessageType.VOICE + elif msg.document: + msg_type = MessageType.DOCUMENT + else: + msg_type = MessageType.DOCUMENT + + event = self._build_message_event(msg, msg_type) + + # Add caption as text + if msg.caption: + event.text = self._clean_bot_trigger_text(msg.caption) + + # Handle stickers: describe via vision tool with caching + if msg.sticker: + await self._handle_sticker(msg, event) + await self.handle_message(event) + return + + # Download photo to local image cache so the vision tool can access it + # even after Telegram's ephemeral file URLs expire (~1 hour). + if msg.photo: + try: + # msg.photo is a list of PhotoSize sorted by size; take the largest + photo = msg.photo[-1] + file_obj = await photo.get_file() + # Download the image bytes directly into memory + image_bytes = await file_obj.download_as_bytearray() + # Determine extension from the file path if available + ext = ".jpg" + if file_obj.file_path: + for candidate in [".png", ".webp", ".gif", ".jpeg", ".jpg"]: + if file_obj.file_path.lower().endswith(candidate): + ext = candidate + break + # Save to local cache (for vision tool access) + cached_path = cache_image_from_bytes(bytes(image_bytes), ext=ext) + event.media_urls = [cached_path] + event.media_types = [f"image/{ext.lstrip('.')}" ] + logger.info("[Telegram] Cached user photo at %s", cached_path) + media_group_id = getattr(msg, "media_group_id", None) + if media_group_id: + await self._queue_media_group_event(str(media_group_id), event) + else: + batch_key = self._photo_batch_key(event, msg) + self._enqueue_photo_event(batch_key, event) + return + + except Exception as e: + logger.warning("[Telegram] Failed to cache photo: %s", e, exc_info=True) + + # Download voice/audio messages to cache for STT transcription + if msg.voice: + try: + file_obj = await msg.voice.get_file() + audio_bytes = await file_obj.download_as_bytearray() + cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".ogg") + event.media_urls = [cached_path] + event.media_types = ["audio/ogg"] + logger.info("[Telegram] Cached user voice at %s", cached_path) + except Exception as e: + logger.warning("[Telegram] Failed to cache voice: %s", e, exc_info=True) + elif msg.audio: + try: + file_obj = await msg.audio.get_file() + audio_bytes = await file_obj.download_as_bytearray() + cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".mp3") + event.media_urls = [cached_path] + event.media_types = ["audio/mp3"] + logger.info("[Telegram] Cached user audio at %s", cached_path) + except Exception as e: + logger.warning("[Telegram] Failed to cache audio: %s", e, exc_info=True) + + # Download document files to cache for agent processing + elif msg.document: + doc = msg.document + try: + # Determine file extension + ext = "" + original_filename = doc.file_name or "" + if original_filename: + _, ext = os.path.splitext(original_filename) + ext = ext.lower() + + # If no extension from filename, reverse-lookup from MIME type + if not ext and doc.mime_type: + mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()} + ext = mime_to_ext.get(doc.mime_type, "") + + # Check if supported + if ext not in SUPPORTED_DOCUMENT_TYPES: + supported_list = ", ".join(sorted(SUPPORTED_DOCUMENT_TYPES.keys())) + event.text = ( + f"Unsupported document type '{ext or 'unknown'}'. " + f"Supported types: {supported_list}" + ) + logger.info("[Telegram] Unsupported document type: %s", ext or "unknown") + await self.handle_message(event) + return + + # Check file size (Telegram Bot API limit: 20 MB) + MAX_DOC_BYTES = 20 * 1024 * 1024 + if not doc.file_size or doc.file_size > MAX_DOC_BYTES: + event.text = ( + "The document is too large or its size could not be verified. " + "Maximum: 20 MB." + ) + logger.info("[Telegram] Document too large: %s bytes", doc.file_size) + await self.handle_message(event) + return + + # Download and cache + file_obj = await doc.get_file() + doc_bytes = await file_obj.download_as_bytearray() + raw_bytes = bytes(doc_bytes) + cached_path = cache_document_from_bytes(raw_bytes, original_filename or f"document{ext}") + mime_type = SUPPORTED_DOCUMENT_TYPES[ext] + event.media_urls = [cached_path] + event.media_types = [mime_type] + logger.info("[Telegram] Cached user document at %s", cached_path) + + # For text files, inject content into event.text (capped at 100 KB) + MAX_TEXT_INJECT_BYTES = 100 * 1024 + if ext in (".md", ".txt") and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: + try: + text_content = raw_bytes.decode("utf-8") + display_name = original_filename or f"document{ext}" + display_name = re.sub(r'[^\w.\- ]', '_', display_name) + injection = f"[Content of {display_name}]:\n{text_content}" + if event.text: + event.text = f"{injection}\n\n{event.text}" + else: + event.text = injection + except UnicodeDecodeError: + logger.warning( + "[Telegram] Could not decode text file as UTF-8, skipping content injection", + exc_info=True, + ) + + except Exception as e: + logger.warning("[Telegram] Failed to cache document: %s", e, exc_info=True) + + media_group_id = getattr(msg, "media_group_id", None) + if media_group_id: + await self._queue_media_group_event(str(media_group_id), event) + return + + await self.handle_message(event) + + async def _queue_media_group_event(self, media_group_id: str, event: MessageEvent) -> None: + """Buffer Telegram media-group items so albums arrive as one logical event. + + Telegram delivers albums as multiple updates with a shared media_group_id. + If we forward each item immediately, the gateway thinks the second image is a + new user message and interrupts the first. We debounce briefly and merge the + attachments into a single MessageEvent. + """ + existing = self._media_group_events.get(media_group_id) + if existing is None: + self._media_group_events[media_group_id] = event + else: + existing.media_urls.extend(event.media_urls) + existing.media_types.extend(event.media_types) + if event.text: + existing.text = self._merge_caption(existing.text, event.text) + + prior_task = self._media_group_tasks.get(media_group_id) + if prior_task: + prior_task.cancel() + + self._media_group_tasks[media_group_id] = asyncio.create_task( + self._flush_media_group_event(media_group_id) + ) + + async def _flush_media_group_event(self, media_group_id: str) -> None: + try: + await asyncio.sleep(self.MEDIA_GROUP_WAIT_SECONDS) + event = self._media_group_events.pop(media_group_id, None) + if event is not None: + await self.handle_message(event) + except asyncio.CancelledError: + return + finally: + self._media_group_tasks.pop(media_group_id, None) + + async def _handle_sticker(self, msg: Message, event: "MessageEvent") -> None: + """ + Describe a Telegram sticker via vision analysis, with caching. + + For static stickers (WEBP), we download, analyze with vision, and cache + the description by file_unique_id. For animated/video stickers, we inject + a placeholder noting the emoji. + """ + from gateway.sticker_cache import ( + get_cached_description, + cache_sticker_description, + build_sticker_injection, + build_animated_sticker_injection, + STICKER_VISION_PROMPT, + ) + + sticker = msg.sticker + emoji = sticker.emoji or "" + set_name = sticker.set_name or "" + + # Animated and video stickers can't be analyzed as static images + if sticker.is_animated or sticker.is_video: + event.text = build_animated_sticker_injection(emoji) + return + + # Check the cache first + cached = get_cached_description(sticker.file_unique_id) + if cached: + event.text = build_sticker_injection( + cached["description"], cached.get("emoji", emoji), cached.get("set_name", set_name) + ) + logger.info("[Telegram] Sticker cache hit: %s", sticker.file_unique_id) + return + + # Cache miss -- download and analyze + try: + file_obj = await sticker.get_file() + image_bytes = await file_obj.download_as_bytearray() + cached_path = cache_image_from_bytes(bytes(image_bytes), ext=".webp") + logger.info("[Telegram] Analyzing sticker at %s", cached_path) + + from tools.vision_tools import vision_analyze_tool + import json as _json + + result_json = await vision_analyze_tool( + image_url=cached_path, + user_prompt=STICKER_VISION_PROMPT, + ) + result = _json.loads(result_json) + + if result.get("success"): + description = result.get("analysis", "a sticker") + cache_sticker_description(sticker.file_unique_id, description, emoji, set_name) + event.text = build_sticker_injection(description, emoji, set_name) + else: + # Vision failed -- use emoji as fallback + event.text = build_sticker_injection( + f"a sticker with emoji {emoji}" if emoji else "a sticker", + emoji, set_name, + ) + except Exception as e: + logger.warning("[Telegram] Sticker analysis error: %s", e, exc_info=True) + event.text = build_sticker_injection( + f"a sticker with emoji {emoji}" if emoji else "a sticker", + emoji, set_name, + ) + + def _reload_dm_topics_from_config(self) -> None: + """Re-read dm_topics from config.yaml and load any new thread_ids into cache. + + This allows topics created externally (e.g. by the agent via API) to be + recognized without a gateway restart. + """ + try: + from hermes_constants import get_hermes_home + config_path = get_hermes_home() / "config.yaml" + if not config_path.exists(): + return + + import yaml as _yaml + with open(config_path, "r") as f: + config = _yaml.safe_load(f) or {} + + dm_topics = ( + config.get("platforms", {}) + .get("telegram", {}) + .get("extra", {}) + .get("dm_topics", []) + ) + if not dm_topics: + return + + # Update in-memory config and cache any new thread_ids + self._dm_topics_config = dm_topics + for chat_entry in dm_topics: + cid = chat_entry.get("chat_id") + if not cid: + continue + for t in chat_entry.get("topics", []): + tid = t.get("thread_id") + name = t.get("name") + if tid and name: + cache_key = f"{cid}:{name}" + if cache_key not in self._dm_topics: + self._dm_topics[cache_key] = int(tid) + logger.info( + "[%s] Hot-loaded DM topic from config: %s -> thread_id=%s", + self.name, cache_key, tid, + ) + except Exception as e: + logger.debug("[%s] Failed to reload dm_topics from config: %s", self.name, e) + + def _get_dm_topic_info(self, chat_id: str, thread_id: Optional[str]) -> Optional[Dict[str, Any]]: + """Look up DM topic config by chat_id and thread_id. + + Returns the topic config dict (name, skill, etc.) if this thread_id + matches a known DM topic, or None. + """ + if not thread_id: + return None + + thread_id_int = int(thread_id) + + # Check cached topics first (created by us or loaded at startup) + for key, cached_tid in self._dm_topics.items(): + if cached_tid == thread_id_int and key.startswith(f"{chat_id}:"): + topic_name = key.split(":", 1)[1] + # Find the full config for this topic + for chat_entry in self._dm_topics_config: + if str(chat_entry.get("chat_id")) == chat_id: + for t in chat_entry.get("topics", []): + if t.get("name") == topic_name: + return t + return {"name": topic_name} + + # Not in cache — hot-reload config in case topics were added externally + self._reload_dm_topics_from_config() + + # Check cache again after reload + for key, cached_tid in self._dm_topics.items(): + if cached_tid == thread_id_int and key.startswith(f"{chat_id}:"): + topic_name = key.split(":", 1)[1] + for chat_entry in self._dm_topics_config: + if str(chat_entry.get("chat_id")) == chat_id: + for t in chat_entry.get("topics", []): + if t.get("name") == topic_name: + return t + return {"name": topic_name} + + return None + + def _cache_dm_topic_from_message(self, chat_id: str, thread_id: str, topic_name: str) -> None: + """Cache a thread_id -> topic_name mapping discovered from an incoming message.""" + cache_key = f"{chat_id}:{topic_name}" + if cache_key not in self._dm_topics: + self._dm_topics[cache_key] = int(thread_id) + logger.info( + "[%s] Cached DM topic from message: %s -> thread_id=%s", + self.name, cache_key, thread_id, + ) + + def _build_message_event(self, message: Message, msg_type: MessageType) -> MessageEvent: + """Build a MessageEvent from a Telegram message.""" + chat = message.chat + user = message.from_user + + # Determine chat type + chat_type = "dm" + if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP): + chat_type = "group" + elif chat.type == ChatType.CHANNEL: + chat_type = "channel" + + # Resolve DM topic name and skill binding + thread_id_raw = message.message_thread_id + thread_id_str = str(thread_id_raw) if thread_id_raw else None + chat_topic = None + topic_skill = None + + if chat_type == "dm" and thread_id_str: + topic_info = self._get_dm_topic_info(str(chat.id), thread_id_str) + if topic_info: + chat_topic = topic_info.get("name") + topic_skill = topic_info.get("skill") + + # Also check forum_topic_created service message for topic discovery + if hasattr(message, "forum_topic_created") and message.forum_topic_created: + created_name = message.forum_topic_created.name + if created_name: + self._cache_dm_topic_from_message(str(chat.id), thread_id_str, created_name) + if not chat_topic: + chat_topic = created_name + + elif chat_type == "group" and thread_id_str: + # Group/supergroup forum topic skill binding via config.extra['group_topics'] + group_topics_config: list = self.config.extra.get("group_topics", []) + for chat_entry in group_topics_config: + if str(chat_entry.get("chat_id", "")) == str(chat.id): + for topic in chat_entry.get("topics", []): + tid = topic.get("thread_id") + if tid is not None and str(tid) == thread_id_str: + chat_topic = topic.get("name") + topic_skill = topic.get("skill") + break + break + + # Build source + source = self.build_source( + chat_id=str(chat.id), + chat_name=chat.title or (chat.full_name if hasattr(chat, "full_name") else None), + chat_type=chat_type, + user_id=str(user.id) if user else None, + user_name=user.full_name if user else None, + thread_id=thread_id_str, + chat_topic=chat_topic, + ) + + # Extract reply context if this message is a reply + reply_to_id = None + reply_to_text = None + if message.reply_to_message: + reply_to_id = str(message.reply_to_message.message_id) + reply_to_text = message.reply_to_message.text or message.reply_to_message.caption or None + + return MessageEvent( + text=message.text or "", + message_type=msg_type, + source=source, + raw_message=message, + message_id=str(message.message_id), + reply_to_message_id=reply_to_id, + reply_to_text=reply_to_text, + auto_skill=topic_skill, + timestamp=message.date, + ) + + # ── Message reactions (processing lifecycle) ────────────────────────── + + def _reactions_enabled(self) -> bool: + """Check if message reactions are enabled via config/env.""" + return os.getenv("TELEGRAM_REACTIONS", "false").lower() not in ("false", "0", "no") + + async def _set_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool: + """Set a single emoji reaction on a Telegram message.""" + if not self._bot: + return False + try: + await self._bot.set_message_reaction( + chat_id=int(chat_id), + message_id=int(message_id), + reaction=emoji, + ) + return True + except Exception as e: + logger.debug("[%s] set_message_reaction failed (%s): %s", self.name, emoji, e) + return False + + async def on_processing_start(self, event: MessageEvent) -> None: + """Add an in-progress reaction when message processing begins.""" + if not self._reactions_enabled(): + return + chat_id = getattr(event.source, "chat_id", None) + message_id = getattr(event, "message_id", None) + if chat_id and message_id: + await self._set_reaction(chat_id, message_id, "\U0001f440") + + async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None: + """Swap the in-progress reaction for a final success/failure reaction. + + Unlike Discord (additive reactions), Telegram's set_message_reaction + replaces all existing reactions in one call — no remove step needed. + """ + if not self._reactions_enabled(): + return + chat_id = getattr(event.source, "chat_id", None) + message_id = getattr(event, "message_id", None) + if chat_id and message_id and outcome != ProcessingOutcome.CANCELLED: + await self._set_reaction( + chat_id, + message_id, + "\U0001f44d" if outcome == ProcessingOutcome.SUCCESS else "\U0001f44e", + ) diff --git a/mindcli/_vendor/gateway/platforms/telegram_network.py b/mindcli/_vendor/gateway/platforms/telegram_network.py new file mode 100644 index 0000000..4fca934 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/telegram_network.py @@ -0,0 +1,246 @@ +"""Telegram-specific network helpers. + +Provides a hostname-preserving fallback transport for networks where +api.telegram.org resolves to an endpoint that is unreachable from the current +host. The transport keeps the logical request host and TLS SNI as +api.telegram.org while retrying the TCP connection against one or more fallback +IPv4 addresses. +""" + +from __future__ import annotations + +import asyncio +import ipaddress +import logging +import socket +from typing import Iterable, Optional + +import httpx + +logger = logging.getLogger(__name__) + +_TELEGRAM_API_HOST = "api.telegram.org" + +# DNS-over-HTTPS providers used to discover Telegram API IPs that may differ +# from the (potentially unreachable) IP returned by the local system resolver. +_DOH_TIMEOUT = 4.0 # seconds — bounded so connect() isn't noticeably delayed + +_DOH_PROVIDERS: list[dict] = [ + { + "url": "https://dns.google/resolve", + "params": {"name": _TELEGRAM_API_HOST, "type": "A"}, + "headers": {}, + }, + { + "url": "https://cloudflare-dns.com/dns-query", + "params": {"name": _TELEGRAM_API_HOST, "type": "A"}, + "headers": {"Accept": "application/dns-json"}, + }, +] + +# Last-resort IPs when DoH is also blocked. These are stable Telegram Bot API +# endpoints in the 149.154.160.0/20 block (same seed used by OpenClaw). +_SEED_FALLBACK_IPS: list[str] = ["149.154.167.220"] + + +def _resolve_proxy_url() -> str | None: + # Delegate to shared implementation (env vars + macOS system proxy detection) + from gateway.platforms.base import resolve_proxy_url + return resolve_proxy_url() + + +class TelegramFallbackTransport(httpx.AsyncBaseTransport): + """Retry Telegram Bot API requests via fallback IPs while preserving TLS/SNI. + + Requests continue to target https://api.telegram.org/... logically, but on + connect failures the underlying TCP connection is retried against a known + reachable IP. This is effectively the programmatic equivalent of + ``curl --resolve api.telegram.org:443:<ip>``. + """ + + def __init__(self, fallback_ips: Iterable[str], **transport_kwargs): + self._fallback_ips = [ip for ip in dict.fromkeys(_normalize_fallback_ips(fallback_ips))] + proxy_url = _resolve_proxy_url() + if proxy_url and "proxy" not in transport_kwargs: + transport_kwargs["proxy"] = proxy_url + self._primary = httpx.AsyncHTTPTransport(**transport_kwargs) + self._fallbacks = { + ip: httpx.AsyncHTTPTransport(**transport_kwargs) for ip in self._fallback_ips + } + self._sticky_ip: Optional[str] = None + self._sticky_lock = asyncio.Lock() + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + if request.url.host != _TELEGRAM_API_HOST or not self._fallback_ips: + return await self._primary.handle_async_request(request) + + sticky_ip = self._sticky_ip + attempt_order: list[Optional[str]] = [sticky_ip] if sticky_ip else [None] + for ip in self._fallback_ips: + if ip != sticky_ip: + attempt_order.append(ip) + + last_error: Exception | None = None + for ip in attempt_order: + candidate = request if ip is None else _rewrite_request_for_ip(request, ip) + transport = self._primary if ip is None else self._fallbacks[ip] + try: + response = await transport.handle_async_request(candidate) + if ip is not None and self._sticky_ip != ip: + async with self._sticky_lock: + if self._sticky_ip != ip: + self._sticky_ip = ip + logger.warning( + "[Telegram] Primary api.telegram.org path unreachable; using sticky fallback IP %s", + ip, + ) + return response + except Exception as exc: + last_error = exc + if not _is_retryable_connect_error(exc): + raise + if ip is None: + logger.warning( + "[Telegram] Primary api.telegram.org connection failed (%s); trying fallback IPs %s", + exc, + ", ".join(self._fallback_ips), + ) + continue + logger.warning("[Telegram] Fallback IP %s failed: %s", ip, exc) + continue + + if last_error is None: + raise RuntimeError("All Telegram fallback IPs exhausted but no error was recorded") + raise last_error + + async def aclose(self) -> None: + await self._primary.aclose() + for transport in self._fallbacks.values(): + await transport.aclose() + + +def _normalize_fallback_ips(values: Iterable[str]) -> list[str]: + normalized: list[str] = [] + for value in values: + raw = str(value).strip() + if not raw: + continue + try: + addr = ipaddress.ip_address(raw) + except ValueError: + logger.warning("Ignoring invalid Telegram fallback IP: %r", raw) + continue + if addr.version != 4: + logger.warning("Ignoring non-IPv4 Telegram fallback IP: %s", raw) + continue + if addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_unspecified: + logger.warning("Ignoring private/internal Telegram fallback IP: %s", raw) + continue + normalized.append(str(addr)) + return normalized + + +def parse_fallback_ip_env(value: str | None) -> list[str]: + if not value: + return [] + parts = [part.strip() for part in value.split(",")] + return _normalize_fallback_ips(parts) + + +def _resolve_system_dns() -> set[str]: + """Return the IPv4 addresses that the OS resolver gives for api.telegram.org.""" + try: + results = socket.getaddrinfo(_TELEGRAM_API_HOST, 443, socket.AF_INET) + return {addr[4][0] for addr in results} + except Exception: + return set() + + +async def _query_doh_provider( + client: httpx.AsyncClient, provider: dict +) -> list[str]: + """Query one DoH provider and return A-record IPs.""" + try: + resp = await client.get( + provider["url"], params=provider["params"], headers=provider["headers"] + ) + resp.raise_for_status() + data = resp.json() + ips: list[str] = [] + for answer in data.get("Answer", []): + if answer.get("type") != 1: # A record + continue + raw = answer.get("data", "").strip() + try: + ipaddress.ip_address(raw) + ips.append(raw) + except ValueError: + continue + return ips + except Exception as exc: + logger.debug("DoH query to %s failed: %s", provider["url"], exc) + return [] + + +async def discover_fallback_ips() -> list[str]: + """Auto-discover Telegram API IPs via DNS-over-HTTPS. + + Resolves api.telegram.org through Google and Cloudflare DoH, collects all + unique IPs, and excludes the system-DNS-resolved IP (which is presumably + unreachable on this network). Falls back to a hardcoded seed list when DoH + is also unavailable. + """ + async with httpx.AsyncClient(timeout=httpx.Timeout(_DOH_TIMEOUT)) as client: + doh_tasks = [_query_doh_provider(client, p) for p in _DOH_PROVIDERS] + system_dns_task = asyncio.to_thread(_resolve_system_dns) + results = await asyncio.gather(system_dns_task, *doh_tasks, return_exceptions=True) + + # results[0] = system DNS IPs (set), results[1:] = DoH IP lists + system_ips: set[str] = results[0] if isinstance(results[0], set) else set() + + doh_ips: list[str] = [] + for r in results[1:]: + if isinstance(r, list): + doh_ips.extend(r) + + # Deduplicate preserving order, exclude system-DNS IPs + seen: set[str] = set() + candidates: list[str] = [] + for ip in doh_ips: + if ip not in seen and ip not in system_ips: + seen.add(ip) + candidates.append(ip) + + # Validate through existing normalization + validated = _normalize_fallback_ips(candidates) + + if validated: + logger.debug("Discovered Telegram fallback IPs via DoH: %s", ", ".join(validated)) + return validated + + logger.info( + "DoH discovery yielded no new IPs (system DNS: %s); using seed fallback IPs %s", + ", ".join(system_ips) or "unknown", + ", ".join(_SEED_FALLBACK_IPS), + ) + return list(_SEED_FALLBACK_IPS) + + +def _rewrite_request_for_ip(request: httpx.Request, ip: str) -> httpx.Request: + original_host = request.url.host or _TELEGRAM_API_HOST + url = request.url.copy_with(host=ip) + headers = request.headers.copy() + headers["host"] = original_host + extensions = dict(request.extensions) + extensions["sni_hostname"] = original_host + return httpx.Request( + method=request.method, + url=url, + headers=headers, + stream=request.stream, + extensions=extensions, + ) + + +def _is_retryable_connect_error(exc: Exception) -> bool: + return isinstance(exc, (httpx.ConnectTimeout, httpx.ConnectError)) diff --git a/mindcli/_vendor/gateway/platforms/voice2md_atoms.py b/mindcli/_vendor/gateway/platforms/voice2md_atoms.py new file mode 100644 index 0000000..8bf1418 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/voice2md_atoms.py @@ -0,0 +1,136 @@ +""" +voice2md_atoms.py — Voice2MD 共享原子层 + +Spec 来源: docs/SPEC_voice2md_atomization_design.md §九 +提取自: mindos_sse.py._runOssTranscribePipeline (管线A) + dashscope_realtime._finalize (管线B) + +共享原子: + ④ persist_audio_result() — 统一 DB 持久化 + ⑤ push_md_appended() — 统一 SSE 推送 +""" + +import json +import logging + +logger = logging.getLogger("voice2md_atoms") + + +# ─── ④ db_persist: 统一音频结果持久化 ───────────────────────── + +def persist_audio_result( + chat_id: str, + user_id: str, + file_name: str, + md_path: str, + chars: int, + md_content: str, + oss_read_url: str = "", +) -> None: + """将音频转写结果写入 SessionDB。 + + 管线 A (Import) 和管线 B (Live) 共用此函数。 + payload 结构统一为 {type:"audio", fileName, mdPath, chars, ossReadUrl, mdContent}。 + + Raises: + 不抛出异常。失败仅 logger.warning。 + """ + try: + from hermes_state import SessionDB # type: ignore + db = SessionDB() + db.create_session( + session_id=chat_id, source="mindos", + user_id=user_id, model="audio", + ) + db.append_message( + session_id=chat_id, + role="assistant", + content=json.dumps({ + "type": "audio", + "fileName": file_name, + "mdPath": md_path, + "chars": chars, + "ossReadUrl": oss_read_url, + "mdContent": md_content, + }, ensure_ascii=False), + ) + logger.info("[voice2md] DB persist ok chatId=%s chars=%d", chat_id, chars) + except Exception as e: + logger.warning("[voice2md] DB persist failed (non-fatal): %s", e) + + +# ─── ⑤ sse_push: 统一 SSE md:appended 推送 ────────────────── + +def push_md_appended( + sse_server, + user_id: str, + chat_id: str, + file: str, + chars: int, + md_content: str, + message: str = "", + source: str = "mic", +) -> None: + """推送 SSE md:appended 事件到前端。 + + 管线 A 和管线 B 共用此函数。 + chars=0 时跳过推送(无内容不扰前端)。 + + Args: + sse_server: MindOSSSEServer 实例(具有 _pushEvent 方法)。 + 管线 A 传 self,管线 B 传 _sse_server 模块级变量。 + source: 音频来源标识("mic" 或 "system"),前端据此区分气泡类型。 + """ + if chars == 0: + logger.info("[voice2md] 无转写内容,跳过 SSE 推送 chatId=%s", chat_id) + return + + if not sse_server: + logger.warning("[voice2md] SSE push 跳过: sse_server 未注入") + return + + if not message: + message = f"✅ 转写完成({chars} 字)" + + try: + sse_server._pushEvent(user_id, "md:appended", { + "chatId": chat_id, + "file": file, + "chars": chars, + "mdContent": md_content, + "message": message, + "source": source, + }) + logger.info("[voice2md] SSE md:appended pushed userId=%s chars=%d source=%s", user_id, chars, source) + except Exception as e: + logger.warning("[voice2md] SSE push failed: %s", e) + + +# ─── ASR 积分扣减(管线 A/B 各自计费方式不同,不统一) ───────── + +def deduct_asr_credits( + user_id: str, + chat_id: str, + credits: int, + tx_type: str, + model: str, + seconds: float, +) -> None: + """ASR 积分扣减。管线 A 和 B 调用时传不同的 tx_type 和 model。""" + try: + from hermes_state import SessionDB # type: ignore + db = SessionDB() + db.deduct_credits( + user_id=user_id, + credits=credits, + tx_type=tx_type, + session_id=chat_id, + model=model, + raw_metric=json.dumps({"seconds": round(seconds, 1)}), + ) + logger.info( + "[ASR Cost] userId=%s model=%s type=%s seconds=%.1f credits=%d chatId=%s", + user_id, model, tx_type, seconds, credits, chat_id, + ) + except Exception as e: + logger.warning("[voice2md] credit deduction failed: %s", e) diff --git a/mindcli/_vendor/gateway/platforms/voice_import.py b/mindcli/_vendor/gateway/platforms/voice_import.py new file mode 100644 index 0000000..7d68b71 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/voice_import.py @@ -0,0 +1,99 @@ +""" +voice_import.py — Voice2MD 管线 A:离线导入(原子组合器) + +Spec 来源: docs/SPEC_voice2md_atomization_design.md §二 +提取自: mindos_sse.py._runOssTranscribePipeline() 82 行 → 本文件 ~30 行编排 + +原子组合: + ⑦ asr_batch (flash_asr.transcribe_from_oss_url) + ⑧ md_writer (md_converter.MdConverter) + ④ db_persist (voice2md_atoms.persist_audio_result) + ⑤ sse_push (voice2md_atoms.push_md_appended) + + 积分扣减 (voice2md_atoms.deduct_asr_credits) +""" + +import json +import logging +import os + +logger = logging.getLogger("voice_import") + + +async def run( + *, + sse_server, + user_id: str, + chat_id: str, + read_url: str, + oss_key: str, + title: str, +) -> None: + """离线音频文件 → Markdown。管线只做编排,不做逻辑。 + + 调用方: mindos_sse.py 的音频转写路由,通过 asyncio.create_task(voice_import.run(...)) + """ + from flash_asr import transcribe_from_oss_url # type: ignore ⑦ asr_batch + from md_converter import MdConverter # type: ignore ⑧ md_writer + from voice2md_atoms import ( # type: ignore ④⑤ 共享终点 + persist_audio_result, push_md_appended, deduct_asr_credits, + ) + + wiki_root = os.getenv("MINDOS_WIKI_DIR", os.path.expanduser("~/.hermes/wiki")) + wiki_dir = os.path.join(wiki_root, user_id) + converter = MdConverter(wiki_dir) + + original_filename = oss_key.split("/")[-1] + + # 进度推送:开始 + sse_server._pushEvent(user_id, "md:progress", { + "chatId": chat_id, "stage": "asr_start", "filename": original_filename, + }) + + # ⑧ md_writer: 创建文件 + md_file = converter.new_file(title=title, source_filename=original_filename) + total_chars = 0 + last_offset_ms = 0 + + # ⑦ asr_batch: paraformer-v2 异步转写 + async for seg in transcribe_from_oss_url(read_url): + if seg.get("text"): + offset_ms = seg.get("begin_ms", 0) + if offset_ms > last_offset_ms: + last_offset_ms = offset_ms + total_chars += converter.append_segment( + md_file, seg["text"], offset_ms=offset_ms, + ) + + # ⑧ md_writer: 写结束标记 + converter.finalize(md_file, char_count=total_chars) + rel_path = converter.relative_path(md_file) + + # 积分扣减 + audio_seconds = last_offset_ms / 1000.0 + asr_credits = max(1, int(audio_seconds)) + deduct_asr_credits( + user_id=user_id, chat_id=chat_id, credits=asr_credits, + tx_type="asr_file", model="paraformer-v2", seconds=audio_seconds, + ) + + # 读取完整 MD 内容 + md_content = "" + try: + md_content = md_file.read_text(encoding="utf-8") + except Exception: + pass + + # ④ db_persist + persist_audio_result( + chat_id=chat_id, user_id=user_id, + file_name=title, md_path=rel_path, + chars=total_chars, md_content=md_content, + oss_read_url=read_url, + ) + + # ⑤ sse_push + push_md_appended( + sse_server=sse_server, user_id=user_id, chat_id=chat_id, + file=rel_path, chars=total_chars, md_content=md_content, + message=f"✅ 会议记录已入库:{md_file.name}({total_chars} 字)", + ) diff --git a/mindcli/_vendor/gateway/platforms/webhook.py b/mindcli/_vendor/gateway/platforms/webhook.py new file mode 100644 index 0000000..c37445b --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/webhook.py @@ -0,0 +1,672 @@ +"""Generic webhook platform adapter. + +Runs an aiohttp HTTP server that receives webhook POSTs from external +services (GitHub, GitLab, JIRA, Stripe, etc.), validates HMAC signatures, +transforms payloads into agent prompts, and routes responses back to the +source or to another configured platform. + +Configuration lives in config.yaml under platforms.webhook.extra.routes. +Each route defines: + - events: which event types to accept (header-based filtering) + - secret: HMAC secret for signature validation (REQUIRED) + - prompt: template string formatted with the webhook payload + - skills: optional list of skills to load for the agent + - deliver: where to send the response (github_comment, telegram, etc.) + - deliver_extra: additional delivery config (repo, pr_number, chat_id) + +Security: + - HMAC secret is required per route (validated at startup) + - Rate limiting per route (fixed-window, configurable) + - Idempotency cache prevents duplicate agent runs on webhook retries + - Body size limits checked before reading payload + - Set secret to "INSECURE_NO_AUTH" to skip validation (testing only) +""" + +import asyncio +import hashlib +import hmac +import json +import logging +import re +import subprocess +import time +from typing import Any, Dict, List, Optional + +try: + from aiohttp import web + + AIOHTTP_AVAILABLE = True +except ImportError: + AIOHTTP_AVAILABLE = False + web = None # type: ignore[assignment] + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, +) + +logger = logging.getLogger(__name__) + +DEFAULT_HOST = "0.0.0.0" +DEFAULT_PORT = 8644 +_INSECURE_NO_AUTH = "INSECURE_NO_AUTH" +_DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json" + + +def check_webhook_requirements() -> bool: + """Check if webhook adapter dependencies are available.""" + return AIOHTTP_AVAILABLE + + +class WebhookAdapter(BasePlatformAdapter): + """Generic webhook receiver that triggers agent runs from HTTP POSTs.""" + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.WEBHOOK) + self._host: str = config.extra.get("host", DEFAULT_HOST) + self._port: int = int(config.extra.get("port", DEFAULT_PORT)) + self._global_secret: str = config.extra.get("secret", "") + self._static_routes: Dict[str, dict] = config.extra.get("routes", {}) + self._dynamic_routes: Dict[str, dict] = {} + self._dynamic_routes_mtime: float = 0.0 + self._routes: Dict[str, dict] = dict(self._static_routes) + self._runner = None + + # Delivery info keyed by session chat_id. + # + # Read by every send() invocation for the chat_id (status messages + # AND the final response). Cleaned up via TTL on each POST so the + # dict stays bounded — see _prune_delivery_info(). Do NOT pop on + # send(), or interim status messages (e.g. fallback notifications, + # context-pressure warnings) will consume the entry before the + # final response arrives, causing the response to silently fall + # back to the "log" deliver type. + self._delivery_info: Dict[str, dict] = {} + self._delivery_info_created: Dict[str, float] = {} + + # Reference to gateway runner for cross-platform delivery (set externally) + self.gateway_runner = None + + # Idempotency: TTL cache of recently processed delivery IDs. + # Prevents duplicate agent runs when webhook providers retry. + self._seen_deliveries: Dict[str, float] = {} + self._idempotency_ttl: int = 3600 # 1 hour + + # Rate limiting: per-route timestamps in a fixed window. + self._rate_counts: Dict[str, List[float]] = {} + self._rate_limit: int = int(config.extra.get("rate_limit", 30)) # per minute + + # Body size limit (auth-before-body pattern) + self._max_body_bytes: int = int( + config.extra.get("max_body_bytes", 1_048_576) + ) # 1MB + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def connect(self) -> bool: + # Load agent-created subscriptions before validating + self._reload_dynamic_routes() + + # Validate routes at startup — secret is required per route + for name, route in self._routes.items(): + secret = route.get("secret", self._global_secret) + if not secret: + raise ValueError( + f"[webhook] Route '{name}' has no HMAC secret. " + f"Set 'secret' on the route or globally. " + f"For testing without auth, set secret to '{_INSECURE_NO_AUTH}'." + ) + + app = web.Application() + app.router.add_get("/health", self._handle_health) + app.router.add_post("/webhooks/{route_name}", self._handle_webhook) + + # Port conflict detection — fail fast if port is already in use + import socket as _socket + try: + with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s: + _s.settimeout(1) + _s.connect(('127.0.0.1', self._port)) + logger.error('[webhook] Port %d already in use. Set a different port in config.yaml: platforms.webhook.port', self._port) + return False + except (ConnectionRefusedError, OSError): + pass # port is free + + self._runner = web.AppRunner(app) + await self._runner.setup() + site = web.TCPSite(self._runner, self._host, self._port) + await site.start() + self._mark_connected() + + route_names = ", ".join(self._routes.keys()) or "(none configured)" + logger.info( + "[webhook] Listening on %s:%d — routes: %s", + self._host, + self._port, + route_names, + ) + return True + + async def disconnect(self) -> None: + if self._runner: + await self._runner.cleanup() + self._runner = None + self._mark_disconnected() + logger.info("[webhook] Disconnected") + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Deliver the agent's response to the configured destination. + + chat_id is ``webhook:{route}:{delivery_id}``. The delivery info + stored during webhook receipt is read with ``.get()`` (not popped) + so that interim status messages emitted before the final response + — fallback-model notifications, context-pressure warnings, etc. — + do not consume the entry and silently downgrade the final response + to the ``log`` deliver type. TTL cleanup happens on POST. + """ + delivery = self._delivery_info.get(chat_id, {}) + deliver_type = delivery.get("deliver", "log") + + if deliver_type == "log": + logger.info("[webhook] Response for %s: %s", chat_id, content[:200]) + return SendResult(success=True) + + if deliver_type == "github_comment": + return await self._deliver_github_comment(content, delivery) + + # Cross-platform delivery — any platform with a gateway adapter + if self.gateway_runner and deliver_type in ( + "telegram", + "discord", + "slack", + "signal", + "sms", + "whatsapp", + "matrix", + "mattermost", + "homeassistant", + "email", + "dingtalk", + "feishu", + "wecom", + "wecom_callback", + "weixin", + "bluebubbles", + "qqbot", + ): + return await self._deliver_cross_platform( + deliver_type, content, delivery + ) + + logger.warning("[webhook] Unknown deliver type: %s", deliver_type) + return SendResult( + success=False, error=f"Unknown deliver type: {deliver_type}" + ) + + def _prune_delivery_info(self, now: float) -> None: + """Drop delivery_info entries older than the idempotency TTL. + + Mirrors the cleanup pattern used for ``_seen_deliveries``. Called + on each POST so the dict size is bounded by ``rate_limit * TTL`` + even if many webhooks fire and never receive a final response. + """ + cutoff = now - self._idempotency_ttl + stale = [ + k + for k, t in self._delivery_info_created.items() + if t < cutoff + ] + for k in stale: + self._delivery_info.pop(k, None) + self._delivery_info_created.pop(k, None) + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + return {"name": chat_id, "type": "webhook"} + + # ------------------------------------------------------------------ + # HTTP handlers + # ------------------------------------------------------------------ + + async def _handle_health(self, request: "web.Request") -> "web.Response": + """GET /health — simple health check.""" + return web.json_response({"status": "ok", "platform": "webhook"}) + + def _reload_dynamic_routes(self) -> None: + """Reload agent-created subscriptions from disk if the file changed.""" + from hermes_constants import get_hermes_home + hermes_home = get_hermes_home() + subs_path = hermes_home / _DYNAMIC_ROUTES_FILENAME + if not subs_path.exists(): + if self._dynamic_routes: + self._dynamic_routes = {} + self._routes = dict(self._static_routes) + logger.debug("[webhook] Dynamic subscriptions file removed, cleared dynamic routes") + return + try: + mtime = subs_path.stat().st_mtime + if mtime <= self._dynamic_routes_mtime: + return # No change + data = json.loads(subs_path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + return + # Merge: static routes take precedence over dynamic ones + self._dynamic_routes = { + k: v for k, v in data.items() + if k not in self._static_routes + } + self._routes = {**self._dynamic_routes, **self._static_routes} + self._dynamic_routes_mtime = mtime + logger.info( + "[webhook] Reloaded %d dynamic route(s): %s", + len(self._dynamic_routes), + ", ".join(self._dynamic_routes.keys()) or "(none)", + ) + except Exception as e: + logger.error("[webhook] Failed to reload dynamic routes: %s", e) + + async def _handle_webhook(self, request: "web.Request") -> "web.Response": + """POST /webhooks/{route_name} — receive and process a webhook event.""" + # Hot-reload dynamic subscriptions on each request (mtime-gated, cheap) + self._reload_dynamic_routes() + + route_name = request.match_info.get("route_name", "") + route_config = self._routes.get(route_name) + + if not route_config: + return web.json_response( + {"error": f"Unknown route: {route_name}"}, status=404 + ) + + # ── Auth-before-body ───────────────────────────────────── + # Check Content-Length before reading the full payload. + content_length = request.content_length or 0 + if content_length > self._max_body_bytes: + return web.json_response( + {"error": "Payload too large"}, status=413 + ) + + # ── Rate limiting ──────────────────────────────────────── + now = time.time() + window = self._rate_counts.setdefault(route_name, []) + window[:] = [t for t in window if now - t < 60] + if len(window) >= self._rate_limit: + return web.json_response( + {"error": "Rate limit exceeded"}, status=429 + ) + window.append(now) + + # Read body + try: + raw_body = await request.read() + except Exception as e: + logger.error("[webhook] Failed to read body: %s", e) + return web.json_response({"error": "Bad request"}, status=400) + + # Validate HMAC signature (skip for INSECURE_NO_AUTH testing mode) + secret = route_config.get("secret", self._global_secret) + if secret and secret != _INSECURE_NO_AUTH: + if not self._validate_signature(request, raw_body, secret): + logger.warning( + "[webhook] Invalid signature for route %s", route_name + ) + return web.json_response( + {"error": "Invalid signature"}, status=401 + ) + + # Parse payload + try: + payload = json.loads(raw_body) + except json.JSONDecodeError: + # Try form-encoded as fallback + try: + import urllib.parse + + payload = dict( + urllib.parse.parse_qsl(raw_body.decode("utf-8")) + ) + except Exception: + return web.json_response( + {"error": "Cannot parse body"}, status=400 + ) + + # Check event type filter + event_type = ( + request.headers.get("X-GitHub-Event", "") + or request.headers.get("X-GitLab-Event", "") + or payload.get("event_type", "") + or "unknown" + ) + allowed_events = route_config.get("events", []) + if allowed_events and event_type not in allowed_events: + logger.debug( + "[webhook] Ignoring event %s for route %s (allowed: %s)", + event_type, + route_name, + allowed_events, + ) + return web.json_response( + {"status": "ignored", "event": event_type} + ) + + # Format prompt from template + prompt_template = route_config.get("prompt", "") + prompt = self._render_prompt( + prompt_template, payload, event_type, route_name + ) + + # Inject skill content if configured. + # We call build_skill_invocation_message() directly rather than + # using /skill-name slash commands — the gateway's command parser + # would intercept those and break the flow. + skills = route_config.get("skills", []) + if skills: + try: + from agent.skill_commands import ( + build_skill_invocation_message, + get_skill_commands, + ) + + skill_cmds = get_skill_commands() + for skill_name in skills: + cmd_key = f"/{skill_name}" + if cmd_key in skill_cmds: + skill_content = build_skill_invocation_message( + cmd_key, user_instruction=prompt + ) + if skill_content: + prompt = skill_content + break # Load the first matching skill + else: + logger.warning( + "[webhook] Skill '%s' not found", skill_name + ) + except Exception as e: + logger.warning("[webhook] Skill loading failed: %s", e) + + # Build a unique delivery ID + delivery_id = request.headers.get( + "X-GitHub-Delivery", + request.headers.get("X-Request-ID", str(int(time.time() * 1000))), + ) + + # ── Idempotency ───────────────────────────────────────── + # Skip duplicate deliveries (webhook retries). + now = time.time() + # Prune expired entries + self._seen_deliveries = { + k: v + for k, v in self._seen_deliveries.items() + if now - v < self._idempotency_ttl + } + if delivery_id in self._seen_deliveries: + logger.info( + "[webhook] Skipping duplicate delivery %s", delivery_id + ) + return web.json_response( + {"status": "duplicate", "delivery_id": delivery_id}, + status=200, + ) + self._seen_deliveries[delivery_id] = now + + # Use delivery_id in session key so concurrent webhooks on the + # same route get independent agent runs (not queued/interrupted). + session_chat_id = f"webhook:{route_name}:{delivery_id}" + + # Store delivery info for send(). Read by every send() invocation + # for this chat_id (interim status messages and the final response), + # so we do NOT pop on send. TTL-based cleanup keeps the dict bounded. + deliver_config = { + "deliver": route_config.get("deliver", "log"), + "deliver_extra": self._render_delivery_extra( + route_config.get("deliver_extra", {}), payload + ), + "payload": payload, + } + self._delivery_info[session_chat_id] = deliver_config + self._delivery_info_created[session_chat_id] = now + self._prune_delivery_info(now) + + # Build source and event + source = self.build_source( + chat_id=session_chat_id, + chat_name=f"webhook/{route_name}", + chat_type="webhook", + user_id=f"webhook:{route_name}", + user_name=route_name, + ) + event = MessageEvent( + text=prompt, + message_type=MessageType.TEXT, + source=source, + raw_message=payload, + message_id=delivery_id, + ) + + logger.info( + "[webhook] %s event=%s route=%s prompt_len=%d delivery=%s", + request.method, + event_type, + route_name, + len(prompt), + delivery_id, + ) + + # Non-blocking — return 202 Accepted immediately + task = asyncio.create_task(self.handle_message(event)) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + + return web.json_response( + { + "status": "accepted", + "route": route_name, + "event": event_type, + "delivery_id": delivery_id, + }, + status=202, + ) + + # ------------------------------------------------------------------ + # Signature validation + # ------------------------------------------------------------------ + + def _validate_signature( + self, request: "web.Request", body: bytes, secret: str + ) -> bool: + """Validate webhook signature (GitHub, GitLab, generic HMAC-SHA256).""" + # GitHub: X-Hub-Signature-256 = sha256=<hex> + gh_sig = request.headers.get("X-Hub-Signature-256", "") + if gh_sig: + expected = "sha256=" + hmac.new( + secret.encode(), body, hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(gh_sig, expected) + + # GitLab: X-Gitlab-Token = <plain secret> + gl_token = request.headers.get("X-Gitlab-Token", "") + if gl_token: + return hmac.compare_digest(gl_token, secret) + + # Generic: X-Webhook-Signature = <hex HMAC-SHA256> + generic_sig = request.headers.get("X-Webhook-Signature", "") + if generic_sig: + expected = hmac.new( + secret.encode(), body, hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(generic_sig, expected) + + # No recognised signature header but secret is configured → reject + logger.debug( + "[webhook] Secret configured but no signature header found" + ) + return False + + # ------------------------------------------------------------------ + # Prompt rendering + # ------------------------------------------------------------------ + + def _render_prompt( + self, + template: str, + payload: dict, + event_type: str, + route_name: str, + ) -> str: + """Render a prompt template with the webhook payload. + + Supports dot-notation access into nested dicts: + ``{pull_request.title}`` → ``payload["pull_request"]["title"]`` + + Special token ``{__raw__}`` dumps the entire payload as indented + JSON (truncated to 4000 chars). Useful for monitoring alerts or + any webhook where the agent needs to see the full payload. + """ + if not template: + truncated = json.dumps(payload, indent=2)[:4000] + return ( + f"Webhook event '{event_type}' on route " + f"'{route_name}':\n\n```json\n{truncated}\n```" + ) + + def _resolve(match: re.Match) -> str: + key = match.group(1) + # Special token: dump the entire payload as JSON + if key == "__raw__": + return json.dumps(payload, indent=2)[:4000] + value: Any = payload + for part in key.split("."): + if isinstance(value, dict): + value = value.get(part, f"{{{key}}}") + else: + return f"{{{key}}}" + if isinstance(value, (dict, list)): + return json.dumps(value, indent=2)[:2000] + return str(value) + + return re.sub(r"\{([a-zA-Z0-9_.]+)\}", _resolve, template) + + def _render_delivery_extra( + self, extra: dict, payload: dict + ) -> dict: + """Render delivery_extra template values with payload data.""" + rendered: Dict[str, Any] = {} + for key, value in extra.items(): + if isinstance(value, str): + rendered[key] = self._render_prompt(value, payload, "", "") + else: + rendered[key] = value + return rendered + + # ------------------------------------------------------------------ + # Response delivery + # ------------------------------------------------------------------ + + async def _deliver_github_comment( + self, content: str, delivery: dict + ) -> SendResult: + """Post agent response as a GitHub PR/issue comment via ``gh`` CLI.""" + extra = delivery.get("deliver_extra", {}) + repo = extra.get("repo", "") + pr_number = extra.get("pr_number", "") + + if not repo or not pr_number: + logger.error( + "[webhook] github_comment delivery missing repo or pr_number" + ) + return SendResult( + success=False, error="Missing repo or pr_number" + ) + + try: + result = subprocess.run( + [ + "gh", + "pr", + "comment", + str(pr_number), + "--repo", + repo, + "--body", + content, + ], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode == 0: + logger.info( + "[webhook] Posted comment on %s#%s", repo, pr_number + ) + return SendResult(success=True) + else: + logger.error( + "[webhook] gh pr comment failed: %s", result.stderr + ) + return SendResult(success=False, error=result.stderr) + except FileNotFoundError: + logger.error( + "[webhook] 'gh' CLI not found — install GitHub CLI for " + "github_comment delivery" + ) + return SendResult( + success=False, error="gh CLI not installed" + ) + except Exception as e: + logger.error("[webhook] github_comment delivery error: %s", e) + return SendResult(success=False, error=str(e)) + + async def _deliver_cross_platform( + self, platform_name: str, content: str, delivery: dict + ) -> SendResult: + """Route response to another platform (telegram, discord, etc.).""" + if not self.gateway_runner: + return SendResult( + success=False, + error="No gateway runner for cross-platform delivery", + ) + + try: + target_platform = Platform(platform_name) + except ValueError: + return SendResult( + success=False, error=f"Unknown platform: {platform_name}" + ) + + adapter = self.gateway_runner.adapters.get(target_platform) + if not adapter: + return SendResult( + success=False, + error=f"Platform {platform_name} not connected", + ) + + # Use home channel if no specific chat_id in deliver_extra + extra = delivery.get("deliver_extra", {}) + chat_id = extra.get("chat_id", "") + if not chat_id: + home = self.gateway_runner.config.get_home_channel(target_platform) + if home: + chat_id = home.chat_id + else: + return SendResult( + success=False, + error=f"No chat_id or home channel for {platform_name}", + ) + + # Pass thread_id from deliver_extra so Telegram forum topics work + metadata = None + thread_id = extra.get("message_thread_id") or extra.get("thread_id") + if thread_id: + metadata = {"thread_id": thread_id} + + return await adapter.send(chat_id, content, metadata=metadata) diff --git a/mindcli/_vendor/gateway/platforms/wecom.py b/mindcli/_vendor/gateway/platforms/wecom.py new file mode 100644 index 0000000..d43fca6 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/wecom.py @@ -0,0 +1,1430 @@ +""" +WeCom (Enterprise WeChat) platform adapter. + +Uses the WeCom AI Bot WebSocket gateway for inbound and outbound messages. +The adapter focuses on the core gateway path: + +- authenticate via ``aibot_subscribe`` +- receive inbound ``aibot_msg_callback`` events +- send outbound markdown messages via ``aibot_send_msg`` +- upload outbound media via ``aibot_upload_media_*`` and send native attachments +- best-effort download of inbound image/file attachments for agent context + +Configuration in config.yaml: + platforms: + wecom: + enabled: true + extra: + bot_id: "your-bot-id" # or WECOM_BOT_ID env var + secret: "your-secret" # or WECOM_SECRET env var + websocket_url: "wss://openws.work.weixin.qq.com" + dm_policy: "open" # open | allowlist | disabled | pairing + allow_from: ["user_id_1"] + group_policy: "open" # open | allowlist | disabled + group_allow_from: ["group_id_1"] + groups: + group_id_1: + allow_from: ["user_id_1"] +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import json +import logging +import mimetypes +import os +import re +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import unquote, urlparse + +try: + import aiohttp + AIOHTTP_AVAILABLE = True +except ImportError: + AIOHTTP_AVAILABLE = False + aiohttp = None # type: ignore[assignment] + +try: + import httpx + HTTPX_AVAILABLE = True +except ImportError: + HTTPX_AVAILABLE = False + httpx = None # type: ignore[assignment] + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.helpers import MessageDeduplicator +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, + cache_document_from_bytes, + cache_image_from_bytes, +) + +logger = logging.getLogger(__name__) + +DEFAULT_WS_URL = "wss://openws.work.weixin.qq.com" + +APP_CMD_SUBSCRIBE = "aibot_subscribe" +APP_CMD_CALLBACK = "aibot_msg_callback" +APP_CMD_LEGACY_CALLBACK = "aibot_callback" +APP_CMD_EVENT_CALLBACK = "aibot_event_callback" +APP_CMD_SEND = "aibot_send_msg" +APP_CMD_RESPONSE = "aibot_respond_msg" +APP_CMD_PING = "ping" +APP_CMD_UPLOAD_MEDIA_INIT = "aibot_upload_media_init" +APP_CMD_UPLOAD_MEDIA_CHUNK = "aibot_upload_media_chunk" +APP_CMD_UPLOAD_MEDIA_FINISH = "aibot_upload_media_finish" + +CALLBACK_COMMANDS = {APP_CMD_CALLBACK, APP_CMD_LEGACY_CALLBACK} +NON_RESPONSE_COMMANDS = CALLBACK_COMMANDS | {APP_CMD_EVENT_CALLBACK} + +MAX_MESSAGE_LENGTH = 4000 +CONNECT_TIMEOUT_SECONDS = 20.0 +REQUEST_TIMEOUT_SECONDS = 15.0 +HEARTBEAT_INTERVAL_SECONDS = 30.0 +RECONNECT_BACKOFF = [2, 5, 10, 30, 60] + +DEDUP_MAX_SIZE = 1000 + +IMAGE_MAX_BYTES = 10 * 1024 * 1024 +VIDEO_MAX_BYTES = 10 * 1024 * 1024 +VOICE_MAX_BYTES = 2 * 1024 * 1024 +FILE_MAX_BYTES = 20 * 1024 * 1024 +ABSOLUTE_MAX_BYTES = FILE_MAX_BYTES +UPLOAD_CHUNK_SIZE = 512 * 1024 +MAX_UPLOAD_CHUNKS = 100 +VOICE_SUPPORTED_MIMES = {"audio/amr"} + + +def check_wecom_requirements() -> bool: + """Check if WeCom runtime dependencies are available.""" + return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE + + +def _coerce_list(value: Any) -> List[str]: + """Coerce config values into a trimmed string list.""" + if value is None: + return [] + if isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] + if isinstance(value, (list, tuple, set)): + return [str(item).strip() for item in value if str(item).strip()] + return [str(value).strip()] if str(value).strip() else [] + + +def _normalize_entry(raw: str) -> str: + """Normalize allowlist entries such as ``wecom:user:foo``.""" + value = str(raw).strip() + value = re.sub(r"^wecom:", "", value, flags=re.IGNORECASE) + value = re.sub(r"^(user|group):", "", value, flags=re.IGNORECASE) + return value.strip() + + +def _entry_matches(entries: List[str], target: str) -> bool: + """Case-insensitive allowlist match with ``*`` support.""" + normalized_target = str(target).strip().lower() + for entry in entries: + normalized = _normalize_entry(entry).lower() + if normalized == "*" or normalized == normalized_target: + return True + return False + + +class WeComAdapter(BasePlatformAdapter): + """WeCom AI Bot adapter backed by a persistent WebSocket connection.""" + + MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH + # Threshold for detecting WeCom client-side message splits. + # When a chunk is near the 4000-char limit, a continuation is almost certain. + _SPLIT_THRESHOLD = 3900 + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.WECOM) + + extra = config.extra or {} + self._bot_id = str(extra.get("bot_id") or os.getenv("WECOM_BOT_ID", "")).strip() + self._secret = str(extra.get("secret") or os.getenv("WECOM_SECRET", "")).strip() + self._ws_url = str( + extra.get("websocket_url") + or extra.get("websocketUrl") + or os.getenv("WECOM_WEBSOCKET_URL", DEFAULT_WS_URL) + ).strip() or DEFAULT_WS_URL + + self._dm_policy = str(extra.get("dm_policy") or os.getenv("WECOM_DM_POLICY", "open")).strip().lower() + self._allow_from = _coerce_list(extra.get("allow_from") or extra.get("allowFrom")) + + self._group_policy = str(extra.get("group_policy") or os.getenv("WECOM_GROUP_POLICY", "open")).strip().lower() + self._group_allow_from = _coerce_list(extra.get("group_allow_from") or extra.get("groupAllowFrom")) + self._groups = extra.get("groups") if isinstance(extra.get("groups"), dict) else {} + + self._session: Optional["aiohttp.ClientSession"] = None + self._ws: Optional["aiohttp.ClientWebSocketResponse"] = None + self._http_client: Optional["httpx.AsyncClient"] = None + self._listen_task: Optional[asyncio.Task] = None + self._heartbeat_task: Optional[asyncio.Task] = None + self._pending_responses: Dict[str, asyncio.Future] = {} + self._dedup = MessageDeduplicator(max_size=DEDUP_MAX_SIZE) + self._reply_req_ids: Dict[str, str] = {} + + # Text batching: merge rapid successive messages (Telegram-style). + # WeCom clients split long messages around 4000 chars. + self._text_batch_delay_seconds = float(os.getenv("HERMES_WECOM_TEXT_BATCH_DELAY_SECONDS", "0.6")) + self._text_batch_split_delay_seconds = float(os.getenv("HERMES_WECOM_TEXT_BATCH_SPLIT_DELAY_SECONDS", "2.0")) + self._pending_text_batches: Dict[str, MessageEvent] = {} + self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {} + + # ------------------------------------------------------------------ + # Connection lifecycle + # ------------------------------------------------------------------ + + async def connect(self) -> bool: + """Connect to the WeCom AI Bot gateway.""" + if not AIOHTTP_AVAILABLE: + message = "WeCom startup failed: aiohttp not installed" + self._set_fatal_error("wecom_missing_dependency", message, retryable=True) + logger.warning("[%s] %s. Run: pip install aiohttp", self.name, message) + return False + if not HTTPX_AVAILABLE: + message = "WeCom startup failed: httpx not installed" + self._set_fatal_error("wecom_missing_dependency", message, retryable=True) + logger.warning("[%s] %s. Run: pip install httpx", self.name, message) + return False + if not self._bot_id or not self._secret: + message = "WeCom startup failed: WECOM_BOT_ID and WECOM_SECRET are required" + self._set_fatal_error("wecom_missing_credentials", message, retryable=True) + logger.warning("[%s] %s", self.name, message) + return False + + try: + self._http_client = httpx.AsyncClient(timeout=30.0, follow_redirects=True) + await self._open_connection() + self._mark_connected() + self._listen_task = asyncio.create_task(self._listen_loop()) + self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) + logger.info("[%s] Connected to %s", self.name, self._ws_url) + return True + except Exception as exc: + message = f"WeCom startup failed: {exc}" + self._set_fatal_error("wecom_connect_error", message, retryable=True) + logger.error("[%s] Failed to connect: %s", self.name, exc, exc_info=True) + await self._cleanup_ws() + if self._http_client: + await self._http_client.aclose() + self._http_client = None + return False + + async def disconnect(self) -> None: + """Disconnect from WeCom.""" + self._running = False + self._mark_disconnected() + + if self._listen_task: + self._listen_task.cancel() + try: + await self._listen_task + except asyncio.CancelledError: + pass + self._listen_task = None + + if self._heartbeat_task: + self._heartbeat_task.cancel() + try: + await self._heartbeat_task + except asyncio.CancelledError: + pass + self._heartbeat_task = None + + self._fail_pending_responses(RuntimeError("WeCom adapter disconnected")) + await self._cleanup_ws() + + if self._http_client: + await self._http_client.aclose() + self._http_client = None + + self._dedup.clear() + logger.info("[%s] Disconnected", self.name) + + async def _cleanup_ws(self) -> None: + """Close the live websocket/session, if any.""" + if self._ws and not self._ws.closed: + await self._ws.close() + self._ws = None + + if self._session and not self._session.closed: + await self._session.close() + self._session = None + + async def _open_connection(self) -> None: + """Open and authenticate a websocket connection.""" + await self._cleanup_ws() + self._session = aiohttp.ClientSession(trust_env=True) + self._ws = await self._session.ws_connect( + self._ws_url, + heartbeat=HEARTBEAT_INTERVAL_SECONDS * 2, + timeout=CONNECT_TIMEOUT_SECONDS, + ) + + req_id = self._new_req_id("subscribe") + await self._send_json( + { + "cmd": APP_CMD_SUBSCRIBE, + "headers": {"req_id": req_id}, + "body": {"bot_id": self._bot_id, "secret": self._secret}, + } + ) + + auth_payload = await self._wait_for_handshake(req_id) + errcode = auth_payload.get("errcode", 0) + if errcode not in (0, None): + errmsg = auth_payload.get("errmsg", "authentication failed") + raise RuntimeError(f"{errmsg} (errcode={errcode})") + + async def _wait_for_handshake(self, req_id: str) -> Dict[str, Any]: + """Wait for the subscribe acknowledgement.""" + if not self._ws: + raise RuntimeError("WebSocket not initialized") + + deadline = asyncio.get_running_loop().time() + CONNECT_TIMEOUT_SECONDS + while True: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise TimeoutError("Timed out waiting for WeCom subscribe acknowledgement") + + msg = await asyncio.wait_for(self._ws.receive(), timeout=remaining) + if msg.type == aiohttp.WSMsgType.TEXT: + payload = self._parse_json(msg.data) + if not payload: + continue + if payload.get("cmd") == APP_CMD_PING: + continue + if self._payload_req_id(payload) == req_id: + return payload + logger.debug("[%s] Ignoring pre-auth payload: %s", self.name, payload.get("cmd")) + elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR): + raise RuntimeError("WeCom websocket closed during authentication") + + async def _listen_loop(self) -> None: + """Read websocket events forever, reconnecting on errors.""" + backoff_idx = 0 + while self._running: + try: + await self._read_events() + backoff_idx = 0 + except asyncio.CancelledError: + return + except Exception as exc: + if not self._running: + return + logger.warning("[%s] WebSocket error: %s", self.name, exc) + self._fail_pending_responses(RuntimeError("WeCom connection interrupted")) + + delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)] + backoff_idx += 1 + await asyncio.sleep(delay) + + try: + await self._open_connection() + backoff_idx = 0 + logger.info("[%s] Reconnected", self.name) + except Exception as reconnect_exc: + logger.warning("[%s] Reconnect failed: %s", self.name, reconnect_exc) + + async def _read_events(self) -> None: + """Read websocket frames until the connection closes.""" + if not self._ws: + raise RuntimeError("WebSocket not connected") + + while self._running and self._ws and not self._ws.closed: + msg = await self._ws.receive() + if msg.type == aiohttp.WSMsgType.TEXT: + payload = self._parse_json(msg.data) + if payload: + await self._dispatch_payload(payload) + elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + raise RuntimeError("WeCom websocket closed") + + async def _heartbeat_loop(self) -> None: + """Send lightweight application-level pings.""" + try: + while self._running: + await asyncio.sleep(HEARTBEAT_INTERVAL_SECONDS) + if not self._ws or self._ws.closed: + continue + try: + await self._send_json( + { + "cmd": APP_CMD_PING, + "headers": {"req_id": self._new_req_id("ping")}, + "body": {}, + } + ) + except Exception as exc: + logger.debug("[%s] Heartbeat send failed: %s", self.name, exc) + except asyncio.CancelledError: + pass + + async def _dispatch_payload(self, payload: Dict[str, Any]) -> None: + """Route inbound websocket payloads.""" + req_id = self._payload_req_id(payload) + cmd = str(payload.get("cmd") or "") + + if req_id and req_id in self._pending_responses and cmd not in NON_RESPONSE_COMMANDS: + future = self._pending_responses.get(req_id) + if future and not future.done(): + future.set_result(payload) + return + + if cmd in CALLBACK_COMMANDS: + await self._on_message(payload) + return + if cmd in {APP_CMD_PING, APP_CMD_EVENT_CALLBACK}: + return + + logger.debug("[%s] Ignoring websocket payload: %s", self.name, cmd or payload) + + def _fail_pending_responses(self, exc: Exception) -> None: + """Fail all outstanding request futures.""" + for req_id, future in list(self._pending_responses.items()): + if not future.done(): + future.set_exception(exc) + self._pending_responses.pop(req_id, None) + + async def _send_json(self, payload: Dict[str, Any]) -> None: + """Send a raw JSON frame over the active websocket.""" + if not self._ws or self._ws.closed: + raise RuntimeError("WeCom websocket is not connected") + await self._ws.send_json(payload) + + async def _send_request(self, cmd: str, body: Dict[str, Any], timeout: float = REQUEST_TIMEOUT_SECONDS) -> Dict[str, Any]: + """Send a JSON request and await the correlated response.""" + if not self._ws or self._ws.closed: + raise RuntimeError("WeCom websocket is not connected") + + req_id = self._new_req_id(cmd) + future = asyncio.get_running_loop().create_future() + self._pending_responses[req_id] = future + try: + await self._send_json({"cmd": cmd, "headers": {"req_id": req_id}, "body": body}) + response = await asyncio.wait_for(future, timeout=timeout) + return response + finally: + self._pending_responses.pop(req_id, None) + + async def _send_reply_request( + self, + reply_req_id: str, + body: Dict[str, Any], + cmd: str = APP_CMD_RESPONSE, + timeout: float = REQUEST_TIMEOUT_SECONDS, + ) -> Dict[str, Any]: + """Send a reply frame correlated to an inbound callback req_id.""" + if not self._ws or self._ws.closed: + raise RuntimeError("WeCom websocket is not connected") + + normalized_req_id = str(reply_req_id or "").strip() + if not normalized_req_id: + raise ValueError("reply_req_id is required") + + future = asyncio.get_running_loop().create_future() + self._pending_responses[normalized_req_id] = future + try: + await self._send_json( + {"cmd": cmd, "headers": {"req_id": normalized_req_id}, "body": body} + ) + response = await asyncio.wait_for(future, timeout=timeout) + return response + finally: + self._pending_responses.pop(normalized_req_id, None) + + @staticmethod + def _new_req_id(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4().hex}" + + @staticmethod + def _payload_req_id(payload: Dict[str, Any]) -> str: + headers = payload.get("headers") + if isinstance(headers, dict): + return str(headers.get("req_id") or "") + return "" + + @staticmethod + def _parse_json(raw: Any) -> Optional[Dict[str, Any]]: + try: + payload = json.loads(raw) + except Exception: + logger.debug("Failed to parse WeCom payload: %r", raw) + return None + return payload if isinstance(payload, dict) else None + + # ------------------------------------------------------------------ + # Inbound message parsing + # ------------------------------------------------------------------ + + async def _on_message(self, payload: Dict[str, Any]) -> None: + """Process an inbound WeCom message callback event.""" + body = payload.get("body") + if not isinstance(body, dict): + return + + msg_id = str(body.get("msgid") or self._payload_req_id(payload) or uuid.uuid4().hex) + if self._dedup.is_duplicate(msg_id): + logger.debug("[%s] Duplicate message %s ignored", self.name, msg_id) + return + self._remember_reply_req_id(msg_id, self._payload_req_id(payload)) + + sender = body.get("from") if isinstance(body.get("from"), dict) else {} + sender_id = str(sender.get("userid") or "").strip() + chat_id = str(body.get("chatid") or sender_id).strip() + if not chat_id: + logger.debug("[%s] Missing chat id, skipping message", self.name) + return + + is_group = str(body.get("chattype") or "").lower() == "group" + if is_group: + if not self._is_group_allowed(chat_id, sender_id): + logger.debug("[%s] Group %s / sender %s blocked by policy", self.name, chat_id, sender_id) + return + elif not self._is_dm_allowed(sender_id): + logger.debug("[%s] DM sender %s blocked by policy", self.name, sender_id) + return + + text, reply_text = self._extract_text(body) + media_urls, media_types = await self._extract_media(body) + message_type = self._derive_message_type(body, text, media_types) + has_reply_context = bool(reply_text and (text or media_urls)) + + if not text and reply_text and not media_urls: + text = reply_text + + if not text and not media_urls: + logger.debug("[%s] Empty WeCom message skipped", self.name) + return + + source = self.build_source( + chat_id=chat_id, + chat_type="group" if is_group else "dm", + user_id=sender_id or None, + user_name=sender_id or None, + ) + + event = MessageEvent( + text=text, + message_type=message_type, + source=source, + raw_message=payload, + message_id=msg_id, + media_urls=media_urls, + media_types=media_types, + reply_to_message_id=f"quote:{msg_id}" if has_reply_context else None, + reply_to_text=reply_text if has_reply_context else None, + timestamp=datetime.now(tz=timezone.utc), + ) + + # Only batch plain text messages — commands, media, etc. dispatch + # immediately since they won't be split by the WeCom client. + if message_type == MessageType.TEXT and self._text_batch_delay_seconds > 0: + self._enqueue_text_event(event) + else: + await self.handle_message(event) + + # ------------------------------------------------------------------ + # Text message aggregation (handles WeCom client-side splits) + # ------------------------------------------------------------------ + + def _text_batch_key(self, event: MessageEvent) -> str: + """Session-scoped key for text message batching.""" + from gateway.session import build_session_key + return build_session_key( + event.source, + group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), + thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + ) + + def _enqueue_text_event(self, event: MessageEvent) -> None: + """Buffer a text event and reset the flush timer. + + When WeCom splits a long user message at 4000 chars, the chunks + arrive within a few hundred milliseconds. This merges them into + a single event before dispatching. + """ + key = self._text_batch_key(event) + existing = self._pending_text_batches.get(key) + chunk_len = len(event.text or "") + if existing is None: + event._last_chunk_len = chunk_len # type: ignore[attr-defined] + self._pending_text_batches[key] = event + else: + if event.text: + existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text + existing._last_chunk_len = chunk_len # type: ignore[attr-defined] + # Merge any media that might be attached + if event.media_urls: + existing.media_urls.extend(event.media_urls) + existing.media_types.extend(event.media_types) + + # Cancel any pending flush and restart the timer + prior_task = self._pending_text_batch_tasks.get(key) + if prior_task and not prior_task.done(): + prior_task.cancel() + self._pending_text_batch_tasks[key] = asyncio.create_task( + self._flush_text_batch(key) + ) + + async def _flush_text_batch(self, key: str) -> None: + """Wait for the quiet period then dispatch the aggregated text. + + Uses a longer delay when the latest chunk is near WeCom's 4000-char + split point, since a continuation chunk is almost certain. + """ + current_task = asyncio.current_task() + try: + pending = self._pending_text_batches.get(key) + last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0 + if last_len >= self._SPLIT_THRESHOLD: + delay = self._text_batch_split_delay_seconds + else: + delay = self._text_batch_delay_seconds + await asyncio.sleep(delay) + event = self._pending_text_batches.pop(key, None) + if not event: + return + logger.info( + "[WeCom] Flushing text batch %s (%d chars)", + key, len(event.text or ""), + ) + await self.handle_message(event) + finally: + if self._pending_text_batch_tasks.get(key) is current_task: + self._pending_text_batch_tasks.pop(key, None) + + @staticmethod + def _extract_text(body: Dict[str, Any]) -> Tuple[str, Optional[str]]: + """Extract plain text and quoted text from a callback payload.""" + text_parts: List[str] = [] + reply_text: Optional[str] = None + msgtype = str(body.get("msgtype") or "").lower() + + if msgtype == "mixed": + mixed = body.get("mixed") if isinstance(body.get("mixed"), dict) else {} + items = mixed.get("msg_item") if isinstance(mixed.get("msg_item"), list) else [] + for item in items: + if not isinstance(item, dict): + continue + if str(item.get("msgtype") or "").lower() == "text": + text_block = item.get("text") if isinstance(item.get("text"), dict) else {} + content = str(text_block.get("content") or "").strip() + if content: + text_parts.append(content) + else: + text_block = body.get("text") if isinstance(body.get("text"), dict) else {} + content = str(text_block.get("content") or "").strip() + if content: + text_parts.append(content) + + if msgtype == "voice": + voice_block = body.get("voice") if isinstance(body.get("voice"), dict) else {} + voice_text = str(voice_block.get("content") or "").strip() + if voice_text: + text_parts.append(voice_text) + + # Extract appmsg title (filename) for WeCom AI Bot attachments + if msgtype == "appmsg": + appmsg = body.get("appmsg") if isinstance(body.get("appmsg"), dict) else {} + title = str(appmsg.get("title") or "").strip() + if title: + text_parts.append(title) + + quote = body.get("quote") if isinstance(body.get("quote"), dict) else {} + quote_type = str(quote.get("msgtype") or "").lower() + if quote_type == "text": + quote_text = quote.get("text") if isinstance(quote.get("text"), dict) else {} + reply_text = str(quote_text.get("content") or "").strip() or None + elif quote_type == "voice": + quote_voice = quote.get("voice") if isinstance(quote.get("voice"), dict) else {} + reply_text = str(quote_voice.get("content") or "").strip() or None + + return "\n".join(part for part in text_parts if part).strip(), reply_text + + async def _extract_media(self, body: Dict[str, Any]) -> Tuple[List[str], List[str]]: + """Best-effort extraction of inbound media to local cache paths.""" + media_paths: List[str] = [] + media_types: List[str] = [] + refs: List[Tuple[str, Dict[str, Any]]] = [] + msgtype = str(body.get("msgtype") or "").lower() + + if msgtype == "mixed": + mixed = body.get("mixed") if isinstance(body.get("mixed"), dict) else {} + items = mixed.get("msg_item") if isinstance(mixed.get("msg_item"), list) else [] + for item in items: + if not isinstance(item, dict): + continue + item_type = str(item.get("msgtype") or "").lower() + if item_type == "image" and isinstance(item.get("image"), dict): + refs.append(("image", item["image"])) + else: + if isinstance(body.get("image"), dict): + refs.append(("image", body["image"])) + if msgtype == "file" and isinstance(body.get("file"), dict): + refs.append(("file", body["file"])) + # Handle appmsg (WeCom AI Bot attachments with PDF/Word/Excel) + if msgtype == "appmsg" and isinstance(body.get("appmsg"), dict): + appmsg = body["appmsg"] + if isinstance(appmsg.get("file"), dict): + refs.append(("file", appmsg["file"])) + elif isinstance(appmsg.get("image"), dict): + refs.append(("image", appmsg["image"])) + + quote = body.get("quote") if isinstance(body.get("quote"), dict) else {} + quote_type = str(quote.get("msgtype") or "").lower() + if quote_type == "image" and isinstance(quote.get("image"), dict): + refs.append(("image", quote["image"])) + elif quote_type == "file" and isinstance(quote.get("file"), dict): + refs.append(("file", quote["file"])) + + for kind, ref in refs: + cached = await self._cache_media(kind, ref) + if cached: + path, content_type = cached + media_paths.append(path) + media_types.append(content_type) + + return media_paths, media_types + + async def _cache_media(self, kind: str, media: Dict[str, Any]) -> Optional[Tuple[str, str]]: + """Cache an inbound image/file/media reference to local storage.""" + if "base64" in media and media.get("base64"): + try: + raw = self._decode_base64(media["base64"]) + except Exception as exc: + logger.debug("[%s] Failed to decode %s base64 media: %s", self.name, kind, exc) + return None + + if kind == "image": + ext = self._detect_image_ext(raw) + try: + return cache_image_from_bytes(raw, ext), self._mime_for_ext(ext, fallback="image/jpeg") + except ValueError as exc: + logger.warning("[%s] Rejected non-image bytes: %s", self.name, exc) + return None + + filename = str(media.get("filename") or media.get("name") or "wecom_file") + return cache_document_from_bytes(raw, filename), mimetypes.guess_type(filename)[0] or "application/octet-stream" + + url = str(media.get("url") or "").strip() + if not url: + return None + + try: + raw, headers = await self._download_remote_bytes(url, max_bytes=ABSOLUTE_MAX_BYTES) + except Exception as exc: + logger.debug("[%s] Failed to download %s from %s: %s", self.name, kind, url, exc) + return None + + aes_key = str(media.get("aeskey") or "").strip() + if aes_key: + try: + raw = self._decrypt_file_bytes(raw, aes_key) + except Exception as exc: + logger.debug("[%s] Failed to decrypt %s from %s: %s", self.name, kind, url, exc) + return None + + content_type = str(headers.get("content-type") or "").split(";", 1)[0].strip() or "application/octet-stream" + if kind == "image": + ext = self._guess_extension(url, content_type, fallback=self._detect_image_ext(raw)) + try: + return cache_image_from_bytes(raw, ext), content_type or self._mime_for_ext(ext, fallback="image/jpeg") + except ValueError as exc: + logger.warning("[%s] Rejected non-image bytes from %s: %s", self.name, url, exc) + return None + + filename = self._guess_filename(url, headers.get("content-disposition"), content_type) + return cache_document_from_bytes(raw, filename), content_type + + @staticmethod + def _decode_base64(data: str) -> bytes: + payload = data.split(",", 1)[-1].strip() + return base64.b64decode(payload) + + @staticmethod + def _detect_image_ext(data: bytes) -> str: + if data.startswith(b"\x89PNG\r\n\x1a\n"): + return ".png" + if data.startswith(b"\xff\xd8\xff"): + return ".jpg" + if data.startswith((b"GIF87a", b"GIF89a")): + return ".gif" + if data.startswith(b"RIFF") and data[8:12] == b"WEBP": + return ".webp" + return ".jpg" + + @staticmethod + def _mime_for_ext(ext: str, fallback: str = "application/octet-stream") -> str: + return mimetypes.types_map.get(ext.lower(), fallback) + + @staticmethod + def _guess_extension(url: str, content_type: str, fallback: str) -> str: + ext = mimetypes.guess_extension(content_type) if content_type else None + if ext: + return ext + path_ext = Path(urlparse(url).path).suffix + if path_ext: + return path_ext + return fallback + + @staticmethod + def _guess_filename(url: str, content_disposition: Optional[str], content_type: str) -> str: + if content_disposition: + match = re.search(r'filename="?([^";]+)"?', content_disposition) + if match: + return match.group(1) + + name = Path(urlparse(url).path).name or "document" + if "." not in name: + ext = mimetypes.guess_extension(content_type) or ".bin" + name = f"{name}{ext}" + return name + + @staticmethod + def _derive_message_type(body: Dict[str, Any], text: str, media_types: List[str]) -> MessageType: + """Choose the normalized inbound message type.""" + if any(mtype.startswith(("application/", "text/")) for mtype in media_types): + return MessageType.DOCUMENT + if any(mtype.startswith("image/") for mtype in media_types): + return MessageType.TEXT if text else MessageType.PHOTO + if str(body.get("msgtype") or "").lower() == "voice": + return MessageType.VOICE + return MessageType.TEXT + + # ------------------------------------------------------------------ + # Policy helpers + # ------------------------------------------------------------------ + + def _is_dm_allowed(self, sender_id: str) -> bool: + if self._dm_policy == "disabled": + return False + if self._dm_policy == "allowlist": + return _entry_matches(self._allow_from, sender_id) + return True + + def _is_group_allowed(self, chat_id: str, sender_id: str) -> bool: + if self._group_policy == "disabled": + return False + if self._group_policy == "allowlist" and not _entry_matches(self._group_allow_from, chat_id): + return False + + group_cfg = self._resolve_group_cfg(chat_id) + sender_allow = _coerce_list(group_cfg.get("allow_from") or group_cfg.get("allowFrom")) + if sender_allow: + return _entry_matches(sender_allow, sender_id) + return True + + def _resolve_group_cfg(self, chat_id: str) -> Dict[str, Any]: + if not isinstance(self._groups, dict): + return {} + if chat_id in self._groups and isinstance(self._groups[chat_id], dict): + return self._groups[chat_id] + lowered = chat_id.lower() + for key, value in self._groups.items(): + if isinstance(key, str) and key.lower() == lowered and isinstance(value, dict): + return value + wildcard = self._groups.get("*") + return wildcard if isinstance(wildcard, dict) else {} + + def _remember_reply_req_id(self, message_id: str, req_id: str) -> None: + normalized_message_id = str(message_id or "").strip() + normalized_req_id = str(req_id or "").strip() + if not normalized_message_id or not normalized_req_id: + return + self._reply_req_ids[normalized_message_id] = normalized_req_id + while len(self._reply_req_ids) > DEDUP_MAX_SIZE: + self._reply_req_ids.pop(next(iter(self._reply_req_ids))) + + def _reply_req_id_for_message(self, reply_to: Optional[str]) -> Optional[str]: + normalized = str(reply_to or "").strip() + if not normalized or normalized.startswith("quote:"): + return None + return self._reply_req_ids.get(normalized) + + # ------------------------------------------------------------------ + # Outbound messaging + # ------------------------------------------------------------------ + + @staticmethod + def _guess_mime_type(filename: str) -> str: + mime_type = mimetypes.guess_type(filename)[0] + if mime_type: + return mime_type + if Path(filename).suffix.lower() == ".amr": + return "audio/amr" + return "application/octet-stream" + + @staticmethod + def _normalize_content_type(content_type: str, filename: str) -> str: + normalized = str(content_type or "").split(";", 1)[0].strip().lower() + guessed = WeComAdapter._guess_mime_type(filename) + if not normalized: + return guessed + if normalized in {"application/octet-stream", "text/plain"}: + return guessed + return normalized + + @staticmethod + def _detect_wecom_media_type(content_type: str) -> str: + mime_type = str(content_type or "").strip().lower() + if mime_type.startswith("image/"): + return "image" + if mime_type.startswith("video/"): + return "video" + if mime_type.startswith("audio/") or mime_type == "application/ogg": + return "voice" + return "file" + + @staticmethod + def _apply_file_size_limits(file_size: int, detected_type: str, content_type: Optional[str] = None) -> Dict[str, Any]: + file_size_mb = file_size / (1024 * 1024) + normalized_type = str(detected_type or "file").lower() + normalized_content_type = str(content_type or "").strip().lower() + + if file_size > ABSOLUTE_MAX_BYTES: + return { + "final_type": normalized_type, + "rejected": True, + "reject_reason": ( + f"文件大小 {file_size_mb:.2f}MB 超过了企业微信允许的最大限制 20MB,无法发送。" + "请尝试压缩文件或减小文件大小。" + ), + "downgraded": False, + "downgrade_note": None, + } + + if normalized_type == "image" and file_size > IMAGE_MAX_BYTES: + return { + "final_type": "file", + "rejected": False, + "reject_reason": None, + "downgraded": True, + "downgrade_note": f"图片大小 {file_size_mb:.2f}MB 超过 10MB 限制,已转为文件格式发送", + } + + if normalized_type == "video" and file_size > VIDEO_MAX_BYTES: + return { + "final_type": "file", + "rejected": False, + "reject_reason": None, + "downgraded": True, + "downgrade_note": f"视频大小 {file_size_mb:.2f}MB 超过 10MB 限制,已转为文件格式发送", + } + + if normalized_type == "voice": + if normalized_content_type and normalized_content_type not in VOICE_SUPPORTED_MIMES: + return { + "final_type": "file", + "rejected": False, + "reject_reason": None, + "downgraded": True, + "downgrade_note": ( + f"语音格式 {normalized_content_type} 不支持,企微仅支持 AMR 格式,已转为文件格式发送" + ), + } + if file_size > VOICE_MAX_BYTES: + return { + "final_type": "file", + "rejected": False, + "reject_reason": None, + "downgraded": True, + "downgrade_note": f"语音大小 {file_size_mb:.2f}MB 超过 2MB 限制,已转为文件格式发送", + } + + return { + "final_type": normalized_type, + "rejected": False, + "reject_reason": None, + "downgraded": False, + "downgrade_note": None, + } + + @staticmethod + def _response_error(response: Dict[str, Any]) -> Optional[str]: + errcode = response.get("errcode", 0) + if errcode in (0, None): + return None + errmsg = str(response.get("errmsg") or "unknown error") + return f"WeCom errcode {errcode}: {errmsg}" + + @classmethod + def _raise_for_wecom_error(cls, response: Dict[str, Any], operation: str) -> None: + error = cls._response_error(response) + if error: + raise RuntimeError(f"{operation} failed: {error}") + + @staticmethod + def _decrypt_file_bytes(encrypted_data: bytes, aes_key: str) -> bytes: + if not encrypted_data: + raise ValueError("encrypted_data is empty") + if not aes_key: + raise ValueError("aes_key is required") + + key = base64.b64decode(aes_key) + if len(key) != 32: + raise ValueError(f"Invalid WeCom AES key length: expected 32 bytes, got {len(key)}") + + try: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + except ImportError as exc: # pragma: no cover - dependency is environment-specific + raise RuntimeError("cryptography is required for WeCom media decryption") from exc + + cipher = Cipher(algorithms.AES(key), modes.CBC(key[:16])) + decryptor = cipher.decryptor() + decrypted = decryptor.update(encrypted_data) + decryptor.finalize() + + pad_len = decrypted[-1] + if pad_len < 1 or pad_len > 32 or pad_len > len(decrypted): + raise ValueError(f"Invalid PKCS#7 padding value: {pad_len}") + if any(byte != pad_len for byte in decrypted[-pad_len:]): + raise ValueError("Invalid PKCS#7 padding: padding bytes mismatch") + + return decrypted[:-pad_len] + + async def _download_remote_bytes( + self, + url: str, + max_bytes: int, + ) -> Tuple[bytes, Dict[str, str]]: + from tools.url_safety import is_safe_url + if not is_safe_url(url): + raise ValueError(f"Blocked unsafe URL (SSRF protection): {url[:80]}") + + if not HTTPX_AVAILABLE: + raise RuntimeError("httpx is required for WeCom media download") + + client = self._http_client or httpx.AsyncClient(timeout=30.0, follow_redirects=True) + created_client = client is not self._http_client + try: + async with client.stream( + "GET", + url, + headers={ + "User-Agent": "HermesAgent/1.0", + "Accept": "*/*", + }, + ) as response: + response.raise_for_status() + headers = {key.lower(): value for key, value in response.headers.items()} + content_length = headers.get("content-length") + if content_length and content_length.isdigit() and int(content_length) > max_bytes: + raise ValueError( + f"Remote media exceeds WeCom limit: {int(content_length)} bytes > {max_bytes} bytes" + ) + + data = bytearray() + async for chunk in response.aiter_bytes(): + data.extend(chunk) + if len(data) > max_bytes: + raise ValueError( + f"Remote media exceeds WeCom limit while downloading: {len(data)} bytes > {max_bytes} bytes" + ) + + return bytes(data), headers + finally: + if created_client: + await client.aclose() + + @staticmethod + def _looks_like_url(media_source: str) -> bool: + parsed = urlparse(str(media_source or "")) + return parsed.scheme in {"http", "https"} + + async def _load_outbound_media( + self, + media_source: str, + file_name: Optional[str] = None, + ) -> Tuple[bytes, str, str]: + source = str(media_source or "").strip() + if not source: + raise ValueError("media source is required") + if re.fullmatch(r"<[^>\n]+>", source): + raise ValueError(f"Media placeholder was not replaced with a real file path: {source}") + + parsed = urlparse(source) + if parsed.scheme in {"http", "https"}: + data, headers = await self._download_remote_bytes(source, max_bytes=ABSOLUTE_MAX_BYTES) + content_disposition = headers.get("content-disposition") + resolved_name = file_name or self._guess_filename(source, content_disposition, headers.get("content-type", "")) + content_type = self._normalize_content_type(headers.get("content-type", ""), resolved_name) + return data, content_type, resolved_name + + if parsed.scheme == "file": + local_path = Path(unquote(parsed.path)).expanduser() + else: + local_path = Path(source).expanduser() + + if not local_path.is_absolute(): + local_path = (Path.cwd() / local_path).resolve() + + if not local_path.exists() or not local_path.is_file(): + raise FileNotFoundError(f"Media file not found: {local_path}") + + data = local_path.read_bytes() + resolved_name = file_name or local_path.name + content_type = self._normalize_content_type("", resolved_name) + return data, content_type, resolved_name + + async def _prepare_outbound_media( + self, + media_source: str, + file_name: Optional[str] = None, + ) -> Dict[str, Any]: + data, content_type, resolved_name = await self._load_outbound_media(media_source, file_name=file_name) + detected_type = self._detect_wecom_media_type(content_type) + size_check = self._apply_file_size_limits(len(data), detected_type, content_type) + return { + "data": data, + "content_type": content_type, + "file_name": resolved_name, + "detected_type": detected_type, + **size_check, + } + + async def _upload_media_bytes(self, data: bytes, media_type: str, filename: str) -> Dict[str, Any]: + if not data: + raise ValueError("Cannot upload empty media") + + total_size = len(data) + total_chunks = (total_size + UPLOAD_CHUNK_SIZE - 1) // UPLOAD_CHUNK_SIZE + if total_chunks > MAX_UPLOAD_CHUNKS: + raise ValueError( + f"File too large: {total_chunks} chunks exceeds maximum of {MAX_UPLOAD_CHUNKS} chunks" + ) + + init_response = await self._send_request( + APP_CMD_UPLOAD_MEDIA_INIT, + { + "type": media_type, + "filename": filename, + "total_size": total_size, + "total_chunks": total_chunks, + "md5": hashlib.md5(data).hexdigest(), + }, + ) + self._raise_for_wecom_error(init_response, "media upload init") + + init_body = init_response.get("body") if isinstance(init_response.get("body"), dict) else {} + upload_id = str(init_body.get("upload_id") or "").strip() + if not upload_id: + raise RuntimeError(f"media upload init failed: missing upload_id in response {init_response}") + + for chunk_index, start in enumerate(range(0, total_size, UPLOAD_CHUNK_SIZE)): + chunk = data[start : start + UPLOAD_CHUNK_SIZE] + chunk_response = await self._send_request( + APP_CMD_UPLOAD_MEDIA_CHUNK, + { + "upload_id": upload_id, + # Match the official SDK implementation, which currently uses 0-based chunk indexes. + "chunk_index": chunk_index, + "base64_data": base64.b64encode(chunk).decode("ascii"), + }, + ) + self._raise_for_wecom_error(chunk_response, f"media upload chunk {chunk_index}") + + finish_response = await self._send_request( + APP_CMD_UPLOAD_MEDIA_FINISH, + {"upload_id": upload_id}, + ) + self._raise_for_wecom_error(finish_response, "media upload finish") + + finish_body = finish_response.get("body") if isinstance(finish_response.get("body"), dict) else {} + media_id = str(finish_body.get("media_id") or "").strip() + if not media_id: + raise RuntimeError(f"media upload finish failed: missing media_id in response {finish_response}") + + return { + "type": str(finish_body.get("type") or media_type), + "media_id": media_id, + "created_at": finish_body.get("created_at"), + } + + async def _send_media_message(self, chat_id: str, media_type: str, media_id: str) -> Dict[str, Any]: + response = await self._send_request( + APP_CMD_SEND, + { + "chatid": chat_id, + "msgtype": media_type, + media_type: {"media_id": media_id}, + }, + ) + self._raise_for_wecom_error(response, "send media message") + return response + + async def _send_reply_stream(self, reply_req_id: str, content: str) -> Dict[str, Any]: + response = await self._send_reply_request( + reply_req_id, + { + "msgtype": "stream", + "stream": { + "id": self._new_req_id("stream"), + "finish": True, + "content": content[:self.MAX_MESSAGE_LENGTH], + }, + }, + ) + self._raise_for_wecom_error(response, "send reply stream") + return response + + async def _send_reply_media_message( + self, + reply_req_id: str, + media_type: str, + media_id: str, + ) -> Dict[str, Any]: + response = await self._send_reply_request( + reply_req_id, + { + "msgtype": media_type, + media_type: {"media_id": media_id}, + }, + ) + self._raise_for_wecom_error(response, "send reply media message") + return response + + async def _send_followup_markdown( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + ) -> Optional[SendResult]: + if not content: + return None + result = await self.send(chat_id=chat_id, content=content, reply_to=reply_to) + if not result.success: + logger.warning("[%s] Follow-up markdown send failed: %s", self.name, result.error) + return result + + async def _send_media_source( + self, + chat_id: str, + media_source: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + ) -> SendResult: + if not chat_id: + return SendResult(success=False, error="chat_id is required") + + try: + prepared = await self._prepare_outbound_media(media_source, file_name=file_name) + except FileNotFoundError as exc: + return SendResult(success=False, error=str(exc)) + except Exception as exc: + logger.error("[%s] Failed to prepare outbound media %s: %s", self.name, media_source, exc) + return SendResult(success=False, error=str(exc)) + + if prepared["rejected"]: + await self._send_followup_markdown( + chat_id, + f"⚠️ {prepared['reject_reason']}", + reply_to=reply_to, + ) + return SendResult(success=False, error=prepared["reject_reason"]) + + reply_req_id = self._reply_req_id_for_message(reply_to) + try: + upload_result = await self._upload_media_bytes( + prepared["data"], + prepared["final_type"], + prepared["file_name"], + ) + if reply_req_id: + media_response = await self._send_reply_media_message( + reply_req_id, + prepared["final_type"], + upload_result["media_id"], + ) + else: + media_response = await self._send_media_message( + chat_id, + prepared["final_type"], + upload_result["media_id"], + ) + except asyncio.TimeoutError: + return SendResult(success=False, error="Timeout sending media to WeCom") + except Exception as exc: + logger.error("[%s] Failed to send media %s: %s", self.name, media_source, exc) + return SendResult(success=False, error=str(exc)) + + caption_result = None + downgrade_result = None + if caption: + caption_result = await self._send_followup_markdown( + chat_id, + caption, + reply_to=reply_to, + ) + if prepared["downgraded"] and prepared["downgrade_note"]: + downgrade_result = await self._send_followup_markdown( + chat_id, + f"ℹ️ {prepared['downgrade_note']}", + reply_to=reply_to, + ) + + return SendResult( + success=True, + message_id=self._payload_req_id(media_response) or uuid.uuid4().hex[:12], + raw_response={ + "upload": upload_result, + "media": media_response, + "caption": caption_result.raw_response if caption_result else None, + "caption_error": caption_result.error if caption_result and not caption_result.success else None, + "downgrade": downgrade_result.raw_response if downgrade_result else None, + "downgrade_error": downgrade_result.error if downgrade_result and not downgrade_result.success else None, + }, + ) + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send markdown to a WeCom chat via proactive ``aibot_send_msg``.""" + del metadata + + if not chat_id: + return SendResult(success=False, error="chat_id is required") + + try: + reply_req_id = self._reply_req_id_for_message(reply_to) + if reply_req_id: + response = await self._send_reply_stream(reply_req_id, content) + else: + response = await self._send_request( + APP_CMD_SEND, + { + "chatid": chat_id, + "msgtype": "markdown", + "markdown": {"content": content[:self.MAX_MESSAGE_LENGTH]}, + }, + ) + except asyncio.TimeoutError: + return SendResult(success=False, error="Timeout sending message to WeCom") + except Exception as exc: + logger.error("[%s] Send failed: %s", self.name, exc) + return SendResult(success=False, error=str(exc)) + + error = self._response_error(response) + if error: + return SendResult(success=False, error=error) + + return SendResult( + success=True, + message_id=self._payload_req_id(response) or uuid.uuid4().hex[:12], + raw_response=response, + ) + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + del metadata + + result = await self._send_media_source( + chat_id=chat_id, + media_source=image_url, + caption=caption, + reply_to=reply_to, + ) + if result.success or not self._looks_like_url(image_url): + return result + + logger.warning("[%s] Falling back to text send for image URL %s: %s", self.name, image_url, result.error) + fallback_text = f"{caption}\n{image_url}" if caption else image_url + return await self.send(chat_id=chat_id, content=fallback_text, reply_to=reply_to) + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + del kwargs + return await self._send_media_source( + chat_id=chat_id, + media_source=image_path, + caption=caption, + reply_to=reply_to, + ) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + del kwargs + return await self._send_media_source( + chat_id=chat_id, + media_source=file_path, + caption=caption, + file_name=file_name, + reply_to=reply_to, + ) + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + del kwargs + return await self._send_media_source( + chat_id=chat_id, + media_source=audio_path, + caption=caption, + reply_to=reply_to, + ) + + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + del kwargs + return await self._send_media_source( + chat_id=chat_id, + media_source=video_path, + caption=caption, + reply_to=reply_to, + ) + + async def send_typing(self, chat_id: str, metadata=None) -> None: + """WeCom does not expose typing indicators in this adapter.""" + del chat_id, metadata + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Return minimal chat info.""" + return { + "name": chat_id, + "type": "group" if chat_id and chat_id.lower().startswith("group") else "dm", + } diff --git a/mindcli/_vendor/gateway/platforms/wecom_callback.py b/mindcli/_vendor/gateway/platforms/wecom_callback.py new file mode 100644 index 0000000..4bb67d5 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/wecom_callback.py @@ -0,0 +1,387 @@ +"""WeCom callback-mode adapter for self-built enterprise applications. + +Unlike the bot/websocket adapter in ``wecom.py``, this handles the standard +WeCom callback flow: WeCom POSTs encrypted XML to an HTTP endpoint, the +adapter decrypts it, queues the message for the agent, and immediately +acknowledges. The agent's reply is delivered later via the proactive +``message/send`` API using an access-token. + +Supports multiple self-built apps under one gateway instance, scoped by +``corp_id:user_id`` to avoid cross-corp collisions. +""" + +from __future__ import annotations + +import asyncio +import logging +import socket as _socket +import time +from typing import Any, Dict, List, Optional +from xml.etree import ElementTree as ET + +try: + from aiohttp import web + + AIOHTTP_AVAILABLE = True +except ImportError: + web = None # type: ignore[assignment] + AIOHTTP_AVAILABLE = False + +try: + import httpx + + HTTPX_AVAILABLE = True +except ImportError: + httpx = None # type: ignore[assignment] + HTTPX_AVAILABLE = False + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult +from gateway.platforms.wecom_crypto import WXBizMsgCrypt, WeComCryptoError + +logger = logging.getLogger(__name__) + +DEFAULT_HOST = "0.0.0.0" +DEFAULT_PORT = 8645 +DEFAULT_PATH = "/wecom/callback" +ACCESS_TOKEN_TTL_SECONDS = 7200 +MESSAGE_DEDUP_TTL_SECONDS = 300 + + +def check_wecom_callback_requirements() -> bool: + return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE + + +class WecomCallbackAdapter(BasePlatformAdapter): + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.WECOM_CALLBACK) + extra = config.extra or {} + self._host = str(extra.get("host") or DEFAULT_HOST) + self._port = int(extra.get("port") or DEFAULT_PORT) + self._path = str(extra.get("path") or DEFAULT_PATH) + self._apps: List[Dict[str, Any]] = self._normalize_apps(extra) + self._runner: Optional[web.AppRunner] = None + self._site: Optional[web.TCPSite] = None + self._app: Optional[web.Application] = None + self._http_client: Optional[httpx.AsyncClient] = None + self._message_queue: asyncio.Queue[MessageEvent] = asyncio.Queue() + self._poll_task: Optional[asyncio.Task] = None + self._seen_messages: Dict[str, float] = {} + self._user_app_map: Dict[str, str] = {} + self._access_tokens: Dict[str, Dict[str, Any]] = {} + + # ------------------------------------------------------------------ + # App normalisation + # ------------------------------------------------------------------ + + @staticmethod + def _user_app_key(corp_id: str, user_id: str) -> str: + return f"{corp_id}:{user_id}" if corp_id else user_id + + @staticmethod + def _normalize_apps(extra: Dict[str, Any]) -> List[Dict[str, Any]]: + apps = extra.get("apps") + if isinstance(apps, list) and apps: + return [dict(app) for app in apps if isinstance(app, dict)] + if extra.get("corp_id"): + return [ + { + "name": extra.get("name") or "default", + "corp_id": extra.get("corp_id", ""), + "corp_secret": extra.get("corp_secret", ""), + "agent_id": str(extra.get("agent_id", "")), + "token": extra.get("token", ""), + "encoding_aes_key": extra.get("encoding_aes_key", ""), + } + ] + return [] + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def connect(self) -> bool: + if not self._apps: + logger.warning("[WecomCallback] No callback apps configured") + return False + if not check_wecom_callback_requirements(): + logger.warning("[WecomCallback] aiohttp/httpx not installed") + return False + + # Quick port-in-use check. + try: + with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as sock: + sock.settimeout(1) + sock.connect(("127.0.0.1", self._port)) + logger.error("[WecomCallback] Port %d already in use", self._port) + return False + except (ConnectionRefusedError, OSError): + pass + + try: + self._http_client = httpx.AsyncClient(timeout=20.0) + self._app = web.Application() + self._app.router.add_get("/health", self._handle_health) + self._app.router.add_get(self._path, self._handle_verify) + self._app.router.add_post(self._path, self._handle_callback) + self._runner = web.AppRunner(self._app) + await self._runner.setup() + self._site = web.TCPSite(self._runner, self._host, self._port) + await self._site.start() + self._poll_task = asyncio.create_task(self._poll_loop()) + self._mark_connected() + logger.info( + "[WecomCallback] HTTP server listening on %s:%s%s", + self._host, self._port, self._path, + ) + for app in self._apps: + try: + await self._refresh_access_token(app) + except Exception as exc: + logger.warning( + "[WecomCallback] Initial token refresh failed for app '%s': %s", + app.get("name", "default"), exc, + ) + return True + except Exception: + await self._cleanup() + logger.exception("[WecomCallback] Failed to start") + return False + + async def disconnect(self) -> None: + self._running = False + if self._poll_task: + self._poll_task.cancel() + try: + await self._poll_task + except asyncio.CancelledError: + pass + self._poll_task = None + await self._cleanup() + self._mark_disconnected() + logger.info("[WecomCallback] Disconnected") + + async def _cleanup(self) -> None: + self._site = None + if self._runner: + await self._runner.cleanup() + self._runner = None + self._app = None + if self._http_client: + await self._http_client.aclose() + self._http_client = None + + # ------------------------------------------------------------------ + # Outbound: proactive send via access-token API + # ------------------------------------------------------------------ + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + app = self._resolve_app_for_chat(chat_id) + touser = chat_id.split(":", 1)[1] if ":" in chat_id else chat_id + try: + token = await self._get_access_token(app) + payload = { + "touser": touser, + "msgtype": "text", + "agentid": int(str(app.get("agent_id") or 0)), + "text": {"content": content[:2048]}, + "safe": 0, + } + resp = await self._http_client.post( + f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}", + json=payload, + ) + data = resp.json() + if data.get("errcode") != 0: + return SendResult(success=False, error=str(data)) + return SendResult( + success=True, + message_id=str(data.get("msgid", "")), + raw_response=data, + ) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + def _resolve_app_for_chat(self, chat_id: str) -> Dict[str, Any]: + """Pick the app associated with *chat_id*, falling back sensibly.""" + app_name = self._user_app_map.get(chat_id) + if not app_name and ":" not in chat_id: + # Legacy bare user_id — try to find a unique match. + matching = [k for k in self._user_app_map if k.endswith(f":{chat_id}")] + if len(matching) == 1: + app_name = self._user_app_map.get(matching[0]) + app = self._get_app_by_name(app_name) if app_name else None + return app or self._apps[0] + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + return {"name": chat_id, "type": "dm"} + + # ------------------------------------------------------------------ + # Inbound: HTTP callback handlers + # ------------------------------------------------------------------ + + async def _handle_health(self, request: web.Request) -> web.Response: + return web.json_response({"status": "ok", "platform": "wecom_callback"}) + + async def _handle_verify(self, request: web.Request) -> web.Response: + """GET endpoint — WeCom URL verification handshake.""" + msg_signature = request.query.get("msg_signature", "") + timestamp = request.query.get("timestamp", "") + nonce = request.query.get("nonce", "") + echostr = request.query.get("echostr", "") + for app in self._apps: + try: + crypt = self._crypt_for_app(app) + plain = crypt.verify_url(msg_signature, timestamp, nonce, echostr) + return web.Response(text=plain, content_type="text/plain") + except Exception: + continue + return web.Response(status=403, text="signature verification failed") + + async def _handle_callback(self, request: web.Request) -> web.Response: + """POST endpoint — receive an encrypted message callback.""" + msg_signature = request.query.get("msg_signature", "") + timestamp = request.query.get("timestamp", "") + nonce = request.query.get("nonce", "") + body = await request.text() + + for app in self._apps: + try: + decrypted = self._decrypt_request( + app, body, msg_signature, timestamp, nonce, + ) + event = self._build_event(app, decrypted) + if event is not None: + # Record which app this user belongs to. + if event.source and event.source.user_id: + map_key = self._user_app_key( + str(app.get("corp_id") or ""), event.source.user_id, + ) + self._user_app_map[map_key] = app["name"] + await self._message_queue.put(event) + # Immediately acknowledge — the agent's reply will arrive + # later via the proactive message/send API. + return web.Response(text="success", content_type="text/plain") + except WeComCryptoError: + continue + except Exception: + logger.exception("[WecomCallback] Error handling message") + break + return web.Response(status=400, text="invalid callback payload") + + async def _poll_loop(self) -> None: + """Drain the message queue and dispatch to the gateway runner.""" + while True: + event = await self._message_queue.get() + try: + task = asyncio.create_task(self.handle_message(event)) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + except Exception: + logger.exception("[WecomCallback] Failed to enqueue event") + + # ------------------------------------------------------------------ + # XML / crypto helpers + # ------------------------------------------------------------------ + + def _decrypt_request( + self, app: Dict[str, Any], body: str, + msg_signature: str, timestamp: str, nonce: str, + ) -> str: + root = ET.fromstring(body) + encrypt = root.findtext("Encrypt", default="") + crypt = self._crypt_for_app(app) + return crypt.decrypt(msg_signature, timestamp, nonce, encrypt).decode("utf-8") + + def _build_event(self, app: Dict[str, Any], xml_text: str) -> Optional[MessageEvent]: + root = ET.fromstring(xml_text) + msg_type = (root.findtext("MsgType") or "").lower() + # Silently acknowledge lifecycle events. + if msg_type == "event": + event_name = (root.findtext("Event") or "").lower() + if event_name in {"enter_agent", "subscribe"}: + return None + if msg_type not in {"text", "event"}: + return None + + user_id = root.findtext("FromUserName", default="") + corp_id = root.findtext("ToUserName", default=app.get("corp_id", "")) + scoped_chat_id = self._user_app_key(corp_id, user_id) + content = root.findtext("Content", default="").strip() + if not content and msg_type == "event": + content = "/start" + msg_id = ( + root.findtext("MsgId") + or f"{user_id}:{root.findtext('CreateTime', default='0')}" + ) + source = self.build_source( + chat_id=scoped_chat_id, + chat_name=user_id, + chat_type="dm", + user_id=user_id, + user_name=user_id, + ) + return MessageEvent( + text=content, + message_type=MessageType.TEXT, + source=source, + raw_message=xml_text, + message_id=msg_id, + ) + + def _crypt_for_app(self, app: Dict[str, Any]) -> WXBizMsgCrypt: + return WXBizMsgCrypt( + token=str(app.get("token") or ""), + encoding_aes_key=str(app.get("encoding_aes_key") or ""), + receive_id=str(app.get("corp_id") or ""), + ) + + def _get_app_by_name(self, name: Optional[str]) -> Optional[Dict[str, Any]]: + if not name: + return None + for app in self._apps: + if app.get("name") == name: + return app + return None + + # ------------------------------------------------------------------ + # Access-token management + # ------------------------------------------------------------------ + + async def _get_access_token(self, app: Dict[str, Any]) -> str: + cached = self._access_tokens.get(app["name"]) + now = time.time() + if cached and cached.get("expires_at", 0) > now + 60: + return cached["token"] + return await self._refresh_access_token(app) + + async def _refresh_access_token(self, app: Dict[str, Any]) -> str: + resp = await self._http_client.get( + "https://qyapi.weixin.qq.com/cgi-bin/gettoken", + params={ + "corpid": app.get("corp_id"), + "corpsecret": app.get("corp_secret"), + }, + ) + data = resp.json() + if data.get("errcode") != 0: + raise RuntimeError(f"WeCom token refresh failed: {data}") + token = data["access_token"] + expires_in = int(data.get("expires_in", ACCESS_TOKEN_TTL_SECONDS)) + self._access_tokens[app["name"]] = { + "token": token, + "expires_at": time.time() + expires_in, + } + logger.info( + "[WecomCallback] Token refreshed for app '%s' (corp=%s), expires in %ss", + app.get("name", "default"), + app.get("corp_id", ""), + expires_in, + ) + return token diff --git a/mindcli/_vendor/gateway/platforms/wecom_crypto.py b/mindcli/_vendor/gateway/platforms/wecom_crypto.py new file mode 100644 index 0000000..f984ca8 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/wecom_crypto.py @@ -0,0 +1,142 @@ +"""WeCom BizMsgCrypt-compatible AES-CBC encryption for callback mode. + +Implements the same wire format as Tencent's official ``WXBizMsgCrypt`` +SDK so that WeCom can verify, encrypt, and decrypt callback payloads. +""" + +from __future__ import annotations + +import base64 +import hashlib +import os +import secrets +import socket +import struct +from typing import Optional +from xml.etree import ElementTree as ET + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + +class WeComCryptoError(Exception): + pass + + +class SignatureError(WeComCryptoError): + pass + + +class DecryptError(WeComCryptoError): + pass + + +class EncryptError(WeComCryptoError): + pass + + +class PKCS7Encoder: + block_size = 32 + + @classmethod + def encode(cls, text: bytes) -> bytes: + amount_to_pad = cls.block_size - (len(text) % cls.block_size) + if amount_to_pad == 0: + amount_to_pad = cls.block_size + pad = bytes([amount_to_pad]) * amount_to_pad + return text + pad + + @classmethod + def decode(cls, decrypted: bytes) -> bytes: + if not decrypted: + raise DecryptError("empty decrypted payload") + pad = decrypted[-1] + if pad < 1 or pad > cls.block_size: + raise DecryptError("invalid PKCS7 padding") + if decrypted[-pad:] != bytes([pad]) * pad: + raise DecryptError("malformed PKCS7 padding") + return decrypted[:-pad] + + +def _sha1_signature(token: str, timestamp: str, nonce: str, encrypt: str) -> str: + parts = sorted([token, timestamp, nonce, encrypt]) + return hashlib.sha1("".join(parts).encode("utf-8")).hexdigest() + + +class WXBizMsgCrypt: + """Minimal WeCom callback crypto helper compatible with BizMsgCrypt semantics.""" + + def __init__(self, token: str, encoding_aes_key: str, receive_id: str): + if not token: + raise ValueError("token is required") + if not encoding_aes_key: + raise ValueError("encoding_aes_key is required") + if len(encoding_aes_key) != 43: + raise ValueError("encoding_aes_key must be 43 chars") + if not receive_id: + raise ValueError("receive_id is required") + + self.token = token + self.receive_id = receive_id + self.key = base64.b64decode(encoding_aes_key + "=") + self.iv = self.key[:16] + + def verify_url(self, msg_signature: str, timestamp: str, nonce: str, echostr: str) -> str: + plain = self.decrypt(msg_signature, timestamp, nonce, echostr) + return plain.decode("utf-8") + + def decrypt(self, msg_signature: str, timestamp: str, nonce: str, encrypt: str) -> bytes: + expected = _sha1_signature(self.token, timestamp, nonce, encrypt) + if expected != msg_signature: + raise SignatureError("signature mismatch") + try: + cipher_text = base64.b64decode(encrypt) + except Exception as exc: + raise DecryptError(f"invalid base64 payload: {exc}") from exc + try: + cipher = Cipher(algorithms.AES(self.key), modes.CBC(self.iv), backend=default_backend()) + decryptor = cipher.decryptor() + padded = decryptor.update(cipher_text) + decryptor.finalize() + plain = PKCS7Encoder.decode(padded) + content = plain[16:] # skip 16-byte random prefix + xml_length = socket.ntohl(struct.unpack("I", content[:4])[0]) + xml_content = content[4:4 + xml_length] + receive_id = content[4 + xml_length:].decode("utf-8") + except WeComCryptoError: + raise + except Exception as exc: + raise DecryptError(f"decrypt failed: {exc}") from exc + + if receive_id != self.receive_id: + raise DecryptError("receive_id mismatch") + return xml_content + + def encrypt(self, plaintext: str, nonce: Optional[str] = None, timestamp: Optional[str] = None) -> str: + nonce = nonce or self._random_nonce() + timestamp = timestamp or str(int(__import__("time").time())) + encrypt = self._encrypt_bytes(plaintext.encode("utf-8")) + signature = _sha1_signature(self.token, timestamp, nonce, encrypt) + root = ET.Element("xml") + ET.SubElement(root, "Encrypt").text = encrypt + ET.SubElement(root, "MsgSignature").text = signature + ET.SubElement(root, "TimeStamp").text = timestamp + ET.SubElement(root, "Nonce").text = nonce + return ET.tostring(root, encoding="unicode") + + def _encrypt_bytes(self, raw: bytes) -> str: + try: + random_prefix = os.urandom(16) + msg_len = struct.pack("I", socket.htonl(len(raw))) + payload = random_prefix + msg_len + raw + self.receive_id.encode("utf-8") + padded = PKCS7Encoder.encode(payload) + cipher = Cipher(algorithms.AES(self.key), modes.CBC(self.iv), backend=default_backend()) + encryptor = cipher.encryptor() + encrypted = encryptor.update(padded) + encryptor.finalize() + return base64.b64encode(encrypted).decode("utf-8") + except Exception as exc: + raise EncryptError(f"encrypt failed: {exc}") from exc + + @staticmethod + def _random_nonce(length: int = 10) -> str: + alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + return "".join(secrets.choice(alphabet) for _ in range(length)) diff --git a/mindcli/_vendor/gateway/platforms/weixin.py b/mindcli/_vendor/gateway/platforms/weixin.py new file mode 100644 index 0000000..e5859e4 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/weixin.py @@ -0,0 +1,1829 @@ +""" +Weixin platform adapter. + +Connects Hermes Agent to WeChat personal accounts via Tencent's iLink Bot API. + +Design notes: +- Long-poll ``getupdates`` drives inbound delivery. +- Every outbound reply must echo the latest ``context_token`` for the peer. +- Media files move through an AES-128-ECB encrypted CDN protocol. +- QR login is exposed as a helper for the gateway setup wizard. +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import json +import logging +import mimetypes +import os +import re +import secrets +import struct +import tempfile +import time +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import quote + +logger = logging.getLogger(__name__) + +try: + import aiohttp + + AIOHTTP_AVAILABLE = True +except ImportError: # pragma: no cover - dependency gate + aiohttp = None # type: ignore[assignment] + AIOHTTP_AVAILABLE = False + +try: + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + CRYPTO_AVAILABLE = True +except ImportError: # pragma: no cover - dependency gate + default_backend = None # type: ignore[assignment] + Cipher = None # type: ignore[assignment] + algorithms = None # type: ignore[assignment] + modes = None # type: ignore[assignment] + CRYPTO_AVAILABLE = False + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.helpers import MessageDeduplicator +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, + cache_audio_from_bytes, + cache_document_from_bytes, + cache_image_from_bytes, +) +from hermes_constants import get_hermes_home +from utils import atomic_json_write + +ILINK_BASE_URL = "https://ilinkai.weixin.qq.com" +WEIXIN_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c" +ILINK_APP_ID = "bot" +CHANNEL_VERSION = "2.2.0" +ILINK_APP_CLIENT_VERSION = (2 << 16) | (2 << 8) | 0 + +EP_GET_UPDATES = "ilink/bot/getupdates" +EP_SEND_MESSAGE = "ilink/bot/sendmessage" +EP_SEND_TYPING = "ilink/bot/sendtyping" +EP_GET_CONFIG = "ilink/bot/getconfig" +EP_GET_UPLOAD_URL = "ilink/bot/getuploadurl" +EP_GET_BOT_QR = "ilink/bot/get_bot_qrcode" +EP_GET_QR_STATUS = "ilink/bot/get_qrcode_status" + +LONG_POLL_TIMEOUT_MS = 35_000 +API_TIMEOUT_MS = 15_000 +CONFIG_TIMEOUT_MS = 10_000 +QR_TIMEOUT_MS = 35_000 + +MAX_CONSECUTIVE_FAILURES = 3 +RETRY_DELAY_SECONDS = 2 +BACKOFF_DELAY_SECONDS = 30 +SESSION_EXPIRED_ERRCODE = -14 +MESSAGE_DEDUP_TTL_SECONDS = 300 + +MEDIA_IMAGE = 1 +MEDIA_VIDEO = 2 +MEDIA_FILE = 3 +MEDIA_VOICE = 4 + +ITEM_TEXT = 1 +ITEM_IMAGE = 2 +ITEM_VOICE = 3 +ITEM_FILE = 4 +ITEM_VIDEO = 5 + +MSG_TYPE_USER = 1 +MSG_TYPE_BOT = 2 +MSG_STATE_FINISH = 2 + +TYPING_START = 1 +TYPING_STOP = 2 + +_HEADER_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$") +_TABLE_RULE_RE = re.compile(r"^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$") +_FENCE_RE = re.compile(r"^```([^\n`]*)\s*$") +_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") + + +def check_weixin_requirements() -> bool: + """Return True when runtime dependencies for Weixin are available.""" + return AIOHTTP_AVAILABLE and CRYPTO_AVAILABLE + + +def _safe_id(value: Optional[str], keep: int = 8) -> str: + raw = str(value or "").strip() + if not raw: + return "?" + if len(raw) <= keep: + return raw + return raw[:keep] + + +def _json_dumps(payload: Dict[str, Any]) -> str: + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + + +def _pkcs7_pad(data: bytes, block_size: int = 16) -> bytes: + pad_len = block_size - (len(data) % block_size) + return data + bytes([pad_len] * pad_len) + + +def _aes128_ecb_encrypt(plaintext: bytes, key: bytes) -> bytes: + cipher = Cipher(algorithms.AES(key), modes.ECB(), backend=default_backend()) + encryptor = cipher.encryptor() + return encryptor.update(_pkcs7_pad(plaintext)) + encryptor.finalize() + + +def _aes128_ecb_decrypt(ciphertext: bytes, key: bytes) -> bytes: + cipher = Cipher(algorithms.AES(key), modes.ECB(), backend=default_backend()) + decryptor = cipher.decryptor() + padded = decryptor.update(ciphertext) + decryptor.finalize() + if not padded: + return padded + pad_len = padded[-1] + if 1 <= pad_len <= 16 and padded.endswith(bytes([pad_len]) * pad_len): + return padded[:-pad_len] + return padded + + +def _aes_padded_size(size: int) -> int: + return ((size + 1 + 15) // 16) * 16 + + +def _random_wechat_uin() -> str: + value = struct.unpack(">I", secrets.token_bytes(4))[0] + return base64.b64encode(str(value).encode("utf-8")).decode("ascii") + + +def _base_info() -> Dict[str, Any]: + return {"channel_version": CHANNEL_VERSION} + + +def _headers(token: Optional[str], body: str) -> Dict[str, str]: + headers = { + "Content-Type": "application/json", + "AuthorizationType": "ilink_bot_token", + "Content-Length": str(len(body.encode("utf-8"))), + "X-WECHAT-UIN": _random_wechat_uin(), + "iLink-App-Id": ILINK_APP_ID, + "iLink-App-ClientVersion": str(ILINK_APP_CLIENT_VERSION), + } + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def _account_dir(hermes_home: str) -> Path: + path = Path(hermes_home) / "weixin" / "accounts" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _account_file(hermes_home: str, account_id: str) -> Path: + return _account_dir(hermes_home) / f"{account_id}.json" + + +def save_weixin_account( + hermes_home: str, + *, + account_id: str, + token: str, + base_url: str, + user_id: str = "", +) -> None: + """Persist account credentials for later reuse.""" + payload = { + "token": token, + "base_url": base_url, + "user_id": user_id, + "saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + path = _account_file(hermes_home, account_id) + atomic_json_write(path, payload) + try: + path.chmod(0o600) + except OSError: + pass + + +def load_weixin_account(hermes_home: str, account_id: str) -> Optional[Dict[str, Any]]: + """Load persisted account credentials.""" + path = _account_file(hermes_home, account_id) + if not path.exists(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + + +class ContextTokenStore: + """Disk-backed ``context_token`` cache keyed by account + peer.""" + + def __init__(self, hermes_home: str): + self._root = _account_dir(hermes_home) + self._cache: Dict[str, str] = {} + + def _path(self, account_id: str) -> Path: + return self._root / f"{account_id}.context-tokens.json" + + def _key(self, account_id: str, user_id: str) -> str: + return f"{account_id}:{user_id}" + + def restore(self, account_id: str) -> None: + path = self._path(account_id) + if not path.exists(): + return + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + logger.warning("weixin: failed to restore context tokens for %s: %s", _safe_id(account_id), exc) + return + restored = 0 + for user_id, token in data.items(): + if isinstance(token, str) and token: + self._cache[self._key(account_id, user_id)] = token + restored += 1 + if restored: + logger.info("weixin: restored %d context token(s) for %s", restored, _safe_id(account_id)) + + def get(self, account_id: str, user_id: str) -> Optional[str]: + return self._cache.get(self._key(account_id, user_id)) + + def set(self, account_id: str, user_id: str, token: str) -> None: + self._cache[self._key(account_id, user_id)] = token + self._persist(account_id) + + def _persist(self, account_id: str) -> None: + prefix = f"{account_id}:" + payload = { + key[len(prefix) :]: value + for key, value in self._cache.items() + if key.startswith(prefix) + } + try: + atomic_json_write(self._path(account_id), payload) + except Exception as exc: + logger.warning("weixin: failed to persist context tokens for %s: %s", _safe_id(account_id), exc) + + +class TypingTicketCache: + """Short-lived typing ticket cache from ``getconfig``.""" + + def __init__(self, ttl_seconds: float = 600.0): + self._ttl_seconds = ttl_seconds + self._cache: Dict[str, Tuple[str, float]] = {} + + def get(self, user_id: str) -> Optional[str]: + entry = self._cache.get(user_id) + if not entry: + return None + if time.time() - entry[1] >= self._ttl_seconds: + self._cache.pop(user_id, None) + return None + return entry[0] + + def set(self, user_id: str, ticket: str) -> None: + self._cache[user_id] = (ticket, time.time()) + + +def _cdn_download_url(cdn_base_url: str, encrypted_query_param: str) -> str: + return f"{cdn_base_url.rstrip('/')}/download?encrypted_query_param={quote(encrypted_query_param, safe='')}" + + +def _cdn_upload_url(cdn_base_url: str, upload_param: str, filekey: str) -> str: + return ( + f"{cdn_base_url.rstrip('/')}/upload" + f"?encrypted_query_param={quote(upload_param, safe='')}" + f"&filekey={quote(filekey, safe='')}" + ) + + +def _parse_aes_key(aes_key_b64: str) -> bytes: + decoded = base64.b64decode(aes_key_b64) + if len(decoded) == 16: + return decoded + if len(decoded) == 32: + text = decoded.decode("ascii", errors="ignore") + if text and all(ch in "0123456789abcdefABCDEF" for ch in text): + return bytes.fromhex(text) + raise ValueError(f"unexpected aes_key format ({len(decoded)} decoded bytes)") + + +def _guess_chat_type(message: Dict[str, Any], account_id: str) -> Tuple[str, str]: + room_id = str(message.get("room_id") or message.get("chat_room_id") or "").strip() + to_user_id = str(message.get("to_user_id") or "").strip() + is_group = bool(room_id) or (to_user_id and account_id and to_user_id != account_id and message.get("msg_type") == 1) + if is_group: + return "group", room_id or to_user_id or str(message.get("from_user_id") or "") + return "dm", str(message.get("from_user_id") or "") + + +async def _api_post( + session: "aiohttp.ClientSession", + *, + base_url: str, + endpoint: str, + payload: Dict[str, Any], + token: Optional[str], + timeout_ms: int, +) -> Dict[str, Any]: + body = _json_dumps({**payload, "base_info": _base_info()}) + url = f"{base_url.rstrip('/')}/{endpoint}" + timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000) + async with session.post(url, data=body, headers=_headers(token, body), timeout=timeout) as response: + raw = await response.text() + if not response.ok: + raise RuntimeError(f"iLink POST {endpoint} HTTP {response.status}: {raw[:200]}") + return json.loads(raw) + + +async def _api_get( + session: "aiohttp.ClientSession", + *, + base_url: str, + endpoint: str, + timeout_ms: int, +) -> Dict[str, Any]: + url = f"{base_url.rstrip('/')}/{endpoint}" + headers = { + "iLink-App-Id": ILINK_APP_ID, + "iLink-App-ClientVersion": str(ILINK_APP_CLIENT_VERSION), + } + timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000) + async with session.get(url, headers=headers, timeout=timeout) as response: + raw = await response.text() + if not response.ok: + raise RuntimeError(f"iLink GET {endpoint} HTTP {response.status}: {raw[:200]}") + return json.loads(raw) + + +async def _get_updates( + session: "aiohttp.ClientSession", + *, + base_url: str, + token: str, + sync_buf: str, + timeout_ms: int, +) -> Dict[str, Any]: + try: + return await _api_post( + session, + base_url=base_url, + endpoint=EP_GET_UPDATES, + payload={"get_updates_buf": sync_buf}, + token=token, + timeout_ms=timeout_ms, + ) + except asyncio.TimeoutError: + return {"ret": 0, "msgs": [], "get_updates_buf": sync_buf} + + +async def _send_message( + session: "aiohttp.ClientSession", + *, + base_url: str, + token: str, + to: str, + text: str, + context_token: Optional[str], + client_id: str, +) -> None: + if not text or not text.strip(): + raise ValueError("_send_message: text must not be empty") + message: Dict[str, Any] = { + "from_user_id": "", + "to_user_id": to, + "client_id": client_id, + "message_type": MSG_TYPE_BOT, + "message_state": MSG_STATE_FINISH, + "item_list": [{"type": ITEM_TEXT, "text_item": {"text": text}}], + } + if context_token: + message["context_token"] = context_token + await _api_post( + session, + base_url=base_url, + endpoint=EP_SEND_MESSAGE, + payload={"msg": message}, + token=token, + timeout_ms=API_TIMEOUT_MS, + ) + + +async def _send_typing( + session: "aiohttp.ClientSession", + *, + base_url: str, + token: str, + to_user_id: str, + typing_ticket: str, + status: int, +) -> None: + await _api_post( + session, + base_url=base_url, + endpoint=EP_SEND_TYPING, + payload={ + "ilink_user_id": to_user_id, + "typing_ticket": typing_ticket, + "status": status, + }, + token=token, + timeout_ms=CONFIG_TIMEOUT_MS, + ) + + +async def _get_config( + session: "aiohttp.ClientSession", + *, + base_url: str, + token: str, + user_id: str, + context_token: Optional[str], +) -> Dict[str, Any]: + payload: Dict[str, Any] = {"ilink_user_id": user_id} + if context_token: + payload["context_token"] = context_token + return await _api_post( + session, + base_url=base_url, + endpoint=EP_GET_CONFIG, + payload=payload, + token=token, + timeout_ms=CONFIG_TIMEOUT_MS, + ) + + +async def _get_upload_url( + session: "aiohttp.ClientSession", + *, + base_url: str, + token: str, + to_user_id: str, + media_type: int, + filekey: str, + rawsize: int, + rawfilemd5: str, + filesize: int, + aeskey_hex: str, +) -> Dict[str, Any]: + return await _api_post( + session, + base_url=base_url, + endpoint=EP_GET_UPLOAD_URL, + payload={ + "filekey": filekey, + "media_type": media_type, + "to_user_id": to_user_id, + "rawsize": rawsize, + "rawfilemd5": rawfilemd5, + "filesize": filesize, + "no_need_thumb": True, + "aeskey": aeskey_hex, + }, + token=token, + timeout_ms=API_TIMEOUT_MS, + ) + + +async def _upload_ciphertext( + session: "aiohttp.ClientSession", + *, + ciphertext: bytes, + upload_url: str, +) -> str: + """Upload encrypted media to the CDN. + + Accepts either a constructed CDN URL (from upload_param) or a direct + upload_full_url — both use POST with the raw ciphertext as the body. + """ + timeout = aiohttp.ClientTimeout(total=120) + async with session.post(upload_url, data=ciphertext, headers={"Content-Type": "application/octet-stream"}, timeout=timeout) as response: + if response.status == 200: + encrypted_param = response.headers.get("x-encrypted-param") + if encrypted_param: + await response.read() + return encrypted_param + raw = await response.text() + raise RuntimeError(f"CDN upload missing x-encrypted-param header: {raw[:200]}") + raw = await response.text() + raise RuntimeError(f"CDN upload HTTP {response.status}: {raw[:200]}") + + +async def _download_bytes( + session: "aiohttp.ClientSession", + *, + url: str, + timeout_seconds: float = 60.0, +) -> bytes: + timeout = aiohttp.ClientTimeout(total=timeout_seconds) + async with session.get(url, timeout=timeout) as response: + response.raise_for_status() + return await response.read() + + +def _media_reference(item: Dict[str, Any], key: str) -> Dict[str, Any]: + return (item.get(key) or {}).get("media") or {} + + +async def _download_and_decrypt_media( + session: "aiohttp.ClientSession", + *, + cdn_base_url: str, + encrypted_query_param: Optional[str], + aes_key_b64: Optional[str], + full_url: Optional[str], + timeout_seconds: float, +) -> bytes: + if encrypted_query_param: + raw = await _download_bytes( + session, + url=_cdn_download_url(cdn_base_url, encrypted_query_param), + timeout_seconds=timeout_seconds, + ) + elif full_url: + raw = await _download_bytes(session, url=full_url, timeout_seconds=timeout_seconds) + else: + raise RuntimeError("media item had neither encrypt_query_param nor full_url") + if aes_key_b64: + raw = _aes128_ecb_decrypt(raw, _parse_aes_key(aes_key_b64)) + return raw + + +def _mime_from_filename(filename: str) -> str: + return mimetypes.guess_type(filename)[0] or "application/octet-stream" + + +def _split_table_row(line: str) -> List[str]: + row = line.strip() + if row.startswith("|"): + row = row[1:] + if row.endswith("|"): + row = row[:-1] + return [cell.strip() for cell in row.split("|")] + + +def _rewrite_headers_for_weixin(line: str) -> str: + match = _HEADER_RE.match(line) + if not match: + return line.rstrip() + level = len(match.group(1)) + title = match.group(2).strip() + if level == 1: + return f"【{title}】" + return f"**{title}**" + + +def _rewrite_table_block_for_weixin(lines: List[str]) -> str: + if len(lines) < 2: + return "\n".join(lines) + headers = _split_table_row(lines[0]) + body_rows = [_split_table_row(line) for line in lines[2:] if line.strip()] + if not headers or not body_rows: + return "\n".join(lines) + + formatted_rows: List[str] = [] + for row in body_rows: + pairs = [] + for idx, header in enumerate(headers): + if idx >= len(row): + break + label = header or f"Column {idx + 1}" + value = row[idx].strip() + if value: + pairs.append((label, value)) + if not pairs: + continue + if len(pairs) == 1: + label, value = pairs[0] + formatted_rows.append(f"- {label}: {value}") + continue + if len(pairs) == 2: + label, value = pairs[0] + other_label, other_value = pairs[1] + formatted_rows.append(f"- {label}: {value}") + formatted_rows.append(f" {other_label}: {other_value}") + continue + summary = " | ".join(f"{label}: {value}" for label, value in pairs) + formatted_rows.append(f"- {summary}") + return "\n".join(formatted_rows) if formatted_rows else "\n".join(lines) + + +def _normalize_markdown_blocks(content: str) -> str: + lines = content.splitlines() + result: List[str] = [] + i = 0 + in_code_block = False + + while i < len(lines): + line = lines[i].rstrip() + fence_match = _FENCE_RE.match(line.strip()) + if fence_match: + in_code_block = not in_code_block + result.append(line) + i += 1 + continue + + if in_code_block: + result.append(line) + i += 1 + continue + + if ( + i + 1 < len(lines) + and "|" in lines[i] + and _TABLE_RULE_RE.match(lines[i + 1].rstrip()) + ): + table_lines = [lines[i].rstrip(), lines[i + 1].rstrip()] + i += 2 + while i < len(lines) and "|" in lines[i]: + table_lines.append(lines[i].rstrip()) + i += 1 + result.append(_rewrite_table_block_for_weixin(table_lines)) + continue + + result.append(_MARKDOWN_LINK_RE.sub(r"\1 (\2)", _rewrite_headers_for_weixin(line))) + i += 1 + + normalized = "\n".join(item.rstrip() for item in result) + normalized = re.sub(r"\n{3,}", "\n\n", normalized) + return normalized.strip() + + +def _split_markdown_blocks(content: str) -> List[str]: + if not content: + return [] + + blocks: List[str] = [] + lines = content.splitlines() + current: List[str] = [] + in_code_block = False + + for raw_line in lines: + line = raw_line.rstrip() + if _FENCE_RE.match(line.strip()): + if not in_code_block and current: + blocks.append("\n".join(current).strip()) + current = [] + current.append(line) + in_code_block = not in_code_block + if not in_code_block: + blocks.append("\n".join(current).strip()) + current = [] + continue + + if in_code_block: + current.append(line) + continue + + if not line.strip(): + if current: + blocks.append("\n".join(current).strip()) + current = [] + continue + current.append(line) + + if current: + blocks.append("\n".join(current).strip()) + return [block for block in blocks if block] + + +def _split_delivery_units_for_weixin(content: str) -> List[str]: + """Split formatted content into chat-friendly delivery units. + + Weixin can render Markdown, but chat readability is better when top-level + line breaks become separate messages. Keep fenced code blocks intact and + attach indented continuation lines to the previous top-level line so + transformed tables/lists do not get torn apart. + """ + units: List[str] = [] + + for block in _split_markdown_blocks(content): + if _FENCE_RE.match(block.splitlines()[0].strip()): + units.append(block) + continue + + current: List[str] = [] + for raw_line in block.splitlines(): + line = raw_line.rstrip() + if not line.strip(): + if current: + units.append("\n".join(current).strip()) + current = [] + continue + + is_continuation = bool(current) and raw_line.startswith((" ", "\t")) + if is_continuation: + current.append(line) + continue + + if current: + units.append("\n".join(current).strip()) + current = [line] + + if current: + units.append("\n".join(current).strip()) + + return [unit for unit in units if unit] + + +def _looks_like_chatty_line_for_weixin(line: str) -> bool: + """Return True when a line looks like a standalone chat utterance.""" + stripped = line.strip() + if not stripped: + return False + if len(stripped) > 48: + return False + if line.startswith((" ", "\t")): + return False + if stripped.startswith((">", "-", "*", "【")): + return False + if re.match(r"^\*\*[^*]+\*\*$", stripped): + return False + if re.match(r"^\d+\.\s", stripped): + return False + return True + + +def _looks_like_heading_line_for_weixin(line: str) -> bool: + """Return True when a short line behaves like a plain-text heading.""" + stripped = line.strip() + if not stripped: + return False + return len(stripped) <= 24 and stripped.endswith((":", ":")) + + +def _should_split_short_chat_block_for_weixin(block: str) -> bool: + """Split only chat-like multiline blocks into separate bubbles.""" + lines = [line for line in block.splitlines() if line.strip()] + if not 2 <= len(lines) <= 6: + return False + if _looks_like_heading_line_for_weixin(lines[0]): + return False + return all(_looks_like_chatty_line_for_weixin(line) for line in lines) + + +def _pack_markdown_blocks_for_weixin(content: str, max_length: int) -> List[str]: + if len(content) <= max_length: + return [content] + + packed: List[str] = [] + current = "" + for block in _split_markdown_blocks(content): + candidate = block if not current else f"{current}\n\n{block}" + if len(candidate) <= max_length: + current = candidate + continue + if current: + packed.append(current) + current = "" + if len(block) <= max_length: + current = block + continue + packed.extend(BasePlatformAdapter.truncate_message(block, max_length)) + if current: + packed.append(current) + return packed + + +def _split_text_for_weixin_delivery( + content: str, max_length: int, split_per_line: bool = False, +) -> List[str]: + """Split content into sequential Weixin messages. + + *compact* (default): Keep everything in a single message whenever it fits + within the platform limit, even when the author used explicit line breaks. + Only fall back to block-aware packing when the payload exceeds + ``max_length``. + + *per_line* (``split_per_line=True``): Legacy behavior — top-level line + breaks become separate chat messages; oversized units still use + block-aware packing. + + The active mode is controlled via ``config.yaml`` -> + ``platforms.weixin.extra.split_multiline_messages`` (``true`` / ``false``) + or the env var ``WEIXIN_SPLIT_MULTILINE_MESSAGES``. + """ + if not content: + return [] + if split_per_line: + # Legacy: one message per top-level delivery unit. + if len(content) <= max_length and "\n" not in content: + return [content] + chunks: List[str] = [] + for unit in _split_delivery_units_for_weixin(content): + if len(unit) <= max_length: + chunks.append(unit) + continue + chunks.extend(_pack_markdown_blocks_for_weixin(unit, max_length)) + return [c for c in chunks if c] or [content] + + # Compact (default): single message when under the limit — unless the + # content looks like a short chatty exchange, in which case split into + # separate bubbles for a more natural chat feel. + if len(content) <= max_length: + return ( + [u for u in _split_delivery_units_for_weixin(content) if u] + if _should_split_short_chat_block_for_weixin(content) + else [content] + ) + return _pack_markdown_blocks_for_weixin(content, max_length) or [content] + + +def _coerce_bool(value: Any, default: bool = True) -> bool: + """Coerce a config value to bool, tolerating strings like ``"true"``.""" + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + text = str(value).strip().lower() + if not text: + return default + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + return default + + +def _extract_text(item_list: List[Dict[str, Any]]) -> str: + for item in item_list: + if item.get("type") == ITEM_TEXT: + text = str((item.get("text_item") or {}).get("text") or "") + ref = item.get("ref_msg") or {} + ref_item = ref.get("message_item") or {} + ref_type = ref_item.get("type") + if ref_type in (ITEM_IMAGE, ITEM_VIDEO, ITEM_FILE, ITEM_VOICE): + title = ref.get("title") or "" + prefix = f"[引用媒体: {title}]\n" if title else "[引用媒体]\n" + return f"{prefix}{text}".strip() + if ref_item: + parts: List[str] = [] + if ref.get("title"): + parts.append(str(ref["title"])) + ref_text = _extract_text([ref_item]) + if ref_text: + parts.append(ref_text) + if parts: + return f"[引用: {' | '.join(parts)}]\n{text}".strip() + return text + for item in item_list: + if item.get("type") == ITEM_VOICE: + voice_text = str((item.get("voice_item") or {}).get("text") or "") + if voice_text: + return voice_text + return "" + + +def _message_type_from_media(media_types: List[str], text: str) -> MessageType: + if any(m.startswith("image/") for m in media_types): + return MessageType.PHOTO + if any(m.startswith("video/") for m in media_types): + return MessageType.VIDEO + if any(m.startswith("audio/") for m in media_types): + return MessageType.VOICE + if media_types: + return MessageType.DOCUMENT + if text.startswith("/"): + return MessageType.COMMAND + return MessageType.TEXT + + +def _sync_buf_path(hermes_home: str, account_id: str) -> Path: + return _account_dir(hermes_home) / f"{account_id}.sync.json" + + +def _load_sync_buf(hermes_home: str, account_id: str) -> str: + path = _sync_buf_path(hermes_home, account_id) + if not path.exists(): + return "" + try: + return json.loads(path.read_text(encoding="utf-8")).get("get_updates_buf", "") + except Exception: + return "" + + +def _save_sync_buf(hermes_home: str, account_id: str, sync_buf: str) -> None: + path = _sync_buf_path(hermes_home, account_id) + atomic_json_write(path, {"get_updates_buf": sync_buf}) + + +async def qr_login( + hermes_home: str, + *, + bot_type: str = "3", + timeout_seconds: int = 480, +) -> Optional[Dict[str, str]]: + """ + Run the interactive iLink QR login flow. + + Returns a credential dict on success, or ``None`` if login fails or times out. + """ + if not AIOHTTP_AVAILABLE: + raise RuntimeError("aiohttp is required for Weixin QR login") + + async with aiohttp.ClientSession(trust_env=True) as session: + try: + qr_resp = await _api_get( + session, + base_url=ILINK_BASE_URL, + endpoint=f"{EP_GET_BOT_QR}?bot_type={bot_type}", + timeout_ms=QR_TIMEOUT_MS, + ) + except Exception as exc: + logger.error("weixin: failed to fetch QR code: %s", exc) + return None + + qrcode_value = str(qr_resp.get("qrcode") or "") + qrcode_url = str(qr_resp.get("qrcode_img_content") or "") + if not qrcode_value: + logger.error("weixin: QR response missing qrcode") + return None + + print("\n请使用微信扫描以下二维码:") + if qrcode_url: + print(qrcode_url) + try: + import qrcode + + qr = qrcode.QRCode() + qr.add_data(qrcode_url or qrcode_value) + qr.make(fit=True) + qr.print_ascii(invert=True) + except Exception: + print("(终端二维码渲染失败,请直接打开上面的二维码链接)") + + deadline = time.time() + timeout_seconds + current_base_url = ILINK_BASE_URL + refresh_count = 0 + + while time.time() < deadline: + try: + status_resp = await _api_get( + session, + base_url=current_base_url, + endpoint=f"{EP_GET_QR_STATUS}?qrcode={qrcode_value}", + timeout_ms=QR_TIMEOUT_MS, + ) + except asyncio.TimeoutError: + await asyncio.sleep(1) + continue + except Exception as exc: + logger.warning("weixin: QR poll error: %s", exc) + await asyncio.sleep(1) + continue + + status = str(status_resp.get("status") or "wait") + if status == "wait": + print(".", end="", flush=True) + elif status == "scaned": + print("\n已扫码,请在微信里确认...") + elif status == "scaned_but_redirect": + redirect_host = str(status_resp.get("redirect_host") or "") + if redirect_host: + current_base_url = f"https://{redirect_host}" + elif status == "expired": + refresh_count += 1 + if refresh_count > 3: + print("\n二维码多次过期,请重新执行登录。") + return None + print(f"\n二维码已过期,正在刷新... ({refresh_count}/3)") + try: + qr_resp = await _api_get( + session, + base_url=ILINK_BASE_URL, + endpoint=f"{EP_GET_BOT_QR}?bot_type={bot_type}", + timeout_ms=QR_TIMEOUT_MS, + ) + qrcode_value = str(qr_resp.get("qrcode") or "") + qrcode_url = str(qr_resp.get("qrcode_img_content") or "") + if qrcode_url: + print(qrcode_url) + except Exception as exc: + logger.error("weixin: QR refresh failed: %s", exc) + return None + elif status == "confirmed": + account_id = str(status_resp.get("ilink_bot_id") or "") + token = str(status_resp.get("bot_token") or "") + base_url = str(status_resp.get("baseurl") or ILINK_BASE_URL) + user_id = str(status_resp.get("ilink_user_id") or "") + if not account_id or not token: + logger.error("weixin: QR confirmed but credential payload was incomplete") + return None + save_weixin_account( + hermes_home, + account_id=account_id, + token=token, + base_url=base_url, + user_id=user_id, + ) + print(f"\n微信连接成功,account_id={account_id}") + return { + "account_id": account_id, + "token": token, + "base_url": base_url, + "user_id": user_id, + } + await asyncio.sleep(1) + + print("\n微信登录超时。") + return None + + +class WeixinAdapter(BasePlatformAdapter): + """Native Hermes adapter for Weixin personal accounts.""" + + MAX_MESSAGE_LENGTH = 4000 + + # WeChat does not support editing sent messages — streaming must use the + # fallback "send-final-only" path so the cursor (▉) is never left visible. + SUPPORTS_MESSAGE_EDITING = False + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.WEIXIN) + extra = config.extra or {} + hermes_home = str(get_hermes_home()) + self._hermes_home = hermes_home + self._token_store = ContextTokenStore(hermes_home) + self._typing_cache = TypingTicketCache() + self._session: Optional[aiohttp.ClientSession] = None + self._poll_task: Optional[asyncio.Task] = None + self._dedup = MessageDeduplicator(ttl_seconds=MESSAGE_DEDUP_TTL_SECONDS) + + self._account_id = str(extra.get("account_id") or os.getenv("WEIXIN_ACCOUNT_ID", "")).strip() + self._token = str(config.token or extra.get("token") or os.getenv("WEIXIN_TOKEN", "")).strip() + self._base_url = str(extra.get("base_url") or os.getenv("WEIXIN_BASE_URL", ILINK_BASE_URL)).strip().rstrip("/") + self._cdn_base_url = str( + extra.get("cdn_base_url") or os.getenv("WEIXIN_CDN_BASE_URL", WEIXIN_CDN_BASE_URL) + ).strip().rstrip("/") + self._send_chunk_delay_seconds = float( + extra.get("send_chunk_delay_seconds") or os.getenv("WEIXIN_SEND_CHUNK_DELAY_SECONDS", "0.35") + ) + self._send_chunk_retries = int( + extra.get("send_chunk_retries") or os.getenv("WEIXIN_SEND_CHUNK_RETRIES", "2") + ) + self._send_chunk_retry_delay_seconds = float( + extra.get("send_chunk_retry_delay_seconds") + or os.getenv("WEIXIN_SEND_CHUNK_RETRY_DELAY_SECONDS", "1.0") + ) + self._dm_policy = str(extra.get("dm_policy") or os.getenv("WEIXIN_DM_POLICY", "open")).strip().lower() + self._group_policy = str(extra.get("group_policy") or os.getenv("WEIXIN_GROUP_POLICY", "disabled")).strip().lower() + allow_from = extra.get("allow_from") + if allow_from is None: + allow_from = os.getenv("WEIXIN_ALLOWED_USERS", "") + group_allow_from = extra.get("group_allow_from") + if group_allow_from is None: + group_allow_from = os.getenv("WEIXIN_GROUP_ALLOWED_USERS", "") + self._allow_from = self._coerce_list(allow_from) + self._group_allow_from = self._coerce_list(group_allow_from) + self._split_multiline_messages = _coerce_bool( + extra.get("split_multiline_messages") + or os.getenv("WEIXIN_SPLIT_MULTILINE_MESSAGES"), + default=False, + ) + + if self._account_id and not self._token: + persisted = load_weixin_account(hermes_home, self._account_id) + if persisted: + self._token = str(persisted.get("token") or "").strip() + self._base_url = str(persisted.get("base_url") or self._base_url).strip().rstrip("/") + + @staticmethod + def _coerce_list(value: Any) -> List[str]: + if value is None: + return [] + if isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] + if isinstance(value, (list, tuple, set)): + return [str(item).strip() for item in value if str(item).strip()] + return [str(value).strip()] if str(value).strip() else [] + + async def connect(self) -> bool: + if not check_weixin_requirements(): + message = "Weixin startup failed: aiohttp and cryptography are required" + self._set_fatal_error("weixin_missing_dependency", message, retryable=False) + logger.warning("[%s] %s", self.name, message) + return False + if not self._token: + message = "Weixin startup failed: WEIXIN_TOKEN is required" + self._set_fatal_error("weixin_missing_token", message, retryable=False) + logger.warning("[%s] %s", self.name, message) + return False + if not self._account_id: + message = "Weixin startup failed: WEIXIN_ACCOUNT_ID is required" + self._set_fatal_error("weixin_missing_account", message, retryable=False) + logger.warning("[%s] %s", self.name, message) + return False + + try: + if not self._acquire_platform_lock('weixin-bot-token', self._token, 'Weixin bot token'): + return False + except Exception as exc: + logger.debug("[%s] Token lock unavailable (non-fatal): %s", self.name, exc) + + self._session = aiohttp.ClientSession(trust_env=True) + self._token_store.restore(self._account_id) + self._poll_task = asyncio.create_task(self._poll_loop(), name="weixin-poll") + self._mark_connected() + logger.info("[%s] Connected account=%s base=%s", self.name, _safe_id(self._account_id), self._base_url) + return True + + async def disconnect(self) -> None: + self._running = False + if self._poll_task and not self._poll_task.done(): + self._poll_task.cancel() + try: + await self._poll_task + except asyncio.CancelledError: + pass + self._poll_task = None + if self._session and not self._session.closed: + await self._session.close() + self._session = None + self._release_platform_lock() + self._mark_disconnected() + logger.info("[%s] Disconnected", self.name) + + async def _poll_loop(self) -> None: + assert self._session is not None + sync_buf = _load_sync_buf(self._hermes_home, self._account_id) + timeout_ms = LONG_POLL_TIMEOUT_MS + consecutive_failures = 0 + + while self._running: + try: + response = await _get_updates( + self._session, + base_url=self._base_url, + token=self._token, + sync_buf=sync_buf, + timeout_ms=timeout_ms, + ) + suggested_timeout = response.get("longpolling_timeout_ms") + if isinstance(suggested_timeout, int) and suggested_timeout > 0: + timeout_ms = suggested_timeout + + ret = response.get("ret", 0) + errcode = response.get("errcode", 0) + if ret not in (0, None) or errcode not in (0, None): + if ret == SESSION_EXPIRED_ERRCODE or errcode == SESSION_EXPIRED_ERRCODE: + logger.error("[%s] Session expired; pausing for 10 minutes", self.name) + await asyncio.sleep(600) + consecutive_failures = 0 + continue + consecutive_failures += 1 + logger.warning( + "[%s] getUpdates failed ret=%s errcode=%s errmsg=%s (%d/%d)", + self.name, + ret, + errcode, + response.get("errmsg", ""), + consecutive_failures, + MAX_CONSECUTIVE_FAILURES, + ) + await asyncio.sleep(BACKOFF_DELAY_SECONDS if consecutive_failures >= MAX_CONSECUTIVE_FAILURES else RETRY_DELAY_SECONDS) + if consecutive_failures >= MAX_CONSECUTIVE_FAILURES: + consecutive_failures = 0 + continue + + consecutive_failures = 0 + new_sync_buf = str(response.get("get_updates_buf") or "") + if new_sync_buf: + sync_buf = new_sync_buf + _save_sync_buf(self._hermes_home, self._account_id, sync_buf) + + for message in response.get("msgs") or []: + asyncio.create_task(self._process_message_safe(message)) + except asyncio.CancelledError: + break + except Exception as exc: + consecutive_failures += 1 + logger.error("[%s] poll error (%d/%d): %s", self.name, consecutive_failures, MAX_CONSECUTIVE_FAILURES, exc) + await asyncio.sleep(BACKOFF_DELAY_SECONDS if consecutive_failures >= MAX_CONSECUTIVE_FAILURES else RETRY_DELAY_SECONDS) + if consecutive_failures >= MAX_CONSECUTIVE_FAILURES: + consecutive_failures = 0 + + async def _process_message_safe(self, message: Dict[str, Any]) -> None: + try: + await self._process_message(message) + except Exception as exc: + logger.error("[%s] unhandled inbound error from=%s: %s", self.name, _safe_id(message.get("from_user_id")), exc, exc_info=True) + + async def _process_message(self, message: Dict[str, Any]) -> None: + assert self._session is not None + sender_id = str(message.get("from_user_id") or "").strip() + if not sender_id: + return + if sender_id == self._account_id: + return + + message_id = str(message.get("message_id") or "").strip() + if message_id and self._dedup.is_duplicate(message_id): + return + + chat_type, effective_chat_id = _guess_chat_type(message, self._account_id) + if chat_type == "group": + if self._group_policy == "disabled": + return + if self._group_policy == "allowlist" and effective_chat_id not in self._group_allow_from: + return + elif not self._is_dm_allowed(sender_id): + return + + context_token = str(message.get("context_token") or "").strip() + if context_token: + self._token_store.set(self._account_id, sender_id, context_token) + asyncio.create_task(self._maybe_fetch_typing_ticket(sender_id, context_token or None)) + + item_list = message.get("item_list") or [] + text = _extract_text(item_list) + media_paths: List[str] = [] + media_types: List[str] = [] + + for item in item_list: + await self._collect_media(item, media_paths, media_types) + ref_message = item.get("ref_msg") or {} + ref_item = ref_message.get("message_item") + if isinstance(ref_item, dict): + await self._collect_media(ref_item, media_paths, media_types) + + if not text and not media_paths: + return + + source = self.build_source( + chat_id=effective_chat_id, + chat_type=chat_type, + user_id=sender_id, + user_name=sender_id, + ) + event = MessageEvent( + text=text, + message_type=_message_type_from_media(media_types, text), + source=source, + raw_message=message, + message_id=message_id or None, + media_urls=media_paths, + media_types=media_types, + timestamp=datetime.now(), + ) + logger.info("[%s] inbound from=%s type=%s media=%d", self.name, _safe_id(sender_id), source.chat_type, len(media_paths)) + await self.handle_message(event) + + def _is_dm_allowed(self, sender_id: str) -> bool: + if self._dm_policy == "disabled": + return False + if self._dm_policy == "allowlist": + return sender_id in self._allow_from + return True + + async def _collect_media(self, item: Dict[str, Any], media_paths: List[str], media_types: List[str]) -> None: + item_type = item.get("type") + if item_type == ITEM_IMAGE: + path = await self._download_image(item) + if path: + media_paths.append(path) + media_types.append("image/jpeg") + elif item_type == ITEM_VIDEO: + path = await self._download_video(item) + if path: + media_paths.append(path) + media_types.append("video/mp4") + elif item_type == ITEM_FILE: + path, mime = await self._download_file(item) + if path: + media_paths.append(path) + media_types.append(mime) + elif item_type == ITEM_VOICE: + voice_path = await self._download_voice(item) + if voice_path: + media_paths.append(voice_path) + media_types.append("audio/silk") + + async def _download_image(self, item: Dict[str, Any]) -> Optional[str]: + media = _media_reference(item, "image_item") + try: + data = await _download_and_decrypt_media( + self._session, + cdn_base_url=self._cdn_base_url, + encrypted_query_param=media.get("encrypt_query_param"), + aes_key_b64=(item.get("image_item") or {}).get("aeskey") + and base64.b64encode(bytes.fromhex(str((item.get("image_item") or {}).get("aeskey")))).decode("ascii") + or media.get("aes_key"), + full_url=media.get("full_url"), + timeout_seconds=30.0, + ) + return cache_image_from_bytes(data, ".jpg") + except Exception as exc: + logger.warning("[%s] image download failed: %s", self.name, exc) + return None + + async def _download_video(self, item: Dict[str, Any]) -> Optional[str]: + media = _media_reference(item, "video_item") + try: + data = await _download_and_decrypt_media( + self._session, + cdn_base_url=self._cdn_base_url, + encrypted_query_param=media.get("encrypt_query_param"), + aes_key_b64=media.get("aes_key"), + full_url=media.get("full_url"), + timeout_seconds=120.0, + ) + return cache_document_from_bytes(data, "video.mp4") + except Exception as exc: + logger.warning("[%s] video download failed: %s", self.name, exc) + return None + + async def _download_file(self, item: Dict[str, Any]) -> Tuple[Optional[str], str]: + file_item = item.get("file_item") or {} + media = file_item.get("media") or {} + filename = str(file_item.get("file_name") or "document.bin") + mime = _mime_from_filename(filename) + try: + data = await _download_and_decrypt_media( + self._session, + cdn_base_url=self._cdn_base_url, + encrypted_query_param=media.get("encrypt_query_param"), + aes_key_b64=media.get("aes_key"), + full_url=media.get("full_url"), + timeout_seconds=60.0, + ) + return cache_document_from_bytes(data, filename), mime + except Exception as exc: + logger.warning("[%s] file download failed: %s", self.name, exc) + return None, mime + + async def _download_voice(self, item: Dict[str, Any]) -> Optional[str]: + voice_item = item.get("voice_item") or {} + media = voice_item.get("media") or {} + if voice_item.get("text"): + return None + try: + data = await _download_and_decrypt_media( + self._session, + cdn_base_url=self._cdn_base_url, + encrypted_query_param=media.get("encrypt_query_param"), + aes_key_b64=media.get("aes_key"), + full_url=media.get("full_url"), + timeout_seconds=60.0, + ) + return cache_audio_from_bytes(data, ".silk") + except Exception as exc: + logger.warning("[%s] voice download failed: %s", self.name, exc) + return None + + async def _maybe_fetch_typing_ticket(self, user_id: str, context_token: Optional[str]) -> None: + if not self._session or not self._token: + return + if self._typing_cache.get(user_id): + return + try: + response = await _get_config( + self._session, + base_url=self._base_url, + token=self._token, + user_id=user_id, + context_token=context_token, + ) + typing_ticket = str(response.get("typing_ticket") or "") + if typing_ticket: + self._typing_cache.set(user_id, typing_ticket) + except Exception as exc: + logger.debug("[%s] getConfig failed for %s: %s", self.name, _safe_id(user_id), exc) + + def _split_text(self, content: str) -> List[str]: + return _split_text_for_weixin_delivery( + content, self.MAX_MESSAGE_LENGTH, self._split_multiline_messages, + ) + + async def _send_text_chunk( + self, + *, + chat_id: str, + chunk: str, + context_token: Optional[str], + client_id: str, + ) -> None: + """Send a single text chunk with per-chunk retry and backoff.""" + last_error: Optional[Exception] = None + for attempt in range(self._send_chunk_retries + 1): + try: + await _send_message( + self._session, + base_url=self._base_url, + token=self._token, + to=chat_id, + text=chunk, + context_token=context_token, + client_id=client_id, + ) + return + except Exception as exc: + last_error = exc + if attempt >= self._send_chunk_retries: + break + wait = self._send_chunk_retry_delay_seconds * (attempt + 1) + logger.warning( + "[%s] send chunk failed to=%s attempt=%d/%d, retrying in %.2fs: %s", + self.name, + _safe_id(chat_id), + attempt + 1, + self._send_chunk_retries + 1, + wait, + exc, + ) + if wait > 0: + await asyncio.sleep(wait) + assert last_error is not None + raise last_error + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + if not self._session or not self._token: + return SendResult(success=False, error="Not connected") + context_token = self._token_store.get(self._account_id, chat_id) + last_message_id: Optional[str] = None + try: + chunks = [c for c in self._split_text(self.format_message(content)) if c and c.strip()] + for idx, chunk in enumerate(chunks): + client_id = f"hermes-weixin-{uuid.uuid4().hex}" + await self._send_text_chunk( + chat_id=chat_id, + chunk=chunk, + context_token=context_token, + client_id=client_id, + ) + last_message_id = client_id + if idx < len(chunks) - 1 and self._send_chunk_delay_seconds > 0: + await asyncio.sleep(self._send_chunk_delay_seconds) + return SendResult(success=True, message_id=last_message_id) + except Exception as exc: + logger.error("[%s] send failed to=%s: %s", self.name, _safe_id(chat_id), exc) + return SendResult(success=False, error=str(exc)) + + async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None: + if not self._session or not self._token: + return + typing_ticket = self._typing_cache.get(chat_id) + if not typing_ticket: + return + try: + await _send_typing( + self._session, + base_url=self._base_url, + token=self._token, + to_user_id=chat_id, + typing_ticket=typing_ticket, + status=TYPING_START, + ) + except Exception as exc: + logger.debug("[%s] typing start failed for %s: %s", self.name, _safe_id(chat_id), exc) + + async def stop_typing(self, chat_id: str) -> None: + if not self._session or not self._token: + return + typing_ticket = self._typing_cache.get(chat_id) + if not typing_ticket: + return + try: + await _send_typing( + self._session, + base_url=self._base_url, + token=self._token, + to_user_id=chat_id, + typing_ticket=typing_ticket, + status=TYPING_STOP, + ) + except Exception as exc: + logger.debug("[%s] typing stop failed for %s: %s", self.name, _safe_id(chat_id), exc) + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + if image_url.startswith(("http://", "https://")): + file_path = await self._download_remote_media(image_url) + cleanup = True + else: + file_path = image_url.replace("file://", "") + if not os.path.isabs(file_path): + file_path = os.path.abspath(file_path) + cleanup = False + try: + return await self.send_document(chat_id, file_path, caption=caption, metadata=metadata) + finally: + if cleanup and file_path and os.path.exists(file_path): + try: + os.unlink(file_path) + except OSError: + pass + + async def send_image_file( + self, + chat_id: str, + path: str, + caption: str = "", + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + return await self.send_document(chat_id, file_path=path, caption=caption, metadata=metadata) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: str = "", + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + if not self._session or not self._token: + return SendResult(success=False, error="Not connected") + try: + message_id = await self._send_file(chat_id, file_path, caption) + return SendResult(success=True, message_id=message_id) + except Exception as exc: + logger.error("[%s] send_document failed to=%s: %s", self.name, _safe_id(chat_id), exc) + return SendResult(success=False, error=str(exc)) + + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + if not self._session or not self._token: + return SendResult(success=False, error="Not connected") + try: + message_id = await self._send_file(chat_id, video_path, caption or "") + return SendResult(success=True, message_id=message_id) + except Exception as exc: + logger.error("[%s] send_video failed to=%s: %s", self.name, _safe_id(chat_id), exc) + return SendResult(success=False, error=str(exc)) + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + return await self.send_document(chat_id, audio_path, caption=caption or "", metadata=metadata) + + async def _download_remote_media(self, url: str) -> str: + from tools.url_safety import is_safe_url + + if not is_safe_url(url): + raise ValueError(f"Blocked unsafe URL (SSRF protection): {url}") + + assert self._session is not None + async with self._session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as response: + response.raise_for_status() + data = await response.read() + suffix = Path(url.split("?", 1)[0]).suffix or ".bin" + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as handle: + handle.write(data) + return handle.name + + async def _send_file(self, chat_id: str, path: str, caption: str) -> str: + assert self._session is not None and self._token is not None + plaintext = Path(path).read_bytes() + media_type, item_builder = self._outbound_media_builder(path) + filekey = secrets.token_hex(16) + aes_key = secrets.token_bytes(16) + rawsize = len(plaintext) + rawfilemd5 = hashlib.md5(plaintext).hexdigest() + upload_response = await _get_upload_url( + self._session, + base_url=self._base_url, + token=self._token, + to_user_id=chat_id, + media_type=media_type, + filekey=filekey, + rawsize=rawsize, + rawfilemd5=rawfilemd5, + filesize=_aes_padded_size(rawsize), + aeskey_hex=aes_key.hex(), + ) + upload_param = str(upload_response.get("upload_param") or "") + upload_full_url = str(upload_response.get("upload_full_url") or "") + ciphertext = _aes128_ecb_encrypt(plaintext, aes_key) + + # Prefer upload_full_url (direct CDN), fall back to constructed CDN URL + # from upload_param. Both paths use POST — the old PUT for + # upload_full_url caused 404s on the WeChat CDN. + if upload_full_url: + upload_url = upload_full_url + elif upload_param: + upload_url = _cdn_upload_url(self._cdn_base_url, upload_param, filekey) + else: + raise RuntimeError(f"getUploadUrl returned neither upload_param nor upload_full_url: {upload_response}") + + encrypted_query_param = await _upload_ciphertext( + self._session, + ciphertext=ciphertext, + upload_url=upload_url, + ) + + context_token = self._token_store.get(self._account_id, chat_id) + # The iLink API expects aes_key as base64(hex_string), not base64(raw_bytes). + # Sending base64(raw_bytes) causes images to show as grey boxes on the + # receiver side because the decryption key doesn't match. + aes_key_for_api = base64.b64encode(aes_key.hex().encode("ascii")).decode("ascii") + media_item = item_builder( + encrypt_query_param=encrypted_query_param, + aes_key_for_api=aes_key_for_api, + ciphertext_size=len(ciphertext), + plaintext_size=rawsize, + filename=Path(path).name, + rawfilemd5=rawfilemd5, + ) + + last_message_id = None + if caption: + last_message_id = f"hermes-weixin-{uuid.uuid4().hex}" + await _send_message( + self._session, + base_url=self._base_url, + token=self._token, + to=chat_id, + text=self.format_message(caption), + context_token=context_token, + client_id=last_message_id, + ) + + last_message_id = f"hermes-weixin-{uuid.uuid4().hex}" + await _api_post( + self._session, + base_url=self._base_url, + endpoint=EP_SEND_MESSAGE, + payload={ + "msg": { + "from_user_id": "", + "to_user_id": chat_id, + "client_id": last_message_id, + "message_type": MSG_TYPE_BOT, + "message_state": MSG_STATE_FINISH, + "item_list": [media_item], + **({"context_token": context_token} if context_token else {}), + } + }, + token=self._token, + timeout_ms=API_TIMEOUT_MS, + ) + return last_message_id + + def _outbound_media_builder(self, path: str): + mime = mimetypes.guess_type(path)[0] or "application/octet-stream" + if mime.startswith("image/"): + return MEDIA_IMAGE, lambda **kw: { + "type": ITEM_IMAGE, + "image_item": { + "media": { + "encrypt_query_param": kw["encrypt_query_param"], + "aes_key": kw["aes_key_for_api"], + "encrypt_type": 1, + }, + "mid_size": kw["ciphertext_size"], + }, + } + if mime.startswith("video/"): + return MEDIA_VIDEO, lambda **kw: { + "type": ITEM_VIDEO, + "video_item": { + "media": { + "encrypt_query_param": kw["encrypt_query_param"], + "aes_key": kw["aes_key_for_api"], + "encrypt_type": 1, + }, + "video_size": kw["ciphertext_size"], + "play_length": kw.get("play_length", 0), + "video_md5": kw.get("rawfilemd5", ""), + }, + } + if mime.startswith("audio/") or path.endswith(".silk"): + return MEDIA_VOICE, lambda **kw: { + "type": ITEM_VOICE, + "voice_item": { + "media": { + "encrypt_query_param": kw["encrypt_query_param"], + "aes_key": kw["aes_key_for_api"], + "encrypt_type": 1, + }, + "playtime": kw.get("playtime", 0), + }, + } + return MEDIA_FILE, lambda **kw: { + "type": ITEM_FILE, + "file_item": { + "media": { + "encrypt_query_param": kw["encrypt_query_param"], + "aes_key": kw["aes_key_for_api"], + "encrypt_type": 1, + }, + "file_name": kw["filename"], + "len": str(kw["plaintext_size"]), + }, + } + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + chat_type = "group" if chat_id.endswith("@chatroom") else "dm" + return {"name": chat_id, "type": chat_type, "chat_id": chat_id} + + def format_message(self, content: Optional[str]) -> str: + if content is None: + return "" + return _normalize_markdown_blocks(content) + + +async def send_weixin_direct( + *, + extra: Dict[str, Any], + token: Optional[str], + chat_id: str, + message: str, + media_files: Optional[List[Tuple[str, bool]]] = None, +) -> Dict[str, Any]: + """ + One-shot send helper for ``send_message`` and cron delivery. + + This bypasses the long-poll adapter lifecycle and uses the raw API directly. + """ + account_id = str(extra.get("account_id") or os.getenv("WEIXIN_ACCOUNT_ID", "")).strip() + base_url = str(extra.get("base_url") or os.getenv("WEIXIN_BASE_URL", ILINK_BASE_URL)).strip().rstrip("/") + cdn_base_url = str(extra.get("cdn_base_url") or os.getenv("WEIXIN_CDN_BASE_URL", WEIXIN_CDN_BASE_URL)).strip().rstrip("/") + resolved_token = str(token or extra.get("token") or os.getenv("WEIXIN_TOKEN", "")).strip() + if not resolved_token: + return {"error": "Weixin token missing. Configure WEIXIN_TOKEN or platforms.weixin.token."} + if not account_id: + return {"error": "Weixin account ID missing. Configure WEIXIN_ACCOUNT_ID or platforms.weixin.extra.account_id."} + + token_store = ContextTokenStore(str(get_hermes_home())) + token_store.restore(account_id) + context_token = token_store.get(account_id, chat_id) + + async with aiohttp.ClientSession(trust_env=True) as session: + adapter = WeixinAdapter( + PlatformConfig( + enabled=True, + token=resolved_token, + extra={ + **dict(extra or {}), + "account_id": account_id, + "base_url": base_url, + "cdn_base_url": cdn_base_url, + }, + ) + ) + adapter._session = session + adapter._token = resolved_token + adapter._account_id = account_id + adapter._base_url = base_url + adapter._cdn_base_url = cdn_base_url + adapter._token_store = token_store + + last_result: Optional[SendResult] = None + cleaned = adapter.format_message(message) + if cleaned: + last_result = await adapter.send(chat_id, cleaned) + if not last_result.success: + return {"error": f"Weixin send failed: {last_result.error}"} + + for media_path, _is_voice in media_files or []: + ext = Path(media_path).suffix.lower() + if ext in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}: + last_result = await adapter.send_image_file(chat_id, media_path) + else: + last_result = await adapter.send_document(chat_id, media_path) + if not last_result.success: + return {"error": f"Weixin media send failed: {last_result.error}"} + + return { + "success": True, + "platform": "weixin", + "chat_id": chat_id, + "message_id": last_result.message_id if last_result else None, + "context_token_used": bool(context_token), + } diff --git a/mindcli/_vendor/gateway/platforms/whatsapp.py b/mindcli/_vendor/gateway/platforms/whatsapp.py new file mode 100644 index 0000000..d1de5b8 --- /dev/null +++ b/mindcli/_vendor/gateway/platforms/whatsapp.py @@ -0,0 +1,989 @@ +""" +WhatsApp platform adapter. + +WhatsApp integration is more complex than Telegram/Discord because: +- No official bot API for personal accounts +- Business API requires Meta Business verification +- Most solutions use web-based automation + +This adapter supports multiple backends: +1. WhatsApp Business API (requires Meta verification) +2. whatsapp-web.js (via Node.js subprocess) - for personal accounts +3. Baileys (via Node.js subprocess) - alternative for personal accounts + +For simplicity, we'll implement a generic interface that can work +with different backends via a bridge pattern. +""" + +import asyncio +import json +import logging +import os +import platform +import re +import subprocess + +_IS_WINDOWS = platform.system() == "Windows" +from pathlib import Path +from typing import Dict, Optional, Any + +from hermes_constants import get_hermes_dir + +logger = logging.getLogger(__name__) + + +def _kill_port_process(port: int) -> None: + """Kill any process listening on the given TCP port.""" + try: + if _IS_WINDOWS: + # Use netstat to find the PID bound to this port, then taskkill + result = subprocess.run( + ["netstat", "-ano", "-p", "TCP"], + capture_output=True, text=True, timeout=5, + ) + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) >= 5 and parts[3] == "LISTENING": + local_addr = parts[1] + if local_addr.endswith(f":{port}"): + try: + subprocess.run( + ["taskkill", "/PID", parts[4], "/F"], + capture_output=True, timeout=5, + ) + except subprocess.SubprocessError: + pass + else: + result = subprocess.run( + ["fuser", f"{port}/tcp"], + capture_output=True, timeout=5, + ) + if result.returncode == 0: + subprocess.run( + ["fuser", "-k", f"{port}/tcp"], + capture_output=True, timeout=5, + ) + except Exception: + pass + +import sys +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, + SUPPORTED_DOCUMENT_TYPES, + cache_image_from_url, + cache_audio_from_url, +) + + +def check_whatsapp_requirements() -> bool: + """ + Check if WhatsApp dependencies are available. + + WhatsApp requires a Node.js bridge for most implementations. + """ + # Check for Node.js + try: + result = subprocess.run( + ["node", "--version"], + capture_output=True, + text=True, + timeout=5 + ) + return result.returncode == 0 + except Exception: + return False + + +class WhatsAppAdapter(BasePlatformAdapter): + """ + WhatsApp adapter. + + This implementation uses a simple HTTP bridge pattern where: + 1. A Node.js process runs the WhatsApp Web client + 2. Messages are forwarded via HTTP/IPC to this Python adapter + 3. Responses are sent back through the bridge + + The actual Node.js bridge implementation can vary: + - whatsapp-web.js based + - Baileys based + - Business API based + + Configuration: + - bridge_script: Path to the Node.js bridge script + - bridge_port: Port for HTTP communication (default: 3000) + - session_path: Path to store WhatsApp session data + """ + + # WhatsApp message limits — practical UX limit, not protocol max. + # WhatsApp allows ~65K but long messages are unreadable on mobile. + MAX_MESSAGE_LENGTH = 4096 + + # Default bridge location relative to the hermes-agent install + _DEFAULT_BRIDGE_DIR = Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge" + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.WHATSAPP) + self._bridge_process: Optional[subprocess.Popen] = None + self._bridge_port: int = config.extra.get("bridge_port", 3000) + self._bridge_script: Optional[str] = config.extra.get( + "bridge_script", + str(self._DEFAULT_BRIDGE_DIR / "bridge.js"), + ) + self._session_path: Path = Path(config.extra.get( + "session_path", + get_hermes_dir("platforms/whatsapp/session", "whatsapp/session") + )) + self._reply_prefix: Optional[str] = config.extra.get("reply_prefix") + self._mention_patterns = self._compile_mention_patterns() + self._message_queue: asyncio.Queue = asyncio.Queue() + self._bridge_log_fh = None + self._bridge_log: Optional[Path] = None + self._poll_task: Optional[asyncio.Task] = None + self._http_session: Optional["aiohttp.ClientSession"] = None + + def _whatsapp_require_mention(self) -> bool: + configured = self.config.extra.get("require_mention") + if configured is not None: + if isinstance(configured, str): + return configured.lower() in ("true", "1", "yes", "on") + return bool(configured) + return os.getenv("WHATSAPP_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on") + + def _whatsapp_free_response_chats(self) -> set[str]: + raw = self.config.extra.get("free_response_chats") + if raw is None: + raw = os.getenv("WHATSAPP_FREE_RESPONSE_CHATS", "") + if isinstance(raw, list): + return {str(part).strip() for part in raw if str(part).strip()} + return {part.strip() for part in str(raw).split(",") if part.strip()} + + def _compile_mention_patterns(self): + patterns = self.config.extra.get("mention_patterns") + if patterns is None: + raw = os.getenv("WHATSAPP_MENTION_PATTERNS", "").strip() + if raw: + try: + patterns = json.loads(raw) + except Exception: + patterns = [part.strip() for part in raw.splitlines() if part.strip()] + if not patterns: + patterns = [part.strip() for part in raw.split(",") if part.strip()] + if patterns is None: + return [] + if isinstance(patterns, str): + patterns = [patterns] + if not isinstance(patterns, list): + logger.warning("[%s] whatsapp mention_patterns must be a list or string; got %s", self.name, type(patterns).__name__) + return [] + + compiled = [] + for pattern in patterns: + if not isinstance(pattern, str) or not pattern.strip(): + continue + try: + compiled.append(re.compile(pattern, re.IGNORECASE)) + except re.error as exc: + logger.warning("[%s] Invalid WhatsApp mention pattern %r: %s", self.name, pattern, exc) + if compiled: + logger.info("[%s] Loaded %d WhatsApp mention pattern(s)", self.name, len(compiled)) + return compiled + + @staticmethod + def _normalize_whatsapp_id(value: Optional[str]) -> str: + if not value: + return "" + normalized = str(value).strip() + if ":" in normalized and "@" in normalized: + normalized = normalized.replace(":", "@", 1) + return normalized + + def _bot_ids_from_message(self, data: Dict[str, Any]) -> set[str]: + bot_ids = set() + for candidate in data.get("botIds") or []: + normalized = self._normalize_whatsapp_id(candidate) + if normalized: + bot_ids.add(normalized) + return bot_ids + + def _message_is_reply_to_bot(self, data: Dict[str, Any]) -> bool: + quoted_participant = self._normalize_whatsapp_id(data.get("quotedParticipant")) + if not quoted_participant: + return False + return quoted_participant in self._bot_ids_from_message(data) + + def _message_mentions_bot(self, data: Dict[str, Any]) -> bool: + bot_ids = self._bot_ids_from_message(data) + if not bot_ids: + return False + mentioned_ids = { + nid + for candidate in (data.get("mentionedIds") or []) + if (nid := self._normalize_whatsapp_id(candidate)) + } + if mentioned_ids & bot_ids: + return True + + body = str(data.get("body") or "") + lower_body = body.lower() + for bot_id in bot_ids: + bare_id = bot_id.split("@", 1)[0].lower() + if bare_id and (f"@{bare_id}" in lower_body or bare_id in lower_body): + return True + return False + + def _message_matches_mention_patterns(self, data: Dict[str, Any]) -> bool: + if not self._mention_patterns: + return False + body = str(data.get("body") or "") + return any(pattern.search(body) for pattern in self._mention_patterns) + + def _clean_bot_mention_text(self, text: str, data: Dict[str, Any]) -> str: + if not text: + return text + bot_ids = self._bot_ids_from_message(data) + cleaned = text + for bot_id in bot_ids: + bare_id = bot_id.split("@", 1)[0] + if bare_id: + cleaned = re.sub(rf"@{re.escape(bare_id)}\b[,:\-]*\s*", "", cleaned) + return cleaned.strip() or text + + def _should_process_message(self, data: Dict[str, Any]) -> bool: + if not data.get("isGroup"): + return True + chat_id = str(data.get("chatId") or "") + if chat_id in self._whatsapp_free_response_chats(): + return True + if not self._whatsapp_require_mention(): + return True + body = str(data.get("body") or "").strip() + if body.startswith("/"): + return True + if self._message_is_reply_to_bot(data): + return True + if self._message_mentions_bot(data): + return True + return self._message_matches_mention_patterns(data) + + async def connect(self) -> bool: + """ + Start the WhatsApp bridge. + + This launches the Node.js bridge process and waits for it to be ready. + """ + if not check_whatsapp_requirements(): + logger.warning("[%s] Node.js not found. WhatsApp requires Node.js.", self.name) + return False + + bridge_path = Path(self._bridge_script) + if not bridge_path.exists(): + logger.warning("[%s] Bridge script not found: %s", self.name, bridge_path) + return False + + logger.info("[%s] Bridge found at %s", self.name, bridge_path) + + # Acquire scoped lock to prevent duplicate sessions + try: + if not self._acquire_platform_lock('whatsapp-session', str(self._session_path), 'WhatsApp session'): + return False + except Exception as e: + logger.warning("[%s] Could not acquire session lock (non-fatal): %s", self.name, e) + + # Auto-install npm dependencies if node_modules doesn't exist + bridge_dir = bridge_path.parent + if not (bridge_dir / "node_modules").exists(): + print(f"[{self.name}] Installing WhatsApp bridge dependencies...") + try: + install_result = subprocess.run( + ["npm", "install", "--silent"], + cwd=str(bridge_dir), + capture_output=True, + text=True, + timeout=60, + ) + if install_result.returncode != 0: + print(f"[{self.name}] npm install failed: {install_result.stderr}") + return False + print(f"[{self.name}] Dependencies installed") + except Exception as e: + print(f"[{self.name}] Failed to install dependencies: {e}") + return False + + try: + # Ensure session directory exists + self._session_path.mkdir(parents=True, exist_ok=True) + + # Check if bridge is already running and connected + import aiohttp + import asyncio + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"http://127.0.0.1:{self._bridge_port}/health", + timeout=aiohttp.ClientTimeout(total=2) + ) as resp: + if resp.status == 200: + data = await resp.json() + bridge_status = data.get("status", "unknown") + if bridge_status == "connected": + print(f"[{self.name}] Using existing bridge (status: {bridge_status})") + self._mark_connected() + self._bridge_process = None # Not managed by us + self._http_session = aiohttp.ClientSession() + self._poll_task = asyncio.create_task(self._poll_messages()) + return True + else: + print(f"[{self.name}] Bridge found but not connected (status: {bridge_status}), restarting") + except Exception: + pass # Bridge not running, start a new one + + # Kill any orphaned bridge from a previous gateway run + _kill_port_process(self._bridge_port) + await asyncio.sleep(1) + + # Start the bridge process in its own process group. + # Route output to a log file so QR codes, errors, and reconnection + # messages are preserved for troubleshooting. + whatsapp_mode = os.getenv("WHATSAPP_MODE", "self-chat") + self._bridge_log = self._session_path.parent / "bridge.log" + bridge_log_fh = open(self._bridge_log, "a") + self._bridge_log_fh = bridge_log_fh + + # Build bridge subprocess environment. + # Pass WHATSAPP_REPLY_PREFIX from config.yaml so the Node bridge + # can use it without the user needing to set a separate env var. + bridge_env = os.environ.copy() + if self._reply_prefix is not None: + bridge_env["WHATSAPP_REPLY_PREFIX"] = self._reply_prefix + + self._bridge_process = subprocess.Popen( + [ + "node", + str(bridge_path), + "--port", str(self._bridge_port), + "--session", str(self._session_path), + "--mode", whatsapp_mode, + ], + stdout=bridge_log_fh, + stderr=bridge_log_fh, + preexec_fn=None if _IS_WINDOWS else os.setsid, + env=bridge_env, + ) + + # Wait for the bridge to connect to WhatsApp. + # Phase 1: wait for the HTTP server to come up (up to 15s). + # Phase 2: wait for WhatsApp status: connected (up to 15s more). + import aiohttp + http_ready = False + data = {} + for attempt in range(15): + await asyncio.sleep(1) + if self._bridge_process.poll() is not None: + print(f"[{self.name}] Bridge process died (exit code {self._bridge_process.returncode})") + print(f"[{self.name}] Check log: {self._bridge_log}") + self._close_bridge_log() + return False + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"http://127.0.0.1:{self._bridge_port}/health", + timeout=aiohttp.ClientTimeout(total=2) + ) as resp: + if resp.status == 200: + http_ready = True + data = await resp.json() + if data.get("status") == "connected": + print(f"[{self.name}] Bridge ready (status: connected)") + break + except Exception: + continue + + if not http_ready: + print(f"[{self.name}] Bridge HTTP server did not start in 15s") + print(f"[{self.name}] Check log: {self._bridge_log}") + self._close_bridge_log() + return False + + # Phase 2: HTTP is up but WhatsApp may still be connecting. + # Give it more time to authenticate with saved credentials. + if data.get("status") != "connected": + print(f"[{self.name}] Bridge HTTP ready, waiting for WhatsApp connection...") + for attempt in range(15): + await asyncio.sleep(1) + if self._bridge_process.poll() is not None: + print(f"[{self.name}] Bridge process died during connection") + print(f"[{self.name}] Check log: {self._bridge_log}") + self._close_bridge_log() + return False + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"http://127.0.0.1:{self._bridge_port}/health", + timeout=aiohttp.ClientTimeout(total=2) + ) as resp: + if resp.status == 200: + data = await resp.json() + if data.get("status") == "connected": + print(f"[{self.name}] Bridge ready (status: connected)") + break + except Exception: + continue + else: + # Still not connected — warn but proceed (bridge may + # auto-reconnect later, e.g. after a code 515 restart). + print(f"[{self.name}] ⚠ WhatsApp not connected after 30s") + print(f"[{self.name}] Bridge log: {self._bridge_log}") + print(f"[{self.name}] If session expired, re-pair: hermes whatsapp") + + # Create a persistent HTTP session for all bridge communication + self._http_session = aiohttp.ClientSession() + + # Start message polling task + self._poll_task = asyncio.create_task(self._poll_messages()) + + self._mark_connected() + print(f"[{self.name}] Bridge started on port {self._bridge_port}") + return True + + except Exception as e: + self._release_platform_lock() + logger.error("[%s] Failed to start bridge: %s", self.name, e, exc_info=True) + self._close_bridge_log() + return False + + def _close_bridge_log(self) -> None: + """Close the bridge log file handle if open.""" + if self._bridge_log_fh: + try: + self._bridge_log_fh.close() + except Exception: + pass + self._bridge_log_fh = None + + async def _check_managed_bridge_exit(self) -> Optional[str]: + """Return a fatal error message if the managed bridge child exited.""" + if self._bridge_process is None: + return None + + returncode = self._bridge_process.poll() + if returncode is None: + return None + + message = f"WhatsApp bridge process exited unexpectedly (code {returncode})." + if not self.has_fatal_error: + logger.error("[%s] %s", self.name, message) + self._set_fatal_error("whatsapp_bridge_exited", message, retryable=True) + self._close_bridge_log() + await self._notify_fatal_error() + return self.fatal_error_message or message + + async def disconnect(self) -> None: + """Stop the WhatsApp bridge and clean up any orphaned processes.""" + if self._bridge_process: + try: + # Kill the entire process group so child node processes die too + import signal + try: + if _IS_WINDOWS: + self._bridge_process.terminate() + else: + os.killpg(os.getpgid(self._bridge_process.pid), signal.SIGTERM) + except (ProcessLookupError, PermissionError): + self._bridge_process.terminate() + await asyncio.sleep(1) + if self._bridge_process.poll() is None: + try: + if _IS_WINDOWS: + self._bridge_process.kill() + else: + os.killpg(os.getpgid(self._bridge_process.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError): + self._bridge_process.kill() + except Exception as e: + print(f"[{self.name}] Error stopping bridge: {e}") + else: + # Bridge was not started by us, don't kill it + print(f"[{self.name}] Disconnecting (external bridge left running)") + + # Cancel the poll task explicitly + if self._poll_task and not self._poll_task.done(): + self._poll_task.cancel() + try: + await self._poll_task + except (asyncio.CancelledError, Exception): + pass + self._poll_task = None + + # Close the persistent HTTP session + if self._http_session and not self._http_session.closed: + await self._http_session.close() + self._http_session = None + + self._release_platform_lock() + + self._mark_disconnected() + self._bridge_process = None + self._close_bridge_log() + print(f"[{self.name}] Disconnected") + + def format_message(self, content: str) -> str: + """Convert standard markdown to WhatsApp-compatible formatting. + + WhatsApp supports: *bold*, _italic_, ~strikethrough~, ```code```, + and monospaced `inline`. Standard markdown uses different syntax + for bold/italic/strikethrough, so we convert here. + + Code blocks (``` fenced) and inline code (`) are protected from + conversion via placeholder substitution. + """ + if not content: + return content + + # --- 1. Protect fenced code blocks from formatting changes --- + _FENCE_PH = "\x00FENCE" + fences: list[str] = [] + + def _save_fence(m: re.Match) -> str: + fences.append(m.group(0)) + return f"{_FENCE_PH}{len(fences) - 1}\x00" + + result = re.sub(r"```[\s\S]*?```", _save_fence, content) + + # --- 2. Protect inline code --- + _CODE_PH = "\x00CODE" + codes: list[str] = [] + + def _save_code(m: re.Match) -> str: + codes.append(m.group(0)) + return f"{_CODE_PH}{len(codes) - 1}\x00" + + result = re.sub(r"`[^`\n]+`", _save_code, result) + + # --- 3. Convert markdown formatting to WhatsApp syntax --- + # Bold: **text** or __text__ → *text* + result = re.sub(r"\*\*(.+?)\*\*", r"*\1*", result) + result = re.sub(r"__(.+?)__", r"*\1*", result) + # Strikethrough: ~~text~~ → ~text~ + result = re.sub(r"~~(.+?)~~", r"~\1~", result) + # Italic: *text* is already WhatsApp italic — leave as-is + # _text_ is already WhatsApp italic — leave as-is + + # --- 4. Convert markdown headers to bold text --- + # # Header → *Header* + result = re.sub(r"^#{1,6}\s+(.+)$", r"*\1*", result, flags=re.MULTILINE) + + # --- 5. Convert markdown links: [text](url) → text (url) --- + result = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", result) + + # --- 6. Restore protected sections --- + for i, fence in enumerate(fences): + result = result.replace(f"{_FENCE_PH}{i}\x00", fence) + for i, code in enumerate(codes): + result = result.replace(f"{_CODE_PH}{i}\x00", code) + + return result + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> SendResult: + """Send a message via the WhatsApp bridge. + + Formats markdown for WhatsApp, splits long messages into chunks + that preserve code block boundaries, and sends each chunk sequentially. + """ + if not self._running or not self._http_session: + return SendResult(success=False, error="Not connected") + bridge_exit = await self._check_managed_bridge_exit() + if bridge_exit: + return SendResult(success=False, error=bridge_exit) + + if not content or not content.strip(): + return SendResult(success=True, message_id=None) + + try: + import aiohttp + + # Format and chunk the message + formatted = self.format_message(content) + chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) + + last_message_id = None + for chunk in chunks: + payload: Dict[str, Any] = { + "chatId": chat_id, + "message": chunk, + } + if reply_to and last_message_id is None: + # Only reply-to on the first chunk + payload["replyTo"] = reply_to + + async with self._http_session.post( + f"http://127.0.0.1:{self._bridge_port}/send", + json=payload, + timeout=aiohttp.ClientTimeout(total=30) + ) as resp: + if resp.status == 200: + data = await resp.json() + last_message_id = data.get("messageId") + else: + error = await resp.text() + return SendResult(success=False, error=error) + + # Small delay between chunks to avoid rate limiting + if len(chunks) > 1: + await asyncio.sleep(0.3) + + return SendResult( + success=True, + message_id=last_message_id, + ) + except Exception as e: + return SendResult(success=False, error=str(e)) + + async def edit_message( + self, + chat_id: str, + message_id: str, + content: str, + ) -> SendResult: + """Edit a previously sent message via the WhatsApp bridge.""" + if not self._running or not self._http_session: + return SendResult(success=False, error="Not connected") + bridge_exit = await self._check_managed_bridge_exit() + if bridge_exit: + return SendResult(success=False, error=bridge_exit) + try: + import aiohttp + async with self._http_session.post( + f"http://127.0.0.1:{self._bridge_port}/edit", + json={ + "chatId": chat_id, + "messageId": message_id, + "message": content, + }, + timeout=aiohttp.ClientTimeout(total=15) + ) as resp: + if resp.status == 200: + return SendResult(success=True, message_id=message_id) + else: + error = await resp.text() + return SendResult(success=False, error=error) + except Exception as e: + return SendResult(success=False, error=str(e)) + + async def _send_media_to_bridge( + self, + chat_id: str, + file_path: str, + media_type: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + ) -> SendResult: + """Send any media file via bridge /send-media endpoint.""" + if not self._running or not self._http_session: + return SendResult(success=False, error="Not connected") + bridge_exit = await self._check_managed_bridge_exit() + if bridge_exit: + return SendResult(success=False, error=bridge_exit) + try: + import aiohttp + + if not os.path.exists(file_path): + return SendResult(success=False, error=f"File not found: {file_path}") + + payload: Dict[str, Any] = { + "chatId": chat_id, + "filePath": file_path, + "mediaType": media_type, + } + if caption: + payload["caption"] = caption + if file_name: + payload["fileName"] = file_name + + async with self._http_session.post( + f"http://127.0.0.1:{self._bridge_port}/send-media", + json=payload, + timeout=aiohttp.ClientTimeout(total=120), + ) as resp: + if resp.status == 200: + data = await resp.json() + return SendResult( + success=True, + message_id=data.get("messageId"), + raw_response=data, + ) + else: + error = await resp.text() + return SendResult(success=False, error=error) + + except Exception as e: + return SendResult(success=False, error=str(e)) + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + ) -> SendResult: + """Download image URL to cache, send natively via bridge.""" + try: + local_path = await cache_image_from_url(image_url) + return await self._send_media_to_bridge(chat_id, local_path, "image", caption) + except Exception: + return await super().send_image(chat_id, image_url, caption, reply_to) + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + """Send a local image file natively via bridge.""" + return await self._send_media_to_bridge(chat_id, image_path, "image", caption) + + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + """Send a video natively via bridge — plays inline in WhatsApp.""" + return await self._send_media_to_bridge(chat_id, video_path, "video", caption) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + """Send a document/file as a downloadable attachment via bridge.""" + return await self._send_media_to_bridge( + chat_id, file_path, "document", caption, + file_name or os.path.basename(file_path), + ) + + async def send_typing(self, chat_id: str, metadata=None) -> None: + """Send typing indicator via bridge.""" + if not self._running or not self._http_session: + return + if await self._check_managed_bridge_exit(): + return + + try: + import aiohttp + + await self._http_session.post( + f"http://127.0.0.1:{self._bridge_port}/typing", + json={"chatId": chat_id}, + timeout=aiohttp.ClientTimeout(total=5) + ) + except Exception: + pass # Ignore typing indicator failures + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Get information about a WhatsApp chat.""" + if not self._running or not self._http_session: + return {"name": "Unknown", "type": "dm"} + if await self._check_managed_bridge_exit(): + return {"name": chat_id, "type": "dm"} + + try: + import aiohttp + + async with self._http_session.get( + f"http://127.0.0.1:{self._bridge_port}/chat/{chat_id}", + timeout=aiohttp.ClientTimeout(total=10) + ) as resp: + if resp.status == 200: + data = await resp.json() + return { + "name": data.get("name", chat_id), + "type": "group" if data.get("isGroup") else "dm", + "participants": data.get("participants", []), + } + except Exception as e: + logger.debug("Could not get WhatsApp chat info for %s: %s", chat_id, e) + + return {"name": chat_id, "type": "dm"} + + async def _poll_messages(self) -> None: + """Poll the bridge for incoming messages.""" + import aiohttp + + while self._running: + if not self._http_session: + break + bridge_exit = await self._check_managed_bridge_exit() + if bridge_exit: + print(f"[{self.name}] {bridge_exit}") + break + try: + async with self._http_session.get( + f"http://127.0.0.1:{self._bridge_port}/messages", + timeout=aiohttp.ClientTimeout(total=30) + ) as resp: + if resp.status == 200: + messages = await resp.json() + for msg_data in messages: + event = await self._build_message_event(msg_data) + if event: + await self.handle_message(event) + except asyncio.CancelledError: + break + except Exception as e: + bridge_exit = await self._check_managed_bridge_exit() + if bridge_exit: + print(f"[{self.name}] {bridge_exit}") + break + print(f"[{self.name}] Poll error: {e}") + await asyncio.sleep(5) + + await asyncio.sleep(1) # Poll interval + + async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEvent]: + """Build a MessageEvent from bridge message data, downloading images to cache.""" + try: + if not self._should_process_message(data): + return None + + # Determine message type + msg_type = MessageType.TEXT + if data.get("hasMedia"): + media_type = data.get("mediaType", "") + if "image" in media_type: + msg_type = MessageType.PHOTO + elif "video" in media_type: + msg_type = MessageType.VIDEO + elif "audio" in media_type or "ptt" in media_type: # ptt = voice note + msg_type = MessageType.VOICE + else: + msg_type = MessageType.DOCUMENT + + # Determine chat type + is_group = data.get("isGroup", False) + chat_type = "group" if is_group else "dm" + + # Build source + source = self.build_source( + chat_id=data.get("chatId", ""), + chat_name=data.get("chatName"), + chat_type=chat_type, + user_id=data.get("senderId"), + user_name=data.get("senderName"), + ) + + # Download media URLs to the local cache so agent tools + # can access them reliably regardless of URL expiration. + raw_urls = data.get("mediaUrls", []) + cached_urls = [] + media_types = [] + for url in raw_urls: + if msg_type == MessageType.PHOTO and url.startswith(("http://", "https://")): + try: + cached_path = await cache_image_from_url(url, ext=".jpg") + cached_urls.append(cached_path) + media_types.append("image/jpeg") + print(f"[{self.name}] Cached user image: {cached_path}", flush=True) + except Exception as e: + print(f"[{self.name}] Failed to cache image: {e}", flush=True) + cached_urls.append(url) + media_types.append("image/jpeg") + elif msg_type == MessageType.PHOTO and os.path.isabs(url): + # Local file path — bridge already downloaded the image + cached_urls.append(url) + media_types.append("image/jpeg") + print(f"[{self.name}] Using bridge-cached image: {url}", flush=True) + elif msg_type == MessageType.VOICE and url.startswith(("http://", "https://")): + try: + cached_path = await cache_audio_from_url(url, ext=".ogg") + cached_urls.append(cached_path) + media_types.append("audio/ogg") + print(f"[{self.name}] Cached user voice: {cached_path}", flush=True) + except Exception as e: + print(f"[{self.name}] Failed to cache voice: {e}", flush=True) + cached_urls.append(url) + media_types.append("audio/ogg") + elif msg_type == MessageType.VOICE and os.path.isabs(url): + # Local file path — bridge already downloaded the audio + cached_urls.append(url) + media_types.append("audio/ogg") + print(f"[{self.name}] Using bridge-cached audio: {url}", flush=True) + elif msg_type == MessageType.DOCUMENT and os.path.isabs(url): + # Local file path — bridge already downloaded the document + cached_urls.append(url) + ext = Path(url).suffix.lower() + mime = SUPPORTED_DOCUMENT_TYPES.get(ext, "application/octet-stream") + media_types.append(mime) + print(f"[{self.name}] Using bridge-cached document: {url}", flush=True) + elif msg_type == MessageType.VIDEO and os.path.isabs(url): + cached_urls.append(url) + media_types.append("video/mp4") + print(f"[{self.name}] Using bridge-cached video: {url}", flush=True) + else: + cached_urls.append(url) + media_types.append("unknown") + + # For text-readable documents, inject file content directly into + # the message text so the agent can read it inline. + # Cap at 100KB to match Telegram/Discord/Slack behaviour. + body = data.get("body", "") + if data.get("isGroup"): + body = self._clean_bot_mention_text(body, data) + MAX_TEXT_INJECT_BYTES = 100 * 1024 + if msg_type == MessageType.DOCUMENT and cached_urls: + for doc_path in cached_urls: + ext = Path(doc_path).suffix.lower() + if ext in (".txt", ".md", ".csv", ".json", ".xml", ".yaml", ".yml", ".log", ".py", ".js", ".ts", ".html", ".css"): + try: + file_size = Path(doc_path).stat().st_size + if file_size > MAX_TEXT_INJECT_BYTES: + print(f"[{self.name}] Skipping text injection for {doc_path} ({file_size} bytes > {MAX_TEXT_INJECT_BYTES})", flush=True) + continue + content = Path(doc_path).read_text(errors="replace") + fname = Path(doc_path).name + # Remove the doc_<hex>_ prefix for display + display_name = fname + if "_" in fname: + parts = fname.split("_", 2) + if len(parts) >= 3: + display_name = parts[2] + injection = f"[Content of {display_name}]:\n{content}" + if body: + body = f"{injection}\n\n{body}" + else: + body = injection + print(f"[{self.name}] Injected text content from: {doc_path}", flush=True) + except Exception as e: + print(f"[{self.name}] Failed to read document text: {e}", flush=True) + + return MessageEvent( + text=body, + message_type=msg_type, + source=source, + raw_message=data, + message_id=data.get("messageId"), + media_urls=cached_urls, + media_types=media_types, + ) + except Exception as e: + print(f"[{self.name}] Error building event: {e}") + return None diff --git a/mindcli/_vendor/gateway/restart.py b/mindcli/_vendor/gateway/restart.py new file mode 100644 index 0000000..fe9b700 --- /dev/null +++ b/mindcli/_vendor/gateway/restart.py @@ -0,0 +1,20 @@ +"""Shared gateway restart constants and parsing helpers.""" + +from hermes_cli.config import DEFAULT_CONFIG + +# EX_TEMPFAIL from sysexits.h — used to ask the service manager to restart +# the gateway after a graceful drain/reload path completes. +GATEWAY_SERVICE_RESTART_EXIT_CODE = 75 + +DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT = float( + DEFAULT_CONFIG["agent"]["restart_drain_timeout"] +) + + +def parse_restart_drain_timeout(raw: object) -> float: + """Parse a configured drain timeout, falling back to the shared default.""" + try: + value = float(raw) if str(raw or "").strip() else DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT + except (TypeError, ValueError): + return DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT + return max(0.0, value) diff --git a/mindcli/_vendor/gateway/run.py b/mindcli/_vendor/gateway/run.py new file mode 100644 index 0000000..c8c2525 --- /dev/null +++ b/mindcli/_vendor/gateway/run.py @@ -0,0 +1,9015 @@ +""" +Gateway runner - entry point for messaging platform integrations. + +This module provides: +- start_gateway(): Start all configured platform adapters +- GatewayRunner: Main class managing the gateway lifecycle + +Usage: + # Start the gateway + python -m gateway.run + + # Or from CLI + python cli.py --gateway +""" + +import asyncio +import json +import logging +import os +import re +import shlex +import sys +import signal +import tempfile +import threading +import time +from pathlib import Path +from datetime import datetime +from typing import Dict, Optional, Any, List + +# --------------------------------------------------------------------------- +# SSL certificate auto-detection for NixOS and other non-standard systems. +# Must run BEFORE any HTTP library (discord, aiohttp, etc.) is imported. +# --------------------------------------------------------------------------- +def _ensure_ssl_certs() -> None: + """Set SSL_CERT_FILE if the system doesn't expose CA certs to Python.""" + if "SSL_CERT_FILE" in os.environ: + return # user already configured it + + import ssl + + # 1. Python's compiled-in defaults + paths = ssl.get_default_verify_paths() + for candidate in (paths.cafile, paths.openssl_cafile): + if candidate and os.path.exists(candidate): + os.environ["SSL_CERT_FILE"] = candidate + return + + # 2. certifi (ships its own Mozilla bundle) + try: + import certifi + os.environ["SSL_CERT_FILE"] = certifi.where() + return + except ImportError: + pass + + # 3. Common distro / macOS locations + for candidate in ( + "/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu/Gentoo + "/etc/pki/tls/certs/ca-bundle.crt", # RHEL/CentOS 7 + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", # RHEL/CentOS 8+ + "/etc/ssl/ca-bundle.pem", # SUSE/OpenSUSE + "/etc/ssl/cert.pem", # Alpine / macOS + "/etc/pki/tls/cert.pem", # Fedora + "/usr/local/etc/openssl@1.1/cert.pem", # macOS Homebrew Intel + "/opt/homebrew/etc/openssl@1.1/cert.pem", # macOS Homebrew ARM + ): + if os.path.exists(candidate): + os.environ["SSL_CERT_FILE"] = candidate + return + +_ensure_ssl_certs() + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# Resolve Hermes home directory (respects HERMES_HOME override) +from hermes_constants import get_hermes_home +from utils import atomic_yaml_write, is_truthy_value +_hermes_home = get_hermes_home() + +# Load environment variables from ~/.hermes/.env first. +# User-managed env files should override stale shell exports on restart. +from dotenv import load_dotenv # backward-compat for tests that monkeypatch this symbol +from hermes_cli.env_loader import load_hermes_dotenv +_env_path = _hermes_home / '.env' +load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).resolve().parents[1] / '.env') + +# Bridge config.yaml values into the environment so os.getenv() picks them up. +# config.yaml is authoritative for terminal settings — overrides .env. +_config_path = _hermes_home / 'config.yaml' +if _config_path.exists(): + try: + import yaml as _yaml + with open(_config_path, encoding="utf-8") as _f: + _cfg = _yaml.safe_load(_f) or {} + # Expand ${ENV_VAR} references before bridging to env vars. + from hermes_cli.config import _expand_env_vars + _cfg = _expand_env_vars(_cfg) + # Top-level simple values (fallback only — don't override .env) + for _key, _val in _cfg.items(): + if isinstance(_val, (str, int, float, bool)) and _key not in os.environ: + os.environ[_key] = str(_val) + # Terminal config is nested — bridge to TERMINAL_* env vars. + # config.yaml overrides .env for these since it's the documented config path. + _terminal_cfg = _cfg.get("terminal", {}) + if _terminal_cfg and isinstance(_terminal_cfg, dict): + _terminal_env_map = { + "backend": "TERMINAL_ENV", + "cwd": "TERMINAL_CWD", + "timeout": "TERMINAL_TIMEOUT", + "lifetime_seconds": "TERMINAL_LIFETIME_SECONDS", + "docker_image": "TERMINAL_DOCKER_IMAGE", + "docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV", + "singularity_image": "TERMINAL_SINGULARITY_IMAGE", + "modal_image": "TERMINAL_MODAL_IMAGE", + "daytona_image": "TERMINAL_DAYTONA_IMAGE", + "ssh_host": "TERMINAL_SSH_HOST", + "ssh_user": "TERMINAL_SSH_USER", + "ssh_port": "TERMINAL_SSH_PORT", + "ssh_key": "TERMINAL_SSH_KEY", + "container_cpu": "TERMINAL_CONTAINER_CPU", + "container_memory": "TERMINAL_CONTAINER_MEMORY", + "container_disk": "TERMINAL_CONTAINER_DISK", + "container_persistent": "TERMINAL_CONTAINER_PERSISTENT", + "docker_volumes": "TERMINAL_DOCKER_VOLUMES", + "sandbox_dir": "TERMINAL_SANDBOX_DIR", + "persistent_shell": "TERMINAL_PERSISTENT_SHELL", + } + for _cfg_key, _env_var in _terminal_env_map.items(): + if _cfg_key in _terminal_cfg: + _val = _terminal_cfg[_cfg_key] + if isinstance(_val, list): + os.environ[_env_var] = json.dumps(_val) + else: + os.environ[_env_var] = str(_val) + # Compression config is read directly from config.yaml by run_agent.py + # and auxiliary_client.py — no env var bridging needed. + # Auxiliary model/direct-endpoint overrides (vision, web_extract). + # Each task has provider/model/base_url/api_key; bridge non-default values to env vars. + _auxiliary_cfg = _cfg.get("auxiliary", {}) + if _auxiliary_cfg and isinstance(_auxiliary_cfg, dict): + _aux_task_env = { + "vision": { + "provider": "AUXILIARY_VISION_PROVIDER", + "model": "AUXILIARY_VISION_MODEL", + "base_url": "AUXILIARY_VISION_BASE_URL", + "api_key": "AUXILIARY_VISION_API_KEY", + }, + "web_extract": { + "provider": "AUXILIARY_WEB_EXTRACT_PROVIDER", + "model": "AUXILIARY_WEB_EXTRACT_MODEL", + "base_url": "AUXILIARY_WEB_EXTRACT_BASE_URL", + "api_key": "AUXILIARY_WEB_EXTRACT_API_KEY", + }, + "approval": { + "provider": "AUXILIARY_APPROVAL_PROVIDER", + "model": "AUXILIARY_APPROVAL_MODEL", + "base_url": "AUXILIARY_APPROVAL_BASE_URL", + "api_key": "AUXILIARY_APPROVAL_API_KEY", + }, + } + for _task_key, _env_map in _aux_task_env.items(): + _task_cfg = _auxiliary_cfg.get(_task_key, {}) + if not isinstance(_task_cfg, dict): + continue + _prov = str(_task_cfg.get("provider", "")).strip() + _model = str(_task_cfg.get("model", "")).strip() + _base_url = str(_task_cfg.get("base_url", "")).strip() + _api_key = str(_task_cfg.get("api_key", "")).strip() + if _prov and _prov != "auto": + os.environ[_env_map["provider"]] = _prov + if _model: + os.environ[_env_map["model"]] = _model + if _base_url: + os.environ[_env_map["base_url"]] = _base_url + if _api_key: + os.environ[_env_map["api_key"]] = _api_key + _agent_cfg = _cfg.get("agent", {}) + if _agent_cfg and isinstance(_agent_cfg, dict): + if "max_turns" in _agent_cfg: + os.environ["HERMES_MAX_ITERATIONS"] = str(_agent_cfg["max_turns"]) + # Bridge agent.gateway_timeout → HERMES_AGENT_TIMEOUT env var. + # Env var from .env takes precedence (already in os.environ). + if "gateway_timeout" in _agent_cfg and "HERMES_AGENT_TIMEOUT" not in os.environ: + os.environ["HERMES_AGENT_TIMEOUT"] = str(_agent_cfg["gateway_timeout"]) + if "gateway_timeout_warning" in _agent_cfg and "HERMES_AGENT_TIMEOUT_WARNING" not in os.environ: + os.environ["HERMES_AGENT_TIMEOUT_WARNING"] = str(_agent_cfg["gateway_timeout_warning"]) + if "gateway_notify_interval" in _agent_cfg and "HERMES_AGENT_NOTIFY_INTERVAL" not in os.environ: + os.environ["HERMES_AGENT_NOTIFY_INTERVAL"] = str(_agent_cfg["gateway_notify_interval"]) + if "restart_drain_timeout" in _agent_cfg and "HERMES_RESTART_DRAIN_TIMEOUT" not in os.environ: + os.environ["HERMES_RESTART_DRAIN_TIMEOUT"] = str(_agent_cfg["restart_drain_timeout"]) + _display_cfg = _cfg.get("display", {}) + if _display_cfg and isinstance(_display_cfg, dict): + if "busy_input_mode" in _display_cfg and "HERMES_GATEWAY_BUSY_INPUT_MODE" not in os.environ: + os.environ["HERMES_GATEWAY_BUSY_INPUT_MODE"] = str(_display_cfg["busy_input_mode"]) + # Timezone: bridge config.yaml → HERMES_TIMEZONE env var. + # HERMES_TIMEZONE from .env takes precedence (already in os.environ). + _tz_cfg = _cfg.get("timezone", "") + if _tz_cfg and isinstance(_tz_cfg, str) and "HERMES_TIMEZONE" not in os.environ: + os.environ["HERMES_TIMEZONE"] = _tz_cfg.strip() + # Security settings + _security_cfg = _cfg.get("security", {}) + if isinstance(_security_cfg, dict): + _redact = _security_cfg.get("redact_secrets") + if _redact is not None: + os.environ["HERMES_REDACT_SECRETS"] = str(_redact).lower() + except Exception: + pass # Non-fatal; gateway can still run with .env values + +# Apply IPv4 preference if configured (before any HTTP clients are created). +try: + from hermes_constants import apply_ipv4_preference + _network_cfg = (_cfg if '_cfg' in dir() else {}).get("network", {}) + if isinstance(_network_cfg, dict) and _network_cfg.get("force_ipv4"): + apply_ipv4_preference(force=True) +except Exception: + pass + +# Validate config structure early — log warnings so gateway operators see problems +try: + from hermes_cli.config import print_config_warnings + print_config_warnings() +except Exception: + pass + +# Gateway runs in quiet mode - suppress debug output and use cwd directly (no temp dirs) +os.environ["HERMES_QUIET"] = "1" + +# Enable interactive exec approval for dangerous commands on messaging platforms +os.environ["HERMES_EXEC_ASK"] = "1" + +# Set terminal working directory for messaging platforms. +# If the user set an explicit path in config.yaml (not "." or "auto"), +# respect it. Otherwise use MESSAGING_CWD or default to home directory. +_configured_cwd = os.environ.get("TERMINAL_CWD", "") +if not _configured_cwd or _configured_cwd in (".", "auto", "cwd"): + messaging_cwd = os.getenv("MESSAGING_CWD") or str(Path.home()) + os.environ["TERMINAL_CWD"] = messaging_cwd + +from gateway.config import ( + Platform, + GatewayConfig, + load_gateway_config, +) +from gateway.session import ( + SessionStore, + SessionSource, + SessionContext, + build_session_context, + build_session_context_prompt, + build_session_key, +) +from gateway.delivery import DeliveryRouter +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + merge_pending_message_event, +) +from gateway.restart import ( + DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT, + GATEWAY_SERVICE_RESTART_EXIT_CODE, + parse_restart_drain_timeout, +) + + +def _normalize_whatsapp_identifier(value: str) -> str: + """Strip WhatsApp JID/LID syntax down to its stable numeric identifier.""" + return ( + str(value or "") + .strip() + .replace("+", "", 1) + .split(":", 1)[0] + .split("@", 1)[0] + ) + + +def _expand_whatsapp_auth_aliases(identifier: str) -> set: + """Resolve WhatsApp phone/LID aliases using bridge session mapping files.""" + normalized = _normalize_whatsapp_identifier(identifier) + if not normalized: + return set() + + session_dir = _hermes_home / "whatsapp" / "session" + resolved = set() + queue = [normalized] + + while queue: + current = queue.pop(0) + if not current or current in resolved: + continue + + resolved.add(current) + for suffix in ("", "_reverse"): + mapping_path = session_dir / f"lid-mapping-{current}{suffix}.json" + if not mapping_path.exists(): + continue + try: + mapped = _normalize_whatsapp_identifier( + json.loads(mapping_path.read_text(encoding="utf-8")) + ) + except Exception: + continue + if mapped and mapped not in resolved: + queue.append(mapped) + + return resolved + +logger = logging.getLogger(__name__) + +# Sentinel placed into _running_agents immediately when a session starts +# processing, *before* any await. Prevents a second message for the same +# session from bypassing the "already running" guard during the async gap +# between the guard check and actual agent creation. +_AGENT_PENDING_SENTINEL = object() + + +def _resolve_runtime_agent_kwargs() -> dict: + """Resolve provider credentials for gateway-created AIAgent instances.""" + from hermes_cli.runtime_provider import ( + resolve_runtime_provider, + format_runtime_provider_error, + ) + + try: + runtime = resolve_runtime_provider( + requested=os.getenv("HERMES_INFERENCE_PROVIDER"), + ) + except Exception as exc: + raise RuntimeError(format_runtime_provider_error(exc)) from exc + + return { + "api_key": runtime.get("api_key"), + "base_url": runtime.get("base_url"), + "provider": runtime.get("provider"), + "api_mode": runtime.get("api_mode"), + "command": runtime.get("command"), + "args": list(runtime.get("args") or []), + "credential_pool": runtime.get("credential_pool"), + } + + +def _build_media_placeholder(event) -> str: + """Build a text placeholder for media-only events so they aren't dropped. + + When a photo/document is queued during active processing and later + dequeued, only .text is extracted. If the event has no caption, + the media would be silently lost. This builds a placeholder that + the vision enrichment pipeline will replace with a real description. + """ + parts = [] + media_urls = getattr(event, "media_urls", None) or [] + media_types = getattr(event, "media_types", None) or [] + for i, url in enumerate(media_urls): + mtype = media_types[i] if i < len(media_types) else "" + if mtype.startswith("image/") or getattr(event, "message_type", None) == MessageType.PHOTO: + parts.append(f"[User sent an image: {url}]") + elif mtype.startswith("audio/"): + parts.append(f"[User sent audio: {url}]") + else: + parts.append(f"[User sent a file: {url}]") + return "\n".join(parts) + + +def _dequeue_pending_event(adapter, session_key: str) -> MessageEvent | None: + """Consume and return the full pending event for a session. + + Queued follow-ups must preserve their media metadata so they can re-enter + the normal image/STT/document preprocessing path instead of being reduced + to a placeholder string. + """ + return adapter.get_pending_message(session_key) + + +def _check_unavailable_skill(command_name: str) -> str | None: + """Check if a command matches a known-but-inactive skill. + + Returns a helpful message if the skill exists but is disabled or only + available as an optional install. Returns None if no match found. + """ + # Normalize: command uses hyphens, skill names may use hyphens or underscores + normalized = command_name.lower().replace("_", "-") + try: + from tools.skills_tool import _get_disabled_skill_names + from agent.skill_utils import get_all_skills_dirs + disabled = _get_disabled_skill_names() + + # Check disabled skills across all dirs (local + external) + for skills_dir in get_all_skills_dirs(): + if not skills_dir.exists(): + continue + for skill_md in skills_dir.rglob("SKILL.md"): + if any(part in ('.git', '.github', '.hub') for part in skill_md.parts): + continue + name = skill_md.parent.name.lower().replace("_", "-") + if name == normalized and name in disabled: + return ( + f"The **{command_name}** skill is installed but disabled.\n" + f"Enable it with: `hermes skills config`" + ) + + # Check optional skills (shipped with repo but not installed) + from hermes_constants import get_optional_skills_dir + repo_root = Path(__file__).resolve().parent.parent + optional_dir = get_optional_skills_dir(repo_root / "optional-skills") + if optional_dir.exists(): + for skill_md in optional_dir.rglob("SKILL.md"): + name = skill_md.parent.name.lower().replace("_", "-") + if name == normalized: + # Build install path: official/<category>/<name> + rel = skill_md.parent.relative_to(optional_dir) + parts = list(rel.parts) + install_path = f"official/{'/'.join(parts)}" + return ( + f"The **{command_name}** skill is available but not installed.\n" + f"Install it with: `hermes skills install {install_path}`" + ) + except Exception: + pass + return None + + +def _platform_config_key(platform: "Platform") -> str: + """Map a Platform enum to its config.yaml key (LOCAL→"cli", rest→enum value).""" + return "cli" if platform == Platform.LOCAL else platform.value + + +def _load_gateway_config() -> dict: + """Load and parse ~/.hermes/config.yaml, returning {} on any error.""" + try: + config_path = _hermes_home / 'config.yaml' + if config_path.exists(): + import yaml + with open(config_path, 'r', encoding='utf-8') as f: + return yaml.safe_load(f) or {} + except Exception: + logger.debug("Could not load gateway config from %s", _hermes_home / 'config.yaml') + return {} + + +def _resolve_gateway_model(config: dict | None = None) -> str: + """Read model from config.yaml — single source of truth. + + Without this, temporary AIAgent instances (memory flush, /compress) fall + back to the hardcoded default which fails when the active provider is + openai-codex. + """ + cfg = config if config is not None else _load_gateway_config() + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, str): + return model_cfg + elif isinstance(model_cfg, dict): + return model_cfg.get("default") or model_cfg.get("model") or "" + return "" + + +def _resolve_hermes_bin() -> Optional[list[str]]: + """Resolve the Hermes update command as argv parts. + + Tries in order: + 1. ``shutil.which("hermes")`` — standard PATH lookup + 2. ``sys.executable -m hermes_cli.main`` — fallback when Hermes is running + from a venv/module invocation and the ``hermes`` shim is not on PATH + + Returns argv parts ready for quoting/joining, or ``None`` if neither works. + """ + import shutil + + hermes_bin = shutil.which("hermes") + if hermes_bin: + return [hermes_bin] + + try: + import importlib.util + + if importlib.util.find_spec("hermes_cli") is not None: + return [sys.executable, "-m", "hermes_cli.main"] + except Exception: + pass + + return None + + +def _format_gateway_process_notification(evt: dict) -> "str | None": + """Format a watch pattern event from completion_queue into a [SYSTEM:] message.""" + evt_type = evt.get("type", "completion") + _sid = evt.get("session_id", "unknown") + _cmd = evt.get("command", "unknown") + + if evt_type == "watch_disabled": + return f"[SYSTEM: {evt.get('message', '')}]" + + if evt_type == "watch_match": + _pat = evt.get("pattern", "?") + _out = evt.get("output", "") + _sup = evt.get("suppressed", 0) + text = ( + f"[SYSTEM: Background process {_sid} matched " + f"watch pattern \"{_pat}\".\n" + f"Command: {_cmd}\n" + f"Matched output:\n{_out}" + ) + if _sup: + text += f"\n({_sup} earlier matches were suppressed by rate limit)" + text += "]" + return text + + return None + + +class GatewayRunner: + """ + Main gateway controller. + + Manages the lifecycle of all platform adapters and routes + messages to/from the agent. + """ + + # Class-level defaults so partial construction in tests doesn't + # blow up on attribute access. + _running_agents_ts: Dict[str, float] = {} + _busy_input_mode: str = "interrupt" + _restart_drain_timeout: float = DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT + _exit_code: Optional[int] = None + _draining: bool = False + _restart_requested: bool = False + _restart_task_started: bool = False + _restart_detached: bool = False + _restart_via_service: bool = False + _stop_task: Optional[asyncio.Task] = None + _session_model_overrides: Dict[str, Dict[str, str]] = {} + + def __init__(self, config: Optional[GatewayConfig] = None): + self.config = config or load_gateway_config() + self.adapters: Dict[Platform, BasePlatformAdapter] = {} + + # Load ephemeral config from config.yaml / env vars. + # Both are injected at API-call time only and never persisted. + self._prefill_messages = self._load_prefill_messages() + self._ephemeral_system_prompt = self._load_ephemeral_system_prompt() + self._reasoning_config = self._load_reasoning_config() + self._service_tier = self._load_service_tier() + self._show_reasoning = self._load_show_reasoning() + self._busy_input_mode = self._load_busy_input_mode() + self._restart_drain_timeout = self._load_restart_drain_timeout() + self._provider_routing = self._load_provider_routing() + self._fallback_model = self._load_fallback_model() + self._smart_model_routing = self._load_smart_model_routing() + + # Wire process registry into session store for reset protection + from tools.process_registry import process_registry + self.session_store = SessionStore( + self.config.sessions_dir, self.config, + has_active_processes_fn=lambda key: process_registry.has_active_for_session(key), + ) + self.delivery_router = DeliveryRouter(self.config) + self._running = False + self._shutdown_event = asyncio.Event() + self._exit_cleanly = False + self._exit_with_failure = False + self._exit_reason: Optional[str] = None + self._exit_code: Optional[int] = None + self._draining = False + self._restart_requested = False + self._restart_task_started = False + self._restart_detached = False + self._restart_via_service = False + self._stop_task: Optional[asyncio.Task] = None + + # Track running agents per session for interrupt support + # Key: session_key, Value: AIAgent instance + self._running_agents: Dict[str, Any] = {} + self._running_agents_ts: Dict[str, float] = {} # start timestamp per session + self._pending_messages: Dict[str, str] = {} # Queued messages during interrupt + + # Cache AIAgent instances per session to preserve prompt caching. + # Without this, a new AIAgent is created per message, rebuilding the + # system prompt (including memory) every turn — breaking prefix cache + # and costing ~10x more on providers with prompt caching (Anthropic). + # Key: session_key, Value: (AIAgent, config_signature_str) + import threading as _threading + self._agent_cache: Dict[str, tuple] = {} + self._agent_cache_lock = _threading.Lock() + + # Per-session model overrides from /model command. + # Key: session_key, Value: dict with model/provider/api_key/base_url/api_mode + self._session_model_overrides: Dict[str, Dict[str, str]] = {} + # Track pending exec approvals per session + # Key: session_key, Value: {"command": str, "pattern_key": str, ...} + self._pending_approvals: Dict[str, Dict[str, Any]] = {} + + # Track platforms that failed to connect for background reconnection. + # Key: Platform enum, Value: {"config": platform_config, "attempts": int, "next_retry": float} + self._failed_platforms: Dict[Platform, Dict[str, Any]] = {} + + # Track pending /update prompt responses per session. + # Key: session_key, Value: True when a prompt is waiting for user input. + self._update_prompt_pending: Dict[str, bool] = {} + + # Persistent Honcho managers keyed by gateway session key. + # This preserves write_frequency="session" semantics across short-lived + # per-message AIAgent instances. + + + + # Ensure tirith security scanner is available (downloads if needed) + try: + from tools.tirith_security import ensure_installed + ensure_installed(log_failures=False) + except Exception: + pass # Non-fatal — fail-open at scan time if unavailable + + # Initialize session database for session_search tool support + self._session_db = None + try: + from hermes_state import SessionDB + self._session_db = SessionDB() + except Exception as e: + logger.debug("SQLite session store not available: %s", e) + + # DM pairing store for code-based user authorization + from gateway.pairing import PairingStore + self.pairing_store = PairingStore() + + # Event hook system + from gateway.hooks import HookRegistry + self.hooks = HookRegistry() + + # Per-chat voice reply mode: "off" | "voice_only" | "all" + self._voice_mode: Dict[str, str] = self._load_voice_modes() + + # Track background tasks to prevent garbage collection mid-execution + self._background_tasks: set = set() + + + + + # -- Setup skill availability ---------------------------------------- + + def _has_setup_skill(self) -> bool: + """Check if the hermes-agent-setup skill is installed.""" + try: + from tools.skill_manager_tool import _find_skill + return _find_skill("hermes-agent-setup") is not None + except Exception: + return False + + # -- Voice mode persistence ------------------------------------------ + + _VOICE_MODE_PATH = _hermes_home / "gateway_voice_mode.json" + + def _load_voice_modes(self) -> Dict[str, str]: + try: + data = json.loads(self._VOICE_MODE_PATH.read_text()) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return {} + + if not isinstance(data, dict): + return {} + + valid_modes = {"off", "voice_only", "all"} + return { + str(chat_id): mode + for chat_id, mode in data.items() + if mode in valid_modes + } + + def _save_voice_modes(self) -> None: + try: + self._VOICE_MODE_PATH.parent.mkdir(parents=True, exist_ok=True) + self._VOICE_MODE_PATH.write_text( + json.dumps(self._voice_mode, indent=2) + ) + except OSError as e: + logger.warning("Failed to save voice modes: %s", e) + + def _set_adapter_auto_tts_disabled(self, adapter, chat_id: str, disabled: bool) -> None: + """Update an adapter's in-memory auto-TTS suppression set if present.""" + disabled_chats = getattr(adapter, "_auto_tts_disabled_chats", None) + if not isinstance(disabled_chats, set): + return + if disabled: + disabled_chats.add(chat_id) + else: + disabled_chats.discard(chat_id) + + def _sync_voice_mode_state_to_adapter(self, adapter) -> None: + """Restore persisted /voice off state into a live platform adapter.""" + disabled_chats = getattr(adapter, "_auto_tts_disabled_chats", None) + if not isinstance(disabled_chats, set): + return + disabled_chats.clear() + disabled_chats.update( + chat_id for chat_id, mode in self._voice_mode.items() if mode == "off" + ) + + # ----------------------------------------------------------------- + + def _flush_memories_for_session( + self, + old_session_id: str, + session_key: Optional[str] = None, + ): + """Prompt the agent to save memories/skills before context is lost. + + Synchronous worker — meant to be called via run_in_executor from + an async context so it doesn't block the event loop. + """ + # Skip cron sessions — they run headless with no meaningful user + # conversation to extract memories from. + if old_session_id and old_session_id.startswith("cron_"): + logger.debug("Skipping memory flush for cron session: %s", old_session_id) + return + + try: + history = self.session_store.load_transcript(old_session_id) + if not history or len(history) < 4: + return + + from run_agent import AIAgent + model, runtime_kwargs = self._resolve_session_agent_runtime( + session_key=session_key, + ) + if not runtime_kwargs.get("api_key"): + return + + tmp_agent = AIAgent( + **runtime_kwargs, + model=model, + max_iterations=8, + quiet_mode=True, + skip_memory=True, # Flush agent — no memory provider + enabled_toolsets=["memory", "skills"], + session_id=old_session_id, + ) + # Fully silence the flush agent — quiet_mode only suppresses init + # messages; tool call output still leaks to the terminal through + # _safe_print → _print_fn. Set a no-op to prevent that. + tmp_agent._print_fn = lambda *a, **kw: None + + # Build conversation history from transcript + msgs = [ + {"role": m.get("role"), "content": m.get("content")} + for m in history + if m.get("role") in ("user", "assistant") and m.get("content") + ] + + # Read live memory state from disk so the flush agent can see + # what's already saved and avoid overwriting newer entries. + _current_memory = "" + try: + from tools.memory_tool import get_memory_dir + _mem_dir = get_memory_dir() + for fname, label in [ + ("MEMORY.md", "MEMORY (your personal notes)"), + ("USER.md", "USER PROFILE (who the user is)"), + ]: + fpath = _mem_dir / fname + if fpath.exists(): + content = fpath.read_text(encoding="utf-8").strip() + if content: + _current_memory += f"\n\n## Current {label}:\n{content}" + except Exception: + pass # Non-fatal — flush still works, just without the guard + + # Give the agent a real turn to think about what to save + flush_prompt = ( + "[System: This session is about to be automatically reset due to " + "inactivity or a scheduled daily reset. The conversation context " + "will be cleared after this turn.\n\n" + "Review the conversation above and:\n" + "1. Save any important facts, preferences, or decisions to memory " + "(user profile or your notes) that would be useful in future sessions.\n" + "2. If you discovered a reusable workflow or solved a non-trivial " + "problem, consider saving it as a skill.\n" + "3. If nothing is worth saving, that's fine — just skip.\n\n" + ) + + if _current_memory: + flush_prompt += ( + "IMPORTANT — here is the current live state of memory. Other " + "sessions, cron jobs, or the user may have updated it since this " + "conversation ended. Do NOT overwrite or remove entries unless " + "the conversation above reveals something that genuinely " + "supersedes them. Only add new information that is not already " + "captured below." + f"{_current_memory}\n\n" + ) + + flush_prompt += ( + "Do NOT respond to the user. Just use the memory and skill_manage " + "tools if needed, then stop.]" + ) + + tmp_agent.run_conversation( + user_message=flush_prompt, + conversation_history=msgs, + ) + logger.info("Pre-reset memory flush completed for session %s", old_session_id) + except Exception as e: + logger.debug("Pre-reset memory flush failed for session %s: %s", old_session_id, e) + + async def _async_flush_memories( + self, + old_session_id: str, + session_key: Optional[str] = None, + ): + """Run the sync memory flush in a thread pool so it won't block the event loop.""" + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + self._flush_memories_for_session, + old_session_id, + session_key, + ) + + @property + def should_exit_cleanly(self) -> bool: + return self._exit_cleanly + + @property + def should_exit_with_failure(self) -> bool: + return self._exit_with_failure + + @property + def exit_reason(self) -> Optional[str]: + return self._exit_reason + + @property + def exit_code(self) -> Optional[int]: + return self._exit_code + + def _session_key_for_source(self, source: SessionSource) -> str: + """Resolve the current session key for a source, honoring gateway config when available.""" + if hasattr(self, "session_store") and self.session_store is not None: + try: + session_key = self.session_store._generate_session_key(source) + if isinstance(session_key, str) and session_key: + return session_key + except Exception: + pass + config = getattr(self, "config", None) + return build_session_key( + source, + group_sessions_per_user=getattr(config, "group_sessions_per_user", True), + thread_sessions_per_user=getattr(config, "thread_sessions_per_user", False), + ) + + def _resolve_session_agent_runtime( + self, + *, + source: Optional[SessionSource] = None, + session_key: Optional[str] = None, + user_config: Optional[dict] = None, + ) -> tuple[str, dict]: + """Resolve model/runtime for a session, honoring session-scoped /model overrides. + + If the session override already contains a complete provider bundle + (provider/api_key/base_url/api_mode), prefer it directly instead of + resolving fresh global runtime state first. + """ + resolved_session_key = session_key + if not resolved_session_key and source is not None: + try: + resolved_session_key = self._session_key_for_source(source) + except Exception: + resolved_session_key = None + + model = _resolve_gateway_model(user_config) + override = self._session_model_overrides.get(resolved_session_key) if resolved_session_key else None + if override: + override_model = override.get("model", model) + override_runtime = { + "provider": override.get("provider"), + "api_key": override.get("api_key"), + "base_url": override.get("base_url"), + "api_mode": override.get("api_mode"), + } + if override_runtime.get("api_key"): + logger.debug( + "Session model override (fast): session=%s config_model=%s -> override_model=%s provider=%s", + (resolved_session_key or "")[:30], model, override_model, + override_runtime.get("provider"), + ) + return override_model, override_runtime + # Override exists but has no api_key — fall through to env-based + # resolution and apply model/provider from the override on top. + logger.debug( + "Session model override (no api_key, fallback): session=%s config_model=%s override_model=%s", + (resolved_session_key or "")[:30], model, override_model, + ) + else: + logger.debug( + "No session model override: session=%s config_model=%s override_keys=%s", + (resolved_session_key or "")[:30], model, + list(self._session_model_overrides.keys())[:5] if self._session_model_overrides else "[]", + ) + + runtime_kwargs = _resolve_runtime_agent_kwargs() + if override and resolved_session_key: + model, runtime_kwargs = self._apply_session_model_override( + resolved_session_key, model, runtime_kwargs + ) + + # When the config has no model.default but a provider was resolved + # (e.g. user ran `hermes auth add openai-codex` without `hermes model`), + # fall back to the provider's first catalog model so the API call + # doesn't fail with "model must be a non-empty string". + if not model and runtime_kwargs.get("provider"): + try: + from hermes_cli.models import get_default_model_for_provider + model = get_default_model_for_provider(runtime_kwargs["provider"]) + if model: + logger.info( + "No model configured — defaulting to %s for provider %s", + model, runtime_kwargs["provider"], + ) + except Exception: + pass + + return model, runtime_kwargs + + def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict: + from agent.smart_model_routing import resolve_turn_route + from hermes_cli.models import resolve_fast_mode_overrides + + primary = { + "model": model, + "api_key": runtime_kwargs.get("api_key"), + "base_url": runtime_kwargs.get("base_url"), + "provider": runtime_kwargs.get("provider"), + "api_mode": runtime_kwargs.get("api_mode"), + "command": runtime_kwargs.get("command"), + "args": list(runtime_kwargs.get("args") or []), + "credential_pool": runtime_kwargs.get("credential_pool"), + } + route = resolve_turn_route(user_message, getattr(self, "_smart_model_routing", {}), primary) + + service_tier = getattr(self, "_service_tier", None) + if not service_tier: + route["request_overrides"] = None + return route + + try: + overrides = resolve_fast_mode_overrides(route.get("model")) + except Exception: + overrides = None + route["request_overrides"] = overrides + return route + + async def _handle_adapter_fatal_error(self, adapter: BasePlatformAdapter) -> None: + """React to an adapter failure after startup. + + If the error is retryable (e.g. network blip, DNS failure), queue the + platform for background reconnection instead of giving up permanently. + """ + logger.error( + "Fatal %s adapter error (%s): %s", + adapter.platform.value, + adapter.fatal_error_code or "unknown", + adapter.fatal_error_message or "unknown error", + ) + self._update_platform_runtime_status( + adapter.platform.value, + platform_state="retrying" if adapter.fatal_error_retryable else "fatal", + error_code=adapter.fatal_error_code, + error_message=adapter.fatal_error_message, + ) + + existing = self.adapters.get(adapter.platform) + if existing is adapter: + try: + await adapter.disconnect() + finally: + self.adapters.pop(adapter.platform, None) + self.delivery_router.adapters = self.adapters + + # Queue retryable failures for background reconnection + if adapter.fatal_error_retryable: + platform_config = self.config.platforms.get(adapter.platform) + if platform_config and adapter.platform not in self._failed_platforms: + self._failed_platforms[adapter.platform] = { + "config": platform_config, + "attempts": 0, + "next_retry": time.monotonic() + 30, + } + logger.info( + "%s queued for background reconnection", + adapter.platform.value, + ) + + if not self.adapters and not self._failed_platforms: + self._exit_reason = adapter.fatal_error_message or "All messaging adapters disconnected" + if adapter.fatal_error_retryable: + self._exit_with_failure = True + logger.error("No connected messaging platforms remain. Shutting down gateway for service restart.") + else: + logger.error("No connected messaging platforms remain. Shutting down gateway cleanly.") + await self.stop() + elif not self.adapters and self._failed_platforms: + # All platforms are down and queued for background reconnection. + # If the error is retryable, exit with failure so systemd Restart=on-failure + # can restart the process. Otherwise stay alive and keep retrying in background. + if adapter.fatal_error_retryable: + self._exit_reason = adapter.fatal_error_message or "All messaging platforms failed with retryable errors" + self._exit_with_failure = True + logger.error( + "All messaging platforms failed with retryable errors. " + "Shutting down gateway for service restart (systemd will retry)." + ) + await self.stop() + else: + logger.warning( + "No connected messaging platforms remain, but %d platform(s) queued for reconnection", + len(self._failed_platforms), + ) + + def _request_clean_exit(self, reason: str) -> None: + self._exit_cleanly = True + self._exit_reason = reason + self._shutdown_event.set() + + def _running_agent_count(self) -> int: + return len(self._running_agents) + + def _status_action_label(self) -> str: + return "restart" if self._restart_requested else "shutdown" + + def _status_action_gerund(self) -> str: + return "restarting" if self._restart_requested else "shutting down" + + def _queue_during_drain_enabled(self) -> bool: + return self._restart_requested and self._busy_input_mode == "queue" + + def _update_runtime_status(self, gateway_state: Optional[str] = None, exit_reason: Optional[str] = None) -> None: + try: + from gateway.status import write_runtime_status + write_runtime_status( + gateway_state=gateway_state, + exit_reason=exit_reason, + restart_requested=self._restart_requested, + active_agents=self._running_agent_count(), + ) + except Exception: + pass + + def _update_platform_runtime_status( + self, + platform: str, + *, + platform_state: Optional[str] = None, + error_code: Optional[str] = None, + error_message: Optional[str] = None, + ) -> None: + try: + from gateway.status import write_runtime_status + write_runtime_status( + platform=platform, + platform_state=platform_state, + error_code=error_code, + error_message=error_message, + ) + except Exception: + pass + + @staticmethod + def _load_prefill_messages() -> List[Dict[str, Any]]: + """Load ephemeral prefill messages from config or env var. + + Checks HERMES_PREFILL_MESSAGES_FILE env var first, then falls back to + the prefill_messages_file key in ~/.hermes/config.yaml. + Relative paths are resolved from ~/.hermes/. + """ + import json as _json + file_path = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") + if not file_path: + try: + import yaml as _y + cfg_path = _hermes_home / "config.yaml" + if cfg_path.exists(): + with open(cfg_path, encoding="utf-8") as _f: + cfg = _y.safe_load(_f) or {} + file_path = cfg.get("prefill_messages_file", "") + except Exception: + pass + if not file_path: + return [] + path = Path(file_path).expanduser() + if not path.is_absolute(): + path = _hermes_home / path + if not path.exists(): + logger.warning("Prefill messages file not found: %s", path) + return [] + try: + with open(path, "r", encoding="utf-8") as f: + data = _json.load(f) + if not isinstance(data, list): + logger.warning("Prefill messages file must contain a JSON array: %s", path) + return [] + return data + except Exception as e: + logger.warning("Failed to load prefill messages from %s: %s", path, e) + return [] + + @staticmethod + def _load_ephemeral_system_prompt() -> str: + """Load ephemeral system prompt from config or env var. + + Checks HERMES_EPHEMERAL_SYSTEM_PROMPT env var first, then falls back to + agent.system_prompt in ~/.hermes/config.yaml. + """ + prompt = os.getenv("HERMES_EPHEMERAL_SYSTEM_PROMPT", "") + if prompt: + return prompt + try: + import yaml as _y + cfg_path = _hermes_home / "config.yaml" + if cfg_path.exists(): + with open(cfg_path, encoding="utf-8") as _f: + cfg = _y.safe_load(_f) or {} + return (cfg.get("agent", {}).get("system_prompt", "") or "").strip() + except Exception: + pass + return "" + + @staticmethod + def _load_reasoning_config() -> dict | None: + """Load reasoning effort from config.yaml. + + Reads agent.reasoning_effort from config.yaml. Valid: "none", + "minimal", "low", "medium", "high", "xhigh". Returns None to use + default (medium). + """ + from hermes_constants import parse_reasoning_effort + effort = "" + try: + import yaml as _y + cfg_path = _hermes_home / "config.yaml" + if cfg_path.exists(): + with open(cfg_path, encoding="utf-8") as _f: + cfg = _y.safe_load(_f) or {} + effort = str(cfg.get("agent", {}).get("reasoning_effort", "") or "").strip() + except Exception: + pass + result = parse_reasoning_effort(effort) + if effort and effort.strip() and result is None: + logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort) + return result + + @staticmethod + def _load_service_tier() -> str | None: + """Load Priority Processing setting from config.yaml. + + Reads agent.service_tier from config.yaml. Accepted values mirror the CLI: + "fast"/"priority"/"on" => "priority", while "normal"/"off" disables it. + Returns None when unset or unsupported. + """ + raw = "" + try: + import yaml as _y + cfg_path = _hermes_home / "config.yaml" + if cfg_path.exists(): + with open(cfg_path, encoding="utf-8") as _f: + cfg = _y.safe_load(_f) or {} + raw = str(cfg.get("agent", {}).get("service_tier", "") or "").strip() + except Exception: + pass + + value = raw.lower() + if not value or value in {"normal", "default", "standard", "off", "none"}: + return None + if value in {"fast", "priority", "on"}: + return "priority" + logger.warning("Unknown service_tier '%s', ignoring", raw) + return None + + @staticmethod + def _load_show_reasoning() -> bool: + """Load show_reasoning toggle from config.yaml display section.""" + try: + import yaml as _y + cfg_path = _hermes_home / "config.yaml" + if cfg_path.exists(): + with open(cfg_path, encoding="utf-8") as _f: + cfg = _y.safe_load(_f) or {} + return bool(cfg.get("display", {}).get("show_reasoning", False)) + except Exception: + pass + return False + + @staticmethod + def _load_busy_input_mode() -> str: + """Load gateway drain-time busy-input behavior from config/env.""" + mode = os.getenv("HERMES_GATEWAY_BUSY_INPUT_MODE", "").strip().lower() + if not mode: + try: + import yaml as _y + cfg_path = _hermes_home / "config.yaml" + if cfg_path.exists(): + with open(cfg_path, encoding="utf-8") as _f: + cfg = _y.safe_load(_f) or {} + mode = str(cfg.get("display", {}).get("busy_input_mode", "") or "").strip().lower() + except Exception: + pass + return "queue" if mode == "queue" else "interrupt" + + @staticmethod + def _load_restart_drain_timeout() -> float: + """Load graceful gateway restart/stop drain timeout in seconds.""" + raw = os.getenv("HERMES_RESTART_DRAIN_TIMEOUT", "").strip() + if not raw: + try: + import yaml as _y + cfg_path = _hermes_home / "config.yaml" + if cfg_path.exists(): + with open(cfg_path, encoding="utf-8") as _f: + cfg = _y.safe_load(_f) or {} + raw = str(cfg.get("agent", {}).get("restart_drain_timeout", "") or "").strip() + except Exception: + pass + value = parse_restart_drain_timeout(raw) + if raw and value == DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT: + try: + float(raw) + except (TypeError, ValueError): + logger.warning( + "Invalid restart_drain_timeout '%s', using default %.0fs", + raw, + DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT, + ) + return value + + @staticmethod + def _load_background_notifications_mode() -> str: + """Load background process notification mode from config or env var. + + Modes: + - ``all`` — push running-output updates *and* the final message (default) + - ``result`` — only the final completion message (regardless of exit code) + - ``error`` — only the final message when exit code is non-zero + - ``off`` — no watcher messages at all + """ + mode = os.getenv("HERMES_BACKGROUND_NOTIFICATIONS", "") + if not mode: + try: + import yaml as _y + cfg_path = _hermes_home / "config.yaml" + if cfg_path.exists(): + with open(cfg_path, encoding="utf-8") as _f: + cfg = _y.safe_load(_f) or {} + raw = cfg.get("display", {}).get("background_process_notifications") + if raw is False: + mode = "off" + elif raw not in (None, ""): + mode = str(raw) + except Exception: + pass + mode = (mode or "all").strip().lower() + valid = {"all", "result", "error", "off"} + if mode not in valid: + logger.warning( + "Unknown background_process_notifications '%s', defaulting to 'all'", + mode, + ) + return "all" + return mode + + @staticmethod + def _load_provider_routing() -> dict: + """Load OpenRouter provider routing preferences from config.yaml.""" + try: + import yaml as _y + cfg_path = _hermes_home / "config.yaml" + if cfg_path.exists(): + with open(cfg_path, encoding="utf-8") as _f: + cfg = _y.safe_load(_f) or {} + return cfg.get("provider_routing", {}) or {} + except Exception: + pass + return {} + + @staticmethod + def _load_fallback_model() -> list | dict | None: + """Load fallback provider chain from config.yaml. + + Returns a list of provider dicts (``fallback_providers``), a single + dict (legacy ``fallback_model``), or None if not configured. + AIAgent.__init__ normalizes both formats into a chain. + """ + try: + import yaml as _y + cfg_path = _hermes_home / "config.yaml" + if cfg_path.exists(): + with open(cfg_path, encoding="utf-8") as _f: + cfg = _y.safe_load(_f) or {} + fb = cfg.get("fallback_providers") or cfg.get("fallback_model") or None + if fb: + return fb + except Exception: + pass + return None + + @staticmethod + def _load_smart_model_routing() -> dict: + """Load optional smart cheap-vs-strong model routing config.""" + try: + import yaml as _y + cfg_path = _hermes_home / "config.yaml" + if cfg_path.exists(): + with open(cfg_path, encoding="utf-8") as _f: + cfg = _y.safe_load(_f) or {} + return cfg.get("smart_model_routing", {}) or {} + except Exception: + pass + return {} + + def _snapshot_running_agents(self) -> Dict[str, Any]: + return { + session_key: agent + for session_key, agent in self._running_agents.items() + if agent is not _AGENT_PENDING_SENTINEL + } + + def _queue_or_replace_pending_event(self, session_key: str, event: MessageEvent) -> None: + adapter = self.adapters.get(event.source.platform) + if not adapter: + return + merge_pending_message_event(adapter._pending_messages, session_key, event) + + async def _handle_active_session_busy_message(self, event: MessageEvent, session_key: str) -> bool: + if not self._draining: + return False + + adapter = self.adapters.get(event.source.platform) + if not adapter: + return True + + thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None + if self._queue_during_drain_enabled(): + self._queue_or_replace_pending_event(session_key, event) + message = f"⏳ Gateway {self._status_action_gerund()} — queued for the next turn after it comes back." + else: + message = f"⏳ Gateway is {self._status_action_gerund()} and is not accepting another turn right now." + + await adapter._send_with_retry( + chat_id=event.source.chat_id, + content=message, + reply_to=event.message_id, + metadata=thread_meta, + ) + return True + + async def _drain_active_agents(self, timeout: float) -> tuple[Dict[str, Any], bool]: + snapshot = self._snapshot_running_agents() + last_active_count = self._running_agent_count() + last_status_at = 0.0 + + def _maybe_update_status(force: bool = False) -> None: + nonlocal last_active_count, last_status_at + now = asyncio.get_running_loop().time() + active_count = self._running_agent_count() + if force or active_count != last_active_count or (now - last_status_at) >= 1.0: + self._update_runtime_status("draining") + last_active_count = active_count + last_status_at = now + + if not self._running_agents: + _maybe_update_status(force=True) + return snapshot, False + + _maybe_update_status(force=True) + if timeout <= 0: + return snapshot, True + + deadline = asyncio.get_running_loop().time() + timeout + while self._running_agents and asyncio.get_running_loop().time() < deadline: + _maybe_update_status() + await asyncio.sleep(0.1) + timed_out = bool(self._running_agents) + _maybe_update_status(force=True) + return snapshot, timed_out + + def _interrupt_running_agents(self, reason: str) -> None: + for session_key, agent in list(self._running_agents.items()): + if agent is _AGENT_PENDING_SENTINEL: + continue + try: + agent.interrupt(reason) + logger.debug("Interrupted running agent for session %s during shutdown", session_key[:20]) + except Exception as e: + logger.debug("Failed interrupting agent during shutdown: %s", e) + + def _finalize_shutdown_agents(self, active_agents: Dict[str, Any]) -> None: + for agent in active_agents.values(): + try: + from hermes_cli.plugins import invoke_hook as _invoke_hook + _invoke_hook( + "on_session_finalize", + session_id=getattr(agent, "session_id", None), + platform="gateway", + ) + except Exception: + pass + try: + if hasattr(agent, "shutdown_memory_provider"): + agent.shutdown_memory_provider() + except Exception: + pass + # Close tool resources (terminal sandboxes, browser daemons, + # background processes, httpx clients) to prevent zombie + # process accumulation. + try: + if hasattr(agent, 'close'): + agent.close() + except Exception: + pass + + async def _launch_detached_restart_command(self) -> None: + import shutil + import subprocess + + hermes_cmd = _resolve_hermes_bin() + if not hermes_cmd: + logger.error("Could not locate hermes binary for detached /restart") + return + + current_pid = os.getpid() + cmd = " ".join(shlex.quote(part) for part in hermes_cmd) + shell_cmd = ( + f"while kill -0 {current_pid} 2>/dev/null; do sleep 0.2; done; " + f"{cmd} gateway restart" + ) + setsid_bin = shutil.which("setsid") + if setsid_bin: + subprocess.Popen( + [setsid_bin, "bash", "-lc", shell_cmd], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + else: + subprocess.Popen( + ["bash", "-lc", shell_cmd], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + + def request_restart(self, *, detached: bool = False, via_service: bool = False) -> bool: + if self._restart_task_started: + return False + self._restart_requested = True + self._restart_detached = detached + self._restart_via_service = via_service + self._restart_task_started = True + + async def _run_restart() -> None: + await asyncio.sleep(0.05) + await self.stop(restart=True, detached_restart=detached, service_restart=via_service) + + task = asyncio.create_task(_run_restart()) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + return True + + async def start(self) -> bool: + """ + Start the gateway and all configured platform adapters. + + Returns True if at least one adapter connected successfully. + """ + logger.info("Starting Hermes Gateway...") + logger.info("Session storage: %s", self.config.sessions_dir) + try: + from hermes_cli.profiles import get_active_profile_name + _profile = get_active_profile_name() + if _profile and _profile != "default": + logger.info("Active profile: %s", _profile) + except Exception: + pass + try: + from gateway.status import write_runtime_status + write_runtime_status(gateway_state="starting", exit_reason=None) + except Exception: + pass + + # Warn if no user allowlists are configured and open access is not opted in + _any_allowlist = any( + os.getenv(v) + for v in ("TELEGRAM_ALLOWED_USERS", "DISCORD_ALLOWED_USERS", + "WHATSAPP_ALLOWED_USERS", "SLACK_ALLOWED_USERS", + "SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS", + "EMAIL_ALLOWED_USERS", + "SMS_ALLOWED_USERS", "MATTERMOST_ALLOWED_USERS", + "MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS", + "FEISHU_ALLOWED_USERS", + "WECOM_ALLOWED_USERS", + "WECOM_CALLBACK_ALLOWED_USERS", + "WEIXIN_ALLOWED_USERS", + "BLUEBUBBLES_ALLOWED_USERS", + "QQ_ALLOWED_USERS", + "GATEWAY_ALLOWED_USERS") + ) + _allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any( + os.getenv(v, "").lower() in ("true", "1", "yes") + for v in ("TELEGRAM_ALLOW_ALL_USERS", "DISCORD_ALLOW_ALL_USERS", + "WHATSAPP_ALLOW_ALL_USERS", "SLACK_ALLOW_ALL_USERS", + "SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS", + "SMS_ALLOW_ALL_USERS", "MATTERMOST_ALLOW_ALL_USERS", + "MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS", + "FEISHU_ALLOW_ALL_USERS", + "WECOM_ALLOW_ALL_USERS", + "WECOM_CALLBACK_ALLOW_ALL_USERS", + "WEIXIN_ALLOW_ALL_USERS", + "BLUEBUBBLES_ALLOW_ALL_USERS", + "QQ_ALLOW_ALL_USERS") + ) + if not _any_allowlist and not _allow_all: + logger.warning( + "No user allowlists configured. All unauthorized users will be denied. " + "Set GATEWAY_ALLOW_ALL_USERS=true in ~/.hermes/.env to allow open access, " + "or configure platform allowlists (e.g., TELEGRAM_ALLOWED_USERS=your_id)." + ) + + # Discover and load event hooks + self.hooks.discover_and_load() + + # Recover background processes from checkpoint (crash recovery) + try: + from tools.process_registry import process_registry + recovered = process_registry.recover_from_checkpoint() + if recovered: + logger.info("Recovered %s background process(es) from previous run", recovered) + except Exception as e: + logger.warning("Process checkpoint recovery: %s", e) + + # Suspend sessions that were active when the gateway last exited. + # This prevents stuck sessions from being blindly resumed on restart, + # which can create an unrecoverable loop (#7536). Suspended sessions + # auto-reset on the next incoming message, giving the user a clean start. + # + # SKIP suspension after a clean (graceful) shutdown — the previous + # process already drained active agents, so sessions aren't stuck. + # This prevents unwanted auto-resets after `hermes update`, + # `hermes gateway restart`, or `/restart`. + _clean_marker = _hermes_home / ".clean_shutdown" + if _clean_marker.exists(): + logger.info("Previous gateway exited cleanly — skipping session suspension") + try: + _clean_marker.unlink() + except Exception: + pass + else: + try: + suspended = self.session_store.suspend_recently_active() + if suspended: + logger.info("Suspended %d in-flight session(s) from previous run", suspended) + except Exception as e: + logger.warning("Session suspension on startup failed: %s", e) + + connected_count = 0 + enabled_platform_count = 0 + startup_nonretryable_errors: list[str] = [] + startup_retryable_errors: list[str] = [] + + # Initialize and connect each configured platform + for platform, platform_config in self.config.platforms.items(): + if not platform_config.enabled: + continue + enabled_platform_count += 1 + + adapter = self._create_adapter(platform, platform_config) + if not adapter: + logger.warning("No adapter available for %s", platform.value) + continue + + # Set up message + fatal error handlers + adapter.set_message_handler(self._handle_message) + adapter.set_fatal_error_handler(self._handle_adapter_fatal_error) + adapter.set_session_store(self.session_store) + adapter.set_busy_session_handler(self._handle_active_session_busy_message) + + # Try to connect + logger.info("Connecting to %s...", platform.value) + self._update_platform_runtime_status( + platform.value, + platform_state="connecting", + error_code=None, + error_message=None, + ) + try: + success = await adapter.connect() + if success: + self.adapters[platform] = adapter + self._sync_voice_mode_state_to_adapter(adapter) + connected_count += 1 + self._update_platform_runtime_status( + platform.value, + platform_state="connected", + error_code=None, + error_message=None, + ) + logger.info("✓ %s connected", platform.value) + else: + logger.warning("✗ %s failed to connect", platform.value) + if adapter.has_fatal_error: + self._update_platform_runtime_status( + platform.value, + platform_state="retrying" if adapter.fatal_error_retryable else "fatal", + error_code=adapter.fatal_error_code, + error_message=adapter.fatal_error_message, + ) + target = ( + startup_retryable_errors + if adapter.fatal_error_retryable + else startup_nonretryable_errors + ) + target.append( + f"{platform.value}: {adapter.fatal_error_message}" + ) + # Queue for reconnection if the error is retryable + if adapter.fatal_error_retryable: + self._failed_platforms[platform] = { + "config": platform_config, + "attempts": 1, + "next_retry": time.monotonic() + 30, + } + else: + self._update_platform_runtime_status( + platform.value, + platform_state="retrying", + error_code=None, + error_message="failed to connect", + ) + startup_retryable_errors.append( + f"{platform.value}: failed to connect" + ) + # No fatal error info means likely a transient issue — queue for retry + self._failed_platforms[platform] = { + "config": platform_config, + "attempts": 1, + "next_retry": time.monotonic() + 30, + } + except Exception as e: + logger.error("✗ %s error: %s", platform.value, e) + self._update_platform_runtime_status( + platform.value, + platform_state="retrying", + error_code=None, + error_message=str(e), + ) + startup_retryable_errors.append(f"{platform.value}: {e}") + # Unexpected exceptions are typically transient — queue for retry + self._failed_platforms[platform] = { + "config": platform_config, + "attempts": 1, + "next_retry": time.monotonic() + 30, + } + + if connected_count == 0: + if startup_nonretryable_errors: + reason = "; ".join(startup_nonretryable_errors) + logger.error("Gateway hit a non-retryable startup conflict: %s", reason) + try: + from gateway.status import write_runtime_status + write_runtime_status(gateway_state="startup_failed", exit_reason=reason) + except Exception: + pass + self._request_clean_exit(reason) + return True + if enabled_platform_count > 0: + reason = "; ".join(startup_retryable_errors) or "all configured messaging platforms failed to connect" + logger.error("Gateway failed to connect any configured messaging platform: %s", reason) + try: + from gateway.status import write_runtime_status + write_runtime_status(gateway_state="startup_failed", exit_reason=reason) + except Exception: + pass + return False + logger.warning("No messaging platforms enabled.") + logger.info("Gateway will continue running for cron job execution.") + + # Update delivery router with adapters + self.delivery_router.adapters = self.adapters + + self._running = True + self._update_runtime_status("running") + + # Emit gateway:startup hook + hook_count = len(self.hooks.loaded_hooks) + if hook_count: + logger.info("%s hook(s) loaded", hook_count) + await self.hooks.emit("gateway:startup", { + "platforms": [p.value for p in self.adapters.keys()], + }) + + if connected_count > 0: + logger.info("Gateway running with %s platform(s)", connected_count) + + # Build initial channel directory for send_message name resolution + try: + from gateway.channel_directory import build_channel_directory + directory = build_channel_directory(self.adapters) + ch_count = sum(len(chs) for chs in directory.get("platforms", {}).values()) + logger.info("Channel directory built: %d target(s)", ch_count) + except Exception as e: + logger.warning("Channel directory build failed: %s", e) + + # Check if we're restarting after a /update command. If the update is + # still running, keep watching so we notify once it actually finishes. + notified = await self._send_update_notification() + if not notified and any( + path.exists() + for path in ( + _hermes_home / ".update_pending.json", + _hermes_home / ".update_pending.claimed.json", + ) + ): + self._schedule_update_notification_watch() + + # Notify the chat that initiated /restart that the gateway is back. + await self._send_restart_notification() + + # Drain any recovered process watchers (from crash recovery checkpoint) + try: + from tools.process_registry import process_registry + while process_registry.pending_watchers: + watcher = process_registry.pending_watchers.pop(0) + asyncio.create_task(self._run_process_watcher(watcher)) + logger.info("Resumed watcher for recovered process %s", watcher.get("session_id")) + except Exception as e: + logger.error("Recovered watcher setup error: %s", e) + + # Start background session expiry watcher for proactive memory flushing + asyncio.create_task(self._session_expiry_watcher()) + + # Start background reconnection watcher for platforms that failed at startup + if self._failed_platforms: + logger.info( + "Starting reconnection watcher for %d failed platform(s): %s", + len(self._failed_platforms), + ", ".join(p.value for p in self._failed_platforms), + ) + asyncio.create_task(self._platform_reconnect_watcher()) + + logger.info("Press Ctrl+C to stop") + + return True + + async def _session_expiry_watcher(self, interval: int = 300): + """Background task that proactively flushes memories for expired sessions. + + Runs every `interval` seconds (default 5 min). For each session that + has expired according to its reset policy, flushes memories in a thread + pool and marks the session so it won't be flushed again. + + This means memories are already saved by the time the user sends their + next message, so there's no blocking delay. + """ + await asyncio.sleep(60) # initial delay — let the gateway fully start + _flush_failures: dict[str, int] = {} # session_id -> consecutive failure count + _MAX_FLUSH_RETRIES = 3 + while self._running: + try: + self.session_store._ensure_loaded() + # Collect expired sessions first, then log a single summary. + _expired_entries = [] + for key, entry in list(self.session_store._entries.items()): + if entry.memory_flushed: + continue + if not self.session_store._is_session_expired(entry): + continue + _expired_entries.append((key, entry)) + + if _expired_entries: + # Extract platform names from session keys for a compact summary. + # Keys look like "agent:main:telegram:dm:12345" — platform is field [2]. + _platforms: dict[str, int] = {} + for _k, _e in _expired_entries: + _parts = _k.split(":") + _plat = _parts[2] if len(_parts) > 2 else "unknown" + _platforms[_plat] = _platforms.get(_plat, 0) + 1 + _plat_summary = ", ".join( + f"{p}:{c}" for p, c in sorted(_platforms.items()) + ) + logger.info( + "Session expiry: %d sessions to flush (%s)", + len(_expired_entries), _plat_summary, + ) + + for key, entry in _expired_entries: + try: + await self._async_flush_memories(entry.session_id, key) + # Shut down memory provider and close tool resources + # on the cached agent. Idle agents live in + # _agent_cache (not _running_agents), so look there. + _cached_agent = None + _cache_lock = getattr(self, "_agent_cache_lock", None) + if _cache_lock is not None: + with _cache_lock: + _cached = self._agent_cache.get(key) + _cached_agent = _cached[0] if isinstance(_cached, tuple) else _cached if _cached else None + # Fall back to _running_agents in case the agent is + # still mid-turn when the expiry fires. + if _cached_agent is None: + _cached_agent = self._running_agents.get(key) + if _cached_agent and _cached_agent is not _AGENT_PENDING_SENTINEL: + try: + if hasattr(_cached_agent, 'shutdown_memory_provider'): + _cached_agent.shutdown_memory_provider() + except Exception: + pass + try: + if hasattr(_cached_agent, 'close'): + _cached_agent.close() + except Exception: + pass + # Mark as flushed and persist to disk so the flag + # survives gateway restarts. + with self.session_store._lock: + entry.memory_flushed = True + self.session_store._save() + logger.debug( + "Memory flush completed for session %s", + entry.session_id, + ) + _flush_failures.pop(entry.session_id, None) + except Exception as e: + failures = _flush_failures.get(entry.session_id, 0) + 1 + _flush_failures[entry.session_id] = failures + if failures >= _MAX_FLUSH_RETRIES: + logger.warning( + "Memory flush gave up after %d attempts for %s: %s. " + "Marking as flushed to prevent infinite retry loop.", + failures, entry.session_id, e, + ) + with self.session_store._lock: + entry.memory_flushed = True + self.session_store._save() + _flush_failures.pop(entry.session_id, None) + else: + logger.debug( + "Memory flush failed (%d/%d) for %s: %s", + failures, _MAX_FLUSH_RETRIES, entry.session_id, e, + ) + + if _expired_entries: + _flushed = sum( + 1 for _, e in _expired_entries if e.memory_flushed + ) + _failed = len(_expired_entries) - _flushed + if _failed: + logger.info( + "Session expiry done: %d flushed, %d pending retry", + _flushed, _failed, + ) + else: + logger.info( + "Session expiry done: %d flushed", _flushed, + ) + except Exception as e: + logger.debug("Session expiry watcher error: %s", e) + # Sleep in small increments so we can stop quickly + for _ in range(interval): + if not self._running: + break + await asyncio.sleep(1) + + async def _platform_reconnect_watcher(self) -> None: + """Background task that periodically retries connecting failed platforms. + + Uses exponential backoff: 30s → 60s → 120s → 240s → 300s (cap). + Stops retrying a platform after 20 failed attempts or if the error + is non-retryable (e.g. bad auth token). + """ + _MAX_ATTEMPTS = 20 + _BACKOFF_CAP = 300 # 5 minutes max between retries + + await asyncio.sleep(10) # initial delay — let startup finish + while self._running: + if not self._failed_platforms: + # Nothing to reconnect — sleep and check again + for _ in range(30): + if not self._running: + return + await asyncio.sleep(1) + continue + + now = time.monotonic() + for platform in list(self._failed_platforms.keys()): + if not self._running: + return + info = self._failed_platforms[platform] + if now < info["next_retry"]: + continue # not time yet + + if info["attempts"] >= _MAX_ATTEMPTS: + logger.warning( + "Giving up reconnecting %s after %d attempts", + platform.value, info["attempts"], + ) + del self._failed_platforms[platform] + continue + + platform_config = info["config"] + attempt = info["attempts"] + 1 + logger.info( + "Reconnecting %s (attempt %d/%d)...", + platform.value, attempt, _MAX_ATTEMPTS, + ) + + try: + adapter = self._create_adapter(platform, platform_config) + if not adapter: + logger.warning( + "Reconnect %s: adapter creation returned None, removing from retry queue", + platform.value, + ) + del self._failed_platforms[platform] + continue + + adapter.set_message_handler(self._handle_message) + adapter.set_fatal_error_handler(self._handle_adapter_fatal_error) + adapter.set_session_store(self.session_store) + adapter.set_busy_session_handler(self._handle_active_session_busy_message) + + success = await adapter.connect() + if success: + self.adapters[platform] = adapter + self._sync_voice_mode_state_to_adapter(adapter) + self.delivery_router.adapters = self.adapters + del self._failed_platforms[platform] + self._update_platform_runtime_status( + platform.value, + platform_state="connected", + error_code=None, + error_message=None, + ) + logger.info("✓ %s reconnected successfully", platform.value) + + # Rebuild channel directory with the new adapter + try: + from gateway.channel_directory import build_channel_directory + build_channel_directory(self.adapters) + except Exception: + pass + else: + # Check if the failure is non-retryable + if adapter.has_fatal_error and not adapter.fatal_error_retryable: + self._update_platform_runtime_status( + platform.value, + platform_state="fatal", + error_code=adapter.fatal_error_code, + error_message=adapter.fatal_error_message, + ) + logger.warning( + "Reconnect %s: non-retryable error (%s), removing from retry queue", + platform.value, adapter.fatal_error_message, + ) + del self._failed_platforms[platform] + else: + self._update_platform_runtime_status( + platform.value, + platform_state="retrying", + error_code=adapter.fatal_error_code, + error_message=adapter.fatal_error_message or "failed to reconnect", + ) + backoff = min(30 * (2 ** (attempt - 1)), _BACKOFF_CAP) + info["attempts"] = attempt + info["next_retry"] = time.monotonic() + backoff + logger.info( + "Reconnect %s failed, next retry in %ds", + platform.value, backoff, + ) + except Exception as e: + self._update_platform_runtime_status( + platform.value, + platform_state="retrying", + error_code=None, + error_message=str(e), + ) + backoff = min(30 * (2 ** (attempt - 1)), _BACKOFF_CAP) + info["attempts"] = attempt + info["next_retry"] = time.monotonic() + backoff + logger.warning( + "Reconnect %s error: %s, next retry in %ds", + platform.value, e, backoff, + ) + + # Check every 10 seconds for platforms that need reconnection + for _ in range(10): + if not self._running: + return + await asyncio.sleep(1) + + async def stop( + self, + *, + restart: bool = False, + detached_restart: bool = False, + service_restart: bool = False, + ) -> None: + """Stop the gateway and disconnect all adapters.""" + if restart: + self._restart_requested = True + self._restart_detached = detached_restart + self._restart_via_service = service_restart + if self._stop_task is not None: + await self._stop_task + return + + async def _stop_impl() -> None: + logger.info( + "Stopping gateway%s...", + " for restart" if self._restart_requested else "", + ) + self._running = False + self._draining = True + + timeout = self._restart_drain_timeout + active_agents, timed_out = await self._drain_active_agents(timeout) + if timed_out: + logger.warning( + "Gateway drain timed out after %.1fs with %d active agent(s); interrupting remaining work.", + timeout, + self._running_agent_count(), + ) + self._interrupt_running_agents( + "Gateway restarting" if self._restart_requested else "Gateway shutting down" + ) + interrupt_deadline = asyncio.get_running_loop().time() + 5.0 + while self._running_agents and asyncio.get_running_loop().time() < interrupt_deadline: + self._update_runtime_status("draining") + await asyncio.sleep(0.1) + + if self._restart_requested and self._restart_detached: + try: + await self._launch_detached_restart_command() + except Exception as e: + logger.error("Failed to launch detached gateway restart: %s", e) + + self._finalize_shutdown_agents(active_agents) + + for platform, adapter in list(self.adapters.items()): + try: + await adapter.cancel_background_tasks() + except Exception as e: + logger.debug("✗ %s background-task cancel error: %s", platform.value, e) + try: + await adapter.disconnect() + logger.info("✓ %s disconnected", platform.value) + except Exception as e: + logger.error("✗ %s disconnect error: %s", platform.value, e) + + for _task in list(self._background_tasks): + if _task is self._stop_task: + continue + _task.cancel() + self._background_tasks.clear() + + self.adapters.clear() + self._running_agents.clear() + self._pending_messages.clear() + self._pending_approvals.clear() + self._shutdown_event.set() + + # Global cleanup: kill any remaining tool subprocesses not tied + # to a specific agent (catch-all for zombie prevention). + try: + from tools.process_registry import process_registry + process_registry.kill_all() + except Exception: + pass + try: + from tools.terminal_tool import cleanup_all_environments + cleanup_all_environments() + except Exception: + pass + try: + from tools.browser_tool import cleanup_all_browsers + cleanup_all_browsers() + except Exception: + pass + + from gateway.status import remove_pid_file + remove_pid_file() + + # Write a clean-shutdown marker so the next startup knows this + # wasn't a crash. suspend_recently_active() only needs to run + # after unexpected exits — graceful shutdowns already drain + # active agents, so there's no stuck-session risk. + try: + (_hermes_home / ".clean_shutdown").touch() + except Exception: + pass + + if self._restart_requested and self._restart_via_service: + self._exit_code = GATEWAY_SERVICE_RESTART_EXIT_CODE + self._exit_reason = self._exit_reason or "Gateway restart requested" + + self._draining = False + self._update_runtime_status("stopped", self._exit_reason) + logger.info("Gateway stopped") + + self._stop_task = asyncio.create_task(_stop_impl()) + await self._stop_task + + async def wait_for_shutdown(self) -> None: + """Wait for shutdown signal.""" + await self._shutdown_event.wait() + + def _create_adapter( + self, + platform: Platform, + config: Any + ) -> Optional[BasePlatformAdapter]: + """Create the appropriate adapter for a platform.""" + if hasattr(config, "extra") and isinstance(config.extra, dict): + config.extra.setdefault( + "group_sessions_per_user", + self.config.group_sessions_per_user, + ) + config.extra.setdefault( + "thread_sessions_per_user", + getattr(self.config, "thread_sessions_per_user", False), + ) + + if platform == Platform.TELEGRAM: + from gateway.platforms.telegram import TelegramAdapter, check_telegram_requirements + if not check_telegram_requirements(): + logger.warning("Telegram: python-telegram-bot not installed") + return None + return TelegramAdapter(config) + + elif platform == Platform.DISCORD: + from gateway.platforms.discord import DiscordAdapter, check_discord_requirements + if not check_discord_requirements(): + logger.warning("Discord: discord.py not installed") + return None + return DiscordAdapter(config) + + elif platform == Platform.WHATSAPP: + from gateway.platforms.whatsapp import WhatsAppAdapter, check_whatsapp_requirements + if not check_whatsapp_requirements(): + logger.warning("WhatsApp: Node.js not installed or bridge not configured") + return None + return WhatsAppAdapter(config) + + elif platform == Platform.SLACK: + from gateway.platforms.slack import SlackAdapter, check_slack_requirements + if not check_slack_requirements(): + logger.warning("Slack: slack-bolt not installed. Run: pip install 'hermes-agent[slack]'") + return None + return SlackAdapter(config) + + elif platform == Platform.SIGNAL: + from gateway.platforms.signal import SignalAdapter, check_signal_requirements + if not check_signal_requirements(): + logger.warning("Signal: SIGNAL_HTTP_URL or SIGNAL_ACCOUNT not configured") + return None + return SignalAdapter(config) + + elif platform == Platform.HOMEASSISTANT: + from gateway.platforms.homeassistant import HomeAssistantAdapter, check_ha_requirements + if not check_ha_requirements(): + logger.warning("HomeAssistant: aiohttp not installed or HASS_TOKEN not set") + return None + return HomeAssistantAdapter(config) + + elif platform == Platform.EMAIL: + from gateway.platforms.email import EmailAdapter, check_email_requirements + if not check_email_requirements(): + logger.warning("Email: EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_IMAP_HOST, or EMAIL_SMTP_HOST not set") + return None + return EmailAdapter(config) + + elif platform == Platform.SMS: + from gateway.platforms.sms import SmsAdapter, check_sms_requirements + if not check_sms_requirements(): + logger.warning("SMS: aiohttp not installed or TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN not set") + return None + return SmsAdapter(config) + + elif platform == Platform.DINGTALK: + from gateway.platforms.dingtalk import DingTalkAdapter, check_dingtalk_requirements + if not check_dingtalk_requirements(): + logger.warning("DingTalk: dingtalk-stream not installed or DINGTALK_CLIENT_ID/SECRET not set") + return None + return DingTalkAdapter(config) + + elif platform == Platform.FEISHU: + from gateway.platforms.feishu import FeishuAdapter, check_feishu_requirements + if not check_feishu_requirements(): + logger.warning("Feishu: lark-oapi not installed or FEISHU_APP_ID/SECRET not set") + return None + return FeishuAdapter(config) + + elif platform == Platform.WECOM_CALLBACK: + from gateway.platforms.wecom_callback import ( + WecomCallbackAdapter, + check_wecom_callback_requirements, + ) + if not check_wecom_callback_requirements(): + logger.warning("WeComCallback: aiohttp/httpx not installed") + return None + return WecomCallbackAdapter(config) + + elif platform == Platform.WECOM: + from gateway.platforms.wecom import WeComAdapter, check_wecom_requirements + if not check_wecom_requirements(): + logger.warning("WeCom: aiohttp not installed or WECOM_BOT_ID/SECRET not set") + return None + return WeComAdapter(config) + + elif platform == Platform.WEIXIN: + from gateway.platforms.weixin import WeixinAdapter, check_weixin_requirements + if not check_weixin_requirements(): + logger.warning("Weixin: aiohttp/cryptography not installed") + return None + return WeixinAdapter(config) + + elif platform == Platform.MATTERMOST: + from gateway.platforms.mattermost import MattermostAdapter, check_mattermost_requirements + if not check_mattermost_requirements(): + logger.warning("Mattermost: MATTERMOST_TOKEN or MATTERMOST_URL not set, or aiohttp missing") + return None + return MattermostAdapter(config) + + elif platform == Platform.MATRIX: + from gateway.platforms.matrix import MatrixAdapter, check_matrix_requirements + if not check_matrix_requirements(): + logger.warning("Matrix: mautrix not installed or credentials not set. Run: pip install 'mautrix[encryption]'") + return None + return MatrixAdapter(config) + + elif platform == Platform.API_SERVER: + from gateway.platforms.api_server import APIServerAdapter, check_api_server_requirements + if not check_api_server_requirements(): + logger.warning("API Server: aiohttp not installed") + return None + return APIServerAdapter(config) + + elif platform == Platform.WEBHOOK: + from gateway.platforms.webhook import WebhookAdapter, check_webhook_requirements + if not check_webhook_requirements(): + logger.warning("Webhook: aiohttp not installed") + return None + adapter = WebhookAdapter(config) + adapter.gateway_runner = self # For cross-platform delivery + return adapter + + elif platform == Platform.BLUEBUBBLES: + from gateway.platforms.bluebubbles import BlueBubblesAdapter, check_bluebubbles_requirements + if not check_bluebubbles_requirements(): + logger.warning("BlueBubbles: aiohttp/httpx missing or BLUEBUBBLES_SERVER_URL/BLUEBUBBLES_PASSWORD not configured") + return None + return BlueBubblesAdapter(config) + + elif platform == Platform.QQBOT: + from gateway.platforms.qqbot import QQAdapter, check_qq_requirements + if not check_qq_requirements(): + logger.warning("QQBot: aiohttp/httpx missing or QQ_APP_ID/QQ_CLIENT_SECRET not configured") + return None + return QQAdapter(config) + + return None + + def _is_user_authorized(self, source: SessionSource) -> bool: + """ + Check if a user is authorized to use the bot. + + Checks in order: + 1. Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true) + 2. Environment variable allowlists (TELEGRAM_ALLOWED_USERS, etc.) + 3. DM pairing approved list + 4. Global allow-all (GATEWAY_ALLOW_ALL_USERS=true) + 5. Default: deny + """ + # Home Assistant events are system-generated (state changes), not + # user-initiated messages. The HASS_TOKEN already authenticates the + # connection, so HA events are always authorized. + # Webhook events are authenticated via HMAC signature validation in + # the adapter itself — no user allowlist applies. + if source.platform in (Platform.HOMEASSISTANT, Platform.WEBHOOK): + return True + + user_id = source.user_id + if not user_id: + return False + + platform_env_map = { + Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS", + Platform.DISCORD: "DISCORD_ALLOWED_USERS", + Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS", + Platform.SLACK: "SLACK_ALLOWED_USERS", + Platform.SIGNAL: "SIGNAL_ALLOWED_USERS", + Platform.EMAIL: "EMAIL_ALLOWED_USERS", + Platform.SMS: "SMS_ALLOWED_USERS", + Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS", + Platform.MATRIX: "MATRIX_ALLOWED_USERS", + Platform.DINGTALK: "DINGTALK_ALLOWED_USERS", + Platform.FEISHU: "FEISHU_ALLOWED_USERS", + Platform.WECOM: "WECOM_ALLOWED_USERS", + Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS", + Platform.WEIXIN: "WEIXIN_ALLOWED_USERS", + Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS", + Platform.QQBOT: "QQ_ALLOWED_USERS", + } + platform_allow_all_map = { + Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS", + Platform.DISCORD: "DISCORD_ALLOW_ALL_USERS", + Platform.WHATSAPP: "WHATSAPP_ALLOW_ALL_USERS", + Platform.SLACK: "SLACK_ALLOW_ALL_USERS", + Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS", + Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS", + Platform.SMS: "SMS_ALLOW_ALL_USERS", + Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS", + Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS", + Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS", + Platform.FEISHU: "FEISHU_ALLOW_ALL_USERS", + Platform.WECOM: "WECOM_ALLOW_ALL_USERS", + Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOW_ALL_USERS", + Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS", + Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS", + Platform.QQBOT: "QQ_ALLOW_ALL_USERS", + } + + # Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true) + platform_allow_all_var = platform_allow_all_map.get(source.platform, "") + if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in ("true", "1", "yes"): + return True + + # Check pairing store (always checked, regardless of allowlists) + platform_name = source.platform.value if source.platform else "" + if self.pairing_store.is_approved(platform_name, user_id): + return True + + # Check platform-specific and global allowlists + platform_allowlist = os.getenv(platform_env_map.get(source.platform, ""), "").strip() + global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip() + + if not platform_allowlist and not global_allowlist: + # No allowlists configured -- check global allow-all flag + return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") + + # Check if user is in any allowlist + allowed_ids = set() + if platform_allowlist: + allowed_ids.update(uid.strip() for uid in platform_allowlist.split(",") if uid.strip()) + if global_allowlist: + allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip()) + + # "*" in any allowlist means allow everyone (consistent with + # SIGNAL_GROUP_ALLOWED_USERS precedent) + if "*" in allowed_ids: + return True + + check_ids = {user_id} + if "@" in user_id: + check_ids.add(user_id.split("@")[0]) + + # WhatsApp: resolve phone↔LID aliases from bridge session mapping files + if source.platform == Platform.WHATSAPP: + normalized_allowed_ids = set() + for allowed_id in allowed_ids: + normalized_allowed_ids.update(_expand_whatsapp_auth_aliases(allowed_id)) + if normalized_allowed_ids: + allowed_ids = normalized_allowed_ids + + check_ids.update(_expand_whatsapp_auth_aliases(user_id)) + normalized_user_id = _normalize_whatsapp_identifier(user_id) + if normalized_user_id: + check_ids.add(normalized_user_id) + + return bool(check_ids & allowed_ids) + + def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str: + """Return how unauthorized DMs should be handled for a platform.""" + config = getattr(self, "config", None) + if config and hasattr(config, "get_unauthorized_dm_behavior"): + return config.get_unauthorized_dm_behavior(platform) + return "pair" + + async def _handle_message(self, event: MessageEvent) -> Optional[str]: + """ + Handle an incoming message from any platform. + + This is the core message processing pipeline: + 1. Check user authorization + 2. Check for commands (/new, /reset, etc.) + 3. Check for running agent and interrupt if needed + 4. Get or create session + 5. Build context for agent + 6. Run agent conversation + 7. Return response + """ + source = event.source + + # Internal events (e.g. background-process completion notifications) + # are system-generated and must skip user authorization. + if getattr(event, "internal", False): + pass + elif source.user_id is None: + # Messages with no user identity (Telegram service messages, + # channel forwards, anonymous admin actions) cannot be + # authorized — drop silently instead of triggering the pairing + # flow with a None user_id. + logger.debug("Ignoring message with no user_id from %s", source.platform.value) + return None + elif not self._is_user_authorized(source): + logger.warning("Unauthorized user: %s (%s) on %s", source.user_id, source.user_name, source.platform.value) + # In DMs: offer pairing code. In groups: silently ignore. + if source.chat_type == "dm" and self._get_unauthorized_dm_behavior(source.platform) == "pair": + platform_name = source.platform.value if source.platform else "unknown" + # Rate-limit ALL pairing responses (code or rejection) to + # prevent spamming the user with repeated messages when + # multiple DMs arrive in quick succession. + if self.pairing_store._is_rate_limited(platform_name, source.user_id): + return None + code = self.pairing_store.generate_code( + platform_name, source.user_id, source.user_name or "" + ) + if code: + adapter = self.adapters.get(source.platform) + if adapter: + await adapter.send( + source.chat_id, + f"Hi~ I don't recognize you yet!\n\n" + f"Here's your pairing code: `{code}`\n\n" + f"Ask the bot owner to run:\n" + f"`hermes pairing approve {platform_name} {code}`" + ) + else: + adapter = self.adapters.get(source.platform) + if adapter: + await adapter.send( + source.chat_id, + "Too many pairing requests right now~ " + "Please try again later!" + ) + # Record rate limit so subsequent messages are silently ignored + self.pairing_store._record_rate_limit(platform_name, source.user_id) + return None + + # Intercept messages that are responses to a pending /update prompt. + # The update process (detached) wrote .update_prompt.json; the watcher + # forwarded it to the user; now the user's reply goes back via + # .update_response so the update process can continue. + _quick_key = self._session_key_for_source(source) + _update_prompts = getattr(self, "_update_prompt_pending", {}) + if _update_prompts.get(_quick_key): + raw = (event.text or "").strip() + # Accept /approve and /deny as shorthand for yes/no + cmd = event.get_command() + if cmd in ("approve", "yes"): + response_text = "y" + elif cmd in ("deny", "no"): + response_text = "n" + else: + response_text = raw + if response_text: + response_path = _hermes_home / ".update_response" + try: + tmp = response_path.with_suffix(".tmp") + tmp.write_text(response_text) + tmp.replace(response_path) + except OSError as e: + logger.warning("Failed to write update response: %s", e) + return f"✗ Failed to send response to update process: {e}" + _update_prompts.pop(_quick_key, None) + label = response_text if len(response_text) <= 20 else response_text[:20] + "…" + return f"✓ Sent `{label}` to the update process." + + # PRIORITY handling when an agent is already running for this session. + # Default behavior is to interrupt immediately so user text/stop messages + # are handled with minimal latency. + # + # Special case: Telegram/photo bursts often arrive as multiple near- + # simultaneous updates. Do NOT interrupt for photo-only follow-ups here; + # let the adapter-level batching/queueing logic absorb them. + + # Staleness eviction: detect leaked locks from hung/crashed handlers. + # With inactivity-based timeout, active tasks can run for hours, so + # wall-clock age alone isn't sufficient. Evict only when the agent + # has been *idle* beyond the inactivity threshold (or when the agent + # object has no activity tracker and wall-clock age is extreme). + _raw_stale_timeout = float(os.getenv("HERMES_AGENT_TIMEOUT", 1800)) + _stale_ts = self._running_agents_ts.get(_quick_key, 0) + if _quick_key in self._running_agents and _stale_ts: + _stale_age = time.time() - _stale_ts + _stale_agent = self._running_agents.get(_quick_key) + # Never evict the pending sentinel — it was just placed moments + # ago during the async setup phase before the real agent is + # created. Sentinels have no get_activity_summary(), so the + # idle check below would always evaluate to inf >= timeout and + # immediately evict them, racing with the setup path. + _stale_idle = float("inf") # assume idle if we can't check + _stale_detail = "" + if _stale_agent and hasattr(_stale_agent, "get_activity_summary"): + try: + _sa = _stale_agent.get_activity_summary() + _stale_idle = _sa.get("seconds_since_activity", float("inf")) + _stale_detail = ( + f" | last_activity={_sa.get('last_activity_desc', 'unknown')} " + f"({_stale_idle:.0f}s ago) " + f"| iteration={_sa.get('api_call_count', 0)}/{_sa.get('max_iterations', 0)}" + ) + except Exception: + pass + # Evict if: agent is idle beyond timeout, OR wall-clock age is + # extreme (10x timeout or 2h, whichever is larger — catches + # cases where the agent object was garbage-collected). + _wall_ttl = max(_raw_stale_timeout * 10, 7200) if _raw_stale_timeout > 0 else float("inf") + _should_evict = ( + _stale_agent is not _AGENT_PENDING_SENTINEL + and ( + (_raw_stale_timeout > 0 and _stale_idle >= _raw_stale_timeout) + or _stale_age > _wall_ttl + ) + ) + if _should_evict: + logger.warning( + "Evicting stale _running_agents entry for %s " + "(age: %.0fs, idle: %.0fs, timeout: %.0fs)%s", + _quick_key[:30], _stale_age, _stale_idle, + _raw_stale_timeout, _stale_detail, + ) + del self._running_agents[_quick_key] + self._running_agents_ts.pop(_quick_key, None) + + if _quick_key in self._running_agents: + if event.get_command() == "status": + return await self._handle_status_command(event) + + # Resolve the command once for all early-intercept checks below. + from hermes_cli.commands import resolve_command as _resolve_cmd_inner + _evt_cmd = event.get_command() + _cmd_def_inner = _resolve_cmd_inner(_evt_cmd) if _evt_cmd else None + + if _cmd_def_inner and _cmd_def_inner.name == "restart": + return await self._handle_restart_command(event) + + # /stop must hard-kill the session when an agent is running. + # A soft interrupt (agent.interrupt()) doesn't help when the agent + # is truly hung — the executor thread is blocked and never checks + # _interrupt_requested. Force-clean _running_agents so the session + # is unlocked and subsequent messages are processed normally. + if _cmd_def_inner and _cmd_def_inner.name == "stop": + running_agent = self._running_agents.get(_quick_key) + if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: + running_agent.interrupt("Stop requested") + # Force-clean: remove the session lock regardless of agent state + adapter = self.adapters.get(source.platform) + if adapter and hasattr(adapter, 'get_pending_message'): + adapter.get_pending_message(_quick_key) # consume and discard + self._pending_messages.pop(_quick_key, None) + if _quick_key in self._running_agents: + del self._running_agents[_quick_key] + logger.info("STOP for session %s — agent interrupted, session lock released", _quick_key[:20]) + return "⚡ Stopped. You can continue this session." + + # /reset and /new must bypass the running-agent guard so they + # actually dispatch as commands instead of being queued as user + # text (which would be fed back to the agent with the same + # broken history — #2170). Interrupt the agent first, then + # clear the adapter's pending queue so the stale "/reset" text + # doesn't get re-processed as a user message after the + # interrupt completes. + if _cmd_def_inner and _cmd_def_inner.name == "new": + running_agent = self._running_agents.get(_quick_key) + if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: + running_agent.interrupt("Session reset requested") + # Clear any pending messages so the old text doesn't replay + adapter = self.adapters.get(source.platform) + if adapter and hasattr(adapter, 'get_pending_message'): + adapter.get_pending_message(_quick_key) # consume and discard + self._pending_messages.pop(_quick_key, None) + # Clean up the running agent entry so the reset handler + # doesn't think an agent is still active. + if _quick_key in self._running_agents: + del self._running_agents[_quick_key] + return await self._handle_reset_command(event) + + # /queue <prompt> — queue without interrupting + if event.get_command() in ("queue", "q"): + queued_text = event.get_command_args().strip() + if not queued_text: + return "Usage: /queue <prompt>" + adapter = self.adapters.get(source.platform) + if adapter: + from gateway.platforms.base import MessageEvent as _ME, MessageType as _MT + queued_event = _ME( + text=queued_text, + message_type=_MT.TEXT, + source=event.source, + message_id=event.message_id, + ) + adapter._pending_messages[_quick_key] = queued_event + return "Queued for the next turn." + + # /model must not be used while the agent is running. + if _cmd_def_inner and _cmd_def_inner.name == "model": + return "Agent is running — wait or /stop first, then switch models." + + # /approve and /deny must bypass the running-agent interrupt path. + # The agent thread is blocked on a threading.Event inside + # tools/approval.py — sending an interrupt won't unblock it. + # Route directly to the approval handler so the event is signalled. + if _cmd_def_inner and _cmd_def_inner.name in ("approve", "deny"): + if _cmd_def_inner.name == "approve": + return await self._handle_approve_command(event) + return await self._handle_deny_command(event) + + # /background must bypass the running-agent guard — it starts a + # parallel task and must never interrupt the active conversation. + if _cmd_def_inner and _cmd_def_inner.name == "background": + return await self._handle_background_command(event) + + if event.message_type == MessageType.PHOTO: + logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key[:20]) + adapter = self.adapters.get(source.platform) + if adapter: + merge_pending_message_event(adapter._pending_messages, _quick_key, event) + return None + + running_agent = self._running_agents.get(_quick_key) + if running_agent is _AGENT_PENDING_SENTINEL: + # Agent is being set up but not ready yet. + if event.get_command() == "stop": + # Force-clean the sentinel so the session is unlocked. + if _quick_key in self._running_agents: + del self._running_agents[_quick_key] + logger.info("HARD STOP (pending) for session %s — sentinel cleared", _quick_key[:20]) + return "⚡ Force-stopped. The agent was still starting — session unlocked." + # Queue the message so it will be picked up after the + # agent starts. + adapter = self.adapters.get(source.platform) + if adapter: + adapter._pending_messages[_quick_key] = event + return None + if self._draining: + if self._queue_during_drain_enabled(): + self._queue_or_replace_pending_event(_quick_key, event) + return ( + f"⏳ Gateway {self._status_action_gerund()} — queued for the next turn after it comes back." + if self._queue_during_drain_enabled() + else f"⏳ Gateway is {self._status_action_gerund()} and is not accepting another turn right now." + ) + logger.debug("PRIORITY interrupt for session %s", _quick_key[:20]) + running_agent.interrupt(event.text) + if _quick_key in self._pending_messages: + self._pending_messages[_quick_key] += "\n" + event.text + else: + self._pending_messages[_quick_key] = event.text + return None + + # Check for commands + command = event.get_command() + + # Emit command:* hook for any recognized slash command. + # GATEWAY_KNOWN_COMMANDS is derived from the central COMMAND_REGISTRY + # in hermes_cli/commands.py — no hardcoded set to maintain here. + from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, resolve_command as _resolve_cmd + if command and command in GATEWAY_KNOWN_COMMANDS: + await self.hooks.emit(f"command:{command}", { + "platform": source.platform.value if source.platform else "", + "user_id": source.user_id, + "command": command, + "args": event.get_command_args().strip(), + }) + + # Resolve aliases to canonical name so dispatch only checks canonicals. + _cmd_def = _resolve_cmd(command) if command else None + canonical = _cmd_def.name if _cmd_def else command + + if canonical == "new": + return await self._handle_reset_command(event) + + if canonical == "help": + return await self._handle_help_command(event) + + if canonical == "commands": + return await self._handle_commands_command(event) + + if canonical == "profile": + return await self._handle_profile_command(event) + + if canonical == "status": + return await self._handle_status_command(event) + + if canonical == "restart": + return await self._handle_restart_command(event) + + if canonical == "stop": + return await self._handle_stop_command(event) + + if canonical == "reasoning": + return await self._handle_reasoning_command(event) + + if canonical == "fast": + return await self._handle_fast_command(event) + + if canonical == "verbose": + return await self._handle_verbose_command(event) + + if canonical == "yolo": + return await self._handle_yolo_command(event) + + if canonical == "model": + return await self._handle_model_command(event) + + if canonical == "provider": + return await self._handle_provider_command(event) + + if canonical == "personality": + return await self._handle_personality_command(event) + + if canonical == "plan": + try: + from agent.skill_commands import build_plan_path, build_skill_invocation_message + + user_instruction = event.get_command_args().strip() + plan_path = build_plan_path(user_instruction) + event.text = build_skill_invocation_message( + "/plan", + user_instruction, + task_id=_quick_key, + runtime_note=( + "Save the markdown plan with write_file to this exact relative path " + f"inside the active workspace/backend cwd: {plan_path}" + ), + ) + if not event.text: + return "Failed to load the bundled /plan skill." + canonical = None + except Exception as e: + logger.exception("Failed to prepare /plan command") + return f"Failed to enter plan mode: {e}" + + if canonical == "retry": + return await self._handle_retry_command(event) + + if canonical == "undo": + return await self._handle_undo_command(event) + + if canonical == "sethome": + return await self._handle_set_home_command(event) + + if canonical == "compress": + return await self._handle_compress_command(event) + + if canonical == "usage": + return await self._handle_usage_command(event) + + if canonical == "insights": + return await self._handle_insights_command(event) + + if canonical == "reload-mcp": + return await self._handle_reload_mcp_command(event) + + if canonical == "approve": + return await self._handle_approve_command(event) + + if canonical == "deny": + return await self._handle_deny_command(event) + + if canonical == "update": + return await self._handle_update_command(event) + + if canonical == "debug": + return await self._handle_debug_command(event) + + if canonical == "title": + return await self._handle_title_command(event) + + if canonical == "resume": + return await self._handle_resume_command(event) + + if canonical == "branch": + return await self._handle_branch_command(event) + + if canonical == "rollback": + return await self._handle_rollback_command(event) + + if canonical == "background": + return await self._handle_background_command(event) + + if canonical == "btw": + return await self._handle_btw_command(event) + + if canonical == "voice": + return await self._handle_voice_command(event) + + if self._draining: + return f"⏳ Gateway is {self._status_action_gerund()} and is not accepting new work right now." + + # User-defined quick commands (bypass agent loop, no LLM call) + if command: + if isinstance(self.config, dict): + quick_commands = self.config.get("quick_commands", {}) or {} + else: + quick_commands = getattr(self.config, "quick_commands", {}) or {} + if not isinstance(quick_commands, dict): + quick_commands = {} + if command in quick_commands: + qcmd = quick_commands[command] + if qcmd.get("type") == "exec": + exec_cmd = qcmd.get("command", "") + if exec_cmd: + try: + proc = await asyncio.create_subprocess_shell( + exec_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30) + output = (stdout or stderr).decode().strip() + return output if output else "Command returned no output." + except asyncio.TimeoutError: + return "Quick command timed out (30s)." + except Exception as e: + return f"Quick command error: {e}" + else: + return f"Quick command '/{command}' has no command defined." + elif qcmd.get("type") == "alias": + target = qcmd.get("target", "").strip() + if target: + target = target if target.startswith("/") else f"/{target}" + target_command = target.lstrip("/") + user_args = event.get_command_args().strip() + event.text = f"{target} {user_args}".strip() + command = target_command + # Fall through to normal command dispatch below + else: + return f"Quick command '/{command}' has no target defined." + else: + return f"Quick command '/{command}' has unsupported type (supported: 'exec', 'alias')." + + # Plugin-registered slash commands + if command: + try: + from hermes_cli.plugins import get_plugin_command_handler + # Normalize underscores to hyphens so Telegram's underscored + # autocomplete form matches plugin commands registered with + # hyphens. See hermes_cli/commands.py:_build_telegram_menu. + plugin_handler = get_plugin_command_handler(command.replace("_", "-")) + if plugin_handler: + user_args = event.get_command_args().strip() + import asyncio as _aio + result = plugin_handler(user_args) + if _aio.iscoroutine(result): + result = await result + return str(result) if result else None + except Exception as e: + logger.debug("Plugin command dispatch failed (non-fatal): %s", e) + + # Skill slash commands: /skill-name loads the skill and sends to agent. + # resolve_skill_command_key() handles the Telegram underscore/hyphen + # round-trip so /claude_code from Telegram autocomplete still resolves + # to the claude-code skill. + if command: + try: + from agent.skill_commands import ( + get_skill_commands, + build_skill_invocation_message, + resolve_skill_command_key, + ) + skill_cmds = get_skill_commands() + cmd_key = resolve_skill_command_key(command) + if cmd_key is not None: + # Check per-platform disabled status before executing. + # get_skill_commands() only applies the *global* disabled + # list at scan time; per-platform overrides need checking + # here because the cache is process-global across platforms. + _skill_name = skill_cmds[cmd_key].get("name", "") + _plat = source.platform.value if source.platform else None + if _plat and _skill_name: + from agent.skill_utils import get_disabled_skill_names as _get_plat_disabled + if _skill_name in _get_plat_disabled(platform=_plat): + return ( + f"The **{_skill_name}** skill is disabled for {_plat}.\n" + f"Enable it with: `hermes skills config`" + ) + user_instruction = event.get_command_args().strip() + msg = build_skill_invocation_message( + cmd_key, user_instruction, task_id=_quick_key + ) + if msg: + event.text = msg + # Fall through to normal message processing with skill content + else: + # Not an active skill — check if it's a known-but-disabled or + # uninstalled skill and give actionable guidance. + _unavail_msg = _check_unavailable_skill(command) + if _unavail_msg: + return _unavail_msg + # Genuinely unrecognized /command: not a built-in, not a + # plugin, not a skill, not a known-inactive skill. Warn + # the user instead of silently forwarding it to the LLM + # as free text (which leads to silent-failure behavior + # like the model inventing a delegate_task call). + # Normalize to hyphenated form before checking known + # built-ins (command may be an alias target set by the + # quick-command block above, so _cmd_def can be stale). + if command.replace("_", "-") not in GATEWAY_KNOWN_COMMANDS: + logger.warning( + "Unrecognized slash command /%s from %s — " + "replying with unknown-command notice", + command, + source.platform.value if source.platform else "?", + ) + return ( + f"Unknown command `/{command}`. " + f"Type /commands to see what's available, " + f"or resend without the leading slash to send " + f"as a regular message." + ) + except Exception as e: + logger.debug("Skill command check failed (non-fatal): %s", e) + + # Pending exec approvals are handled by /approve and /deny commands above. + # No bare text matching — "yes" in normal conversation must not trigger + # execution of a dangerous command. + + # ── Claim this session before any await ─────────────────────── + # Between here and _run_agent registering the real AIAgent, there + # are numerous await points (hooks, vision enrichment, STT, + # session hygiene compression). Without this sentinel a second + # message arriving during any of those yields would pass the + # "already running" guard and spin up a duplicate agent for the + # same session — corrupting the transcript. + self._running_agents[_quick_key] = _AGENT_PENDING_SENTINEL + self._running_agents_ts[_quick_key] = time.time() + + try: + return await self._handle_message_with_agent(event, source, _quick_key) + finally: + # If _run_agent replaced the sentinel with a real agent and + # then cleaned it up, this is a no-op. If we exited early + # (exception, command fallthrough, etc.) the sentinel must + # not linger or the session would be permanently locked out. + if self._running_agents.get(_quick_key) is _AGENT_PENDING_SENTINEL: + del self._running_agents[_quick_key] + self._running_agents_ts.pop(_quick_key, None) + + async def _prepare_inbound_message_text( + self, + *, + event: MessageEvent, + source: SessionSource, + history: List[Dict[str, Any]], + ) -> Optional[str]: + """Prepare inbound event text for the agent. + + Keep the normal inbound path and the queued follow-up path on the same + preprocessing pipeline so sender attribution, image enrichment, STT, + document notes, reply context, and @ references all behave the same. + """ + history = history or [] + message_text = event.text or "" + + _is_shared_thread = ( + source.chat_type != "dm" + and source.thread_id + and not getattr(self.config, "thread_sessions_per_user", False) + ) + if _is_shared_thread and source.user_name: + message_text = f"[{source.user_name}] {message_text}" + + if event.media_urls: + image_paths = [] + audio_paths = [] + for i, path in enumerate(event.media_urls): + mtype = event.media_types[i] if i < len(event.media_types) else "" + if mtype.startswith("image/") or event.message_type == MessageType.PHOTO: + image_paths.append(path) + if mtype.startswith("audio/") or event.message_type in (MessageType.VOICE, MessageType.AUDIO): + audio_paths.append(path) + + if image_paths: + message_text = await self._enrich_message_with_vision( + message_text, + image_paths, + ) + + if audio_paths: + message_text = await self._enrich_message_with_transcription( + message_text, + audio_paths, + ) + _stt_fail_markers = ( + "No STT provider", + "STT is disabled", + "can't listen", + "VOICE_TOOLS_OPENAI_KEY", + ) + if any(marker in message_text for marker in _stt_fail_markers): + _stt_adapter = self.adapters.get(source.platform) + _stt_meta = {"thread_id": source.thread_id} if source.thread_id else None + if _stt_adapter: + try: + _stt_msg = ( + "🎤 I received your voice message but can't transcribe it — " + "no speech-to-text provider is configured.\n\n" + "To enable voice: install faster-whisper " + "(`pip install faster-whisper` in the Hermes venv) " + "and set `stt.enabled: true` in config.yaml, " + "then /restart the gateway." + ) + if self._has_setup_skill(): + _stt_msg += "\n\nFor full setup instructions, type: `/skill hermes-agent-setup`" + await _stt_adapter.send( + source.chat_id, + _stt_msg, + metadata=_stt_meta, + ) + except Exception: + pass + + if event.media_urls and event.message_type == MessageType.DOCUMENT: + import mimetypes as _mimetypes + + _TEXT_EXTENSIONS = {".txt", ".md", ".csv", ".log", ".json", ".xml", ".yaml", ".yml", ".toml", ".ini", ".cfg"} + for i, path in enumerate(event.media_urls): + mtype = event.media_types[i] if i < len(event.media_types) else "" + if mtype in ("", "application/octet-stream"): + import os as _os2 + + _ext = _os2.path.splitext(path)[1].lower() + if _ext in _TEXT_EXTENSIONS: + mtype = "text/plain" + else: + guessed, _ = _mimetypes.guess_type(path) + if guessed: + mtype = guessed + if not mtype.startswith(("application/", "text/")): + continue + + import os as _os + import re as _re + + basename = _os.path.basename(path) + parts = basename.split("_", 2) + display_name = parts[2] if len(parts) >= 3 else basename + display_name = _re.sub(r'[^\w.\- ]', '_', display_name) + + if mtype.startswith("text/"): + context_note = ( + f"[The user sent a text document: '{display_name}'. " + f"Its content has been included below. " + f"The file is also saved at: {path}]" + ) + else: + context_note = ( + f"[The user sent a document: '{display_name}'. " + f"The file is saved at: {path}. " + f"Ask the user what they'd like you to do with it.]" + ) + message_text = f"{context_note}\n\n{message_text}" + + if getattr(event, "reply_to_text", None) and event.reply_to_message_id: + reply_snippet = event.reply_to_text[:500] + found_in_history = any( + reply_snippet[:200] in (msg.get("content") or "") + for msg in history + if msg.get("role") in ("assistant", "user", "tool") + ) + if not found_in_history: + message_text = f'[Replying to: "{reply_snippet}"]\n\n{message_text}' + + if "@" in message_text: + try: + from agent.context_references import preprocess_context_references_async + from agent.model_metadata import get_model_context_length + + _msg_cwd = os.environ.get("MESSAGING_CWD", os.path.expanduser("~")) + _msg_ctx_len = get_model_context_length( + self._model, + base_url=self._base_url or "", + ) + _ctx_result = await preprocess_context_references_async( + message_text, + cwd=_msg_cwd, + context_length=_msg_ctx_len, + allowed_root=_msg_cwd, + ) + if _ctx_result.blocked: + _adapter = self.adapters.get(source.platform) + if _adapter: + await _adapter.send( + source.chat_id, + "\n".join(_ctx_result.warnings) or "Context injection refused.", + ) + return None + if _ctx_result.expanded: + message_text = _ctx_result.message + except Exception as exc: + logger.debug("@ context reference expansion failed: %s", exc) + + return message_text + + async def _handle_message_with_agent(self, event, source, _quick_key: str): + """Inner handler that runs under the _running_agents sentinel guard.""" + _msg_start_time = time.time() + _platform_name = source.platform.value if hasattr(source.platform, "value") else str(source.platform) + _msg_preview = (event.text or "")[:80].replace("\n", " ") + logger.info( + "inbound message: platform=%s user=%s chat=%s msg=%r", + _platform_name, source.user_name or source.user_id or "unknown", + source.chat_id or "unknown", _msg_preview, + ) + + # Get or create session + session_entry = self.session_store.get_or_create_session(source) + session_key = session_entry.session_key + + # Emit session:start for new or auto-reset sessions + _is_new_session = ( + session_entry.created_at == session_entry.updated_at + or getattr(session_entry, "was_auto_reset", False) + ) + if _is_new_session: + await self.hooks.emit("session:start", { + "platform": source.platform.value if source.platform else "", + "user_id": source.user_id, + "session_id": session_entry.session_id, + "session_key": session_key, + }) + + # Build session context + context = build_session_context(source, self.config, session_entry) + + # Set session context variables for tools (task-local, concurrency-safe) + _session_env_tokens = self._set_session_env(context) + + # Read privacy.redact_pii from config (re-read per message) + _redact_pii = False + try: + import yaml as _pii_yaml + with open(_config_path, encoding="utf-8") as _pf: + _pcfg = _pii_yaml.safe_load(_pf) or {} + _redact_pii = bool((_pcfg.get("privacy") or {}).get("redact_pii", False)) + except Exception: + pass + + # Build the context prompt to inject + context_prompt = build_session_context_prompt(context, redact_pii=_redact_pii) + + # If the previous session expired and was auto-reset, prepend a notice + # so the agent knows this is a fresh conversation (not an intentional /reset). + if getattr(session_entry, 'was_auto_reset', False): + reset_reason = getattr(session_entry, 'auto_reset_reason', None) or 'idle' + if reset_reason == "suspended": + context_note = "[System note: The user's previous session was stopped and suspended. This is a fresh conversation with no prior context.]" + elif reset_reason == "daily": + context_note = "[System note: The user's session was automatically reset by the daily schedule. This is a fresh conversation with no prior context.]" + else: + context_note = "[System note: The user's previous session expired due to inactivity. This is a fresh conversation with no prior context.]" + context_prompt = context_note + "\n\n" + context_prompt + + # Send a user-facing notification explaining the reset, unless: + # - notifications are disabled in config + # - the platform is excluded (e.g. api_server, webhook) + # - the expired session had no activity (nothing was cleared) + try: + policy = self.session_store.config.get_reset_policy( + platform=source.platform, + session_type=getattr(source, 'chat_type', 'dm'), + ) + platform_name = source.platform.value if source.platform else "" + had_activity = getattr(session_entry, 'reset_had_activity', False) + # Suspended sessions always notify (they were explicitly stopped + # or crashed mid-operation) — skip the policy check. + should_notify = reset_reason == "suspended" or ( + policy.notify + and had_activity + and platform_name not in policy.notify_exclude_platforms + ) + if should_notify: + adapter = self.adapters.get(source.platform) + if adapter: + if reset_reason == "suspended": + reason_text = "previous session was stopped or interrupted" + elif reset_reason == "daily": + reason_text = f"daily schedule at {policy.at_hour}:00" + else: + hours = policy.idle_minutes // 60 + mins = policy.idle_minutes % 60 + duration = f"{hours}h" if not mins else f"{hours}h {mins}m" if hours else f"{mins}m" + reason_text = f"inactive for {duration}" + notice = ( + f"◐ Session automatically reset ({reason_text}). " + f"Conversation history cleared.\n" + f"Use /resume to browse and restore a previous session.\n" + f"Adjust reset timing in config.yaml under session_reset." + ) + try: + session_info = self._format_session_info() + if session_info: + notice = f"{notice}\n\n{session_info}" + except Exception: + pass + await adapter.send( + source.chat_id, notice, + metadata=getattr(event, 'metadata', None), + ) + except Exception as e: + logger.debug("Auto-reset notification failed (non-fatal): %s", e) + + session_entry.was_auto_reset = False + session_entry.auto_reset_reason = None + + # Auto-load skill(s) for topic/channel bindings (Telegram DM Topics, + # Discord channel_skill_bindings). Supports a single name or ordered list. + # Only inject on NEW sessions — ongoing conversations already have the + # skill content in their conversation history from the first message. + _auto = getattr(event, "auto_skill", None) + if _is_new_session and _auto: + _skill_names = [_auto] if isinstance(_auto, str) else list(_auto) + try: + from agent.skill_commands import _load_skill_payload, _build_skill_message + _combined_parts: list[str] = [] + _loaded_names: list[str] = [] + for _sname in _skill_names: + _loaded = _load_skill_payload(_sname, task_id=_quick_key) + if _loaded: + _loaded_skill, _skill_dir, _display_name = _loaded + _note = ( + f'[SYSTEM: The "{_display_name}" skill is auto-loaded. ' + f"Follow its instructions for this session.]" + ) + _part = _build_skill_message(_loaded_skill, _skill_dir, _note) + if _part: + _combined_parts.append(_part) + _loaded_names.append(_sname) + else: + logger.warning("[Gateway] Auto-skill '%s' not found", _sname) + if _combined_parts: + # Append the user's original text after all skill payloads + _combined_parts.append(event.text) + event.text = "\n\n".join(_combined_parts) + logger.info( + "[Gateway] Auto-loaded skill(s) %s for session %s", + _loaded_names, session_key, + ) + except Exception as e: + logger.warning("[Gateway] Failed to auto-load skill(s) %s: %s", _skill_names, e) + + # Load conversation history from transcript + history = self.session_store.load_transcript(session_entry.session_id) + + # ----------------------------------------------------------------- + # Session hygiene: auto-compress pathologically large transcripts + # + # Long-lived gateway sessions can accumulate enough history that + # every new message rehydrates an oversized transcript, causing + # repeated truncation/context failures. Detect this early and + # compress proactively — before the agent even starts. (#628) + # + # Token source priority: + # 1. Actual API-reported prompt_tokens from the last turn + # (stored in session_entry.last_prompt_tokens) + # 2. Rough char-based estimate (str(msg)//4). Overestimates + # by 30-50% on code/JSON-heavy sessions, but that just + # means hygiene fires a bit early — safe and harmless. + # ----------------------------------------------------------------- + if history and len(history) >= 4: + from agent.model_metadata import ( + estimate_messages_tokens_rough, + get_model_context_length, + ) + + # Read model + compression config from config.yaml. + # NOTE: hygiene threshold is intentionally HIGHER than the agent's + # own compressor (0.85 vs 0.50). Hygiene is a safety net for + # sessions that grew too large between turns — it fires pre-agent + # to prevent API failures. The agent's own compressor handles + # normal context management during its tool loop with accurate + # real token counts. Having hygiene at 0.50 caused premature + # compression on every turn in long gateway sessions. + _hyg_model = "anthropic/claude-sonnet-4.6" + _hyg_threshold_pct = 0.85 + _hyg_compression_enabled = True + _hyg_config_context_length = None + _hyg_provider = None + _hyg_base_url = None + _hyg_api_key = None + _hyg_data = {} + try: + _hyg_cfg_path = _hermes_home / "config.yaml" + if _hyg_cfg_path.exists(): + import yaml as _hyg_yaml + with open(_hyg_cfg_path, encoding="utf-8") as _hyg_f: + _hyg_data = _hyg_yaml.safe_load(_hyg_f) or {} + + # Resolve model name (same logic as run_sync) + _model_cfg = _hyg_data.get("model", {}) + if isinstance(_model_cfg, str): + _hyg_model = _model_cfg + elif isinstance(_model_cfg, dict): + _hyg_model = _model_cfg.get("default") or _model_cfg.get("model") or _hyg_model + # Read explicit context_length override from model config + # (same as run_agent.py lines 995-1005) + _raw_ctx = _model_cfg.get("context_length") + if _raw_ctx is not None: + try: + _hyg_config_context_length = int(_raw_ctx) + except (TypeError, ValueError): + pass + # Read provider for accurate context detection + _hyg_provider = _model_cfg.get("provider") or None + _hyg_base_url = _model_cfg.get("base_url") or None + + # Read compression settings — only use enabled flag. + # The threshold is intentionally separate from the agent's + # compression.threshold (hygiene runs higher). + _comp_cfg = _hyg_data.get("compression", {}) + if isinstance(_comp_cfg, dict): + _hyg_compression_enabled = str( + _comp_cfg.get("enabled", True) + ).lower() in ("true", "1", "yes") + + try: + _hyg_model, _hyg_runtime = self._resolve_session_agent_runtime( + source=source, + session_key=session_key, + user_config=_hyg_data if isinstance(_hyg_data, dict) else None, + ) + _hyg_provider = _hyg_runtime.get("provider") or _hyg_provider + _hyg_base_url = _hyg_runtime.get("base_url") or _hyg_base_url + _hyg_api_key = _hyg_runtime.get("api_key") or _hyg_api_key + except Exception: + pass + + # Check custom_providers per-model context_length + # (same fallback as run_agent.py lines 1171-1189). + # Must run after runtime resolution so _hyg_base_url is set. + if _hyg_config_context_length is None and _hyg_base_url: + try: + try: + from hermes_cli.config import get_compatible_custom_providers as _gw_gcp + _hyg_custom_providers = _gw_gcp(_hyg_data) + except Exception: + _hyg_custom_providers = _hyg_data.get("custom_providers") + if not isinstance(_hyg_custom_providers, list): + _hyg_custom_providers = [] + for _cp in _hyg_custom_providers: + if not isinstance(_cp, dict): + continue + _cp_url = (_cp.get("base_url") or "").rstrip("/") + if _cp_url and _cp_url == _hyg_base_url.rstrip("/"): + _cp_models = _cp.get("models", {}) + if isinstance(_cp_models, dict): + _cp_model_cfg = _cp_models.get(_hyg_model, {}) + if isinstance(_cp_model_cfg, dict): + _cp_ctx = _cp_model_cfg.get("context_length") + if _cp_ctx is not None: + _hyg_config_context_length = int(_cp_ctx) + break + except (TypeError, ValueError): + pass + except Exception: + pass + + if _hyg_compression_enabled: + _hyg_context_length = get_model_context_length( + _hyg_model, + base_url=_hyg_base_url or "", + api_key=_hyg_api_key or "", + config_context_length=_hyg_config_context_length, + provider=_hyg_provider or "", + ) + _compress_token_threshold = int( + _hyg_context_length * _hyg_threshold_pct + ) + _warn_token_threshold = int(_hyg_context_length * 0.95) + + _msg_count = len(history) + + # Prefer actual API-reported tokens from the last turn + # (stored in session entry) over the rough char-based estimate. + _stored_tokens = session_entry.last_prompt_tokens + if _stored_tokens > 0: + _approx_tokens = _stored_tokens + _token_source = "actual" + else: + _approx_tokens = estimate_messages_tokens_rough(history) + _token_source = "estimated" + # Note: rough estimates overestimate by 30-50% for code/JSON-heavy + # sessions, but that just means hygiene fires a bit early — which + # is safe and harmless. The 85% threshold already provides ample + # headroom (agent's own compressor runs at 50%). A previous 1.4x + # multiplier tried to compensate by inflating the threshold, but + # 85% * 1.4 = 119% of context — which exceeds the model's limit + # and prevented hygiene from ever firing for ~200K models (GLM-5). + + # Hard safety valve: force compression if message count is + # extreme, regardless of token estimates. This breaks the + # death spiral where API disconnects prevent token data + # collection, which prevents compression, which causes more + # disconnects. 400 messages is well above normal sessions + # but catches runaway growth before it becomes unrecoverable. + # (#2153) + _HARD_MSG_LIMIT = 400 + _needs_compress = ( + _approx_tokens >= _compress_token_threshold + or _msg_count >= _HARD_MSG_LIMIT + ) + + if _needs_compress: + logger.info( + "Session hygiene: %s messages, ~%s tokens (%s) — auto-compressing " + "(threshold: %s%% of %s = %s tokens)", + _msg_count, f"{_approx_tokens:,}", _token_source, + int(_hyg_threshold_pct * 100), + f"{_hyg_context_length:,}", + f"{_compress_token_threshold:,}", + ) + + _hyg_meta = {"thread_id": source.thread_id} if source.thread_id else None + + try: + from run_agent import AIAgent + + _hyg_model, _hyg_runtime = self._resolve_session_agent_runtime( + source=source, + session_key=session_key, + user_config=_hyg_data if isinstance(_hyg_data, dict) else None, + ) + if _hyg_runtime.get("api_key"): + _hyg_msgs = [ + {"role": m.get("role"), "content": m.get("content")} + for m in history + if m.get("role") in ("user", "assistant") + and m.get("content") + ] + + if len(_hyg_msgs) >= 4: + _hyg_agent = AIAgent( + **_hyg_runtime, + model=_hyg_model, + max_iterations=4, + quiet_mode=True, + enabled_toolsets=["memory"], + session_id=session_entry.session_id, + ) + _hyg_agent._print_fn = lambda *a, **kw: None + + loop = asyncio.get_event_loop() + _compressed, _ = await loop.run_in_executor( + None, + lambda: _hyg_agent._compress_context( + _hyg_msgs, "", + approx_tokens=_approx_tokens, + ), + ) + + # _compress_context ends the old session and creates + # a new session_id. Write compressed messages into + # the NEW session so the old transcript stays intact + # and searchable via session_search. + _hyg_new_sid = _hyg_agent.session_id + if _hyg_new_sid != session_entry.session_id: + session_entry.session_id = _hyg_new_sid + self.session_store._save() + + self.session_store.rewrite_transcript( + session_entry.session_id, _compressed + ) + # Reset stored token count — transcript was rewritten + session_entry.last_prompt_tokens = 0 + history = _compressed + _new_count = len(_compressed) + _new_tokens = estimate_messages_tokens_rough( + _compressed + ) + + logger.info( + "Session hygiene: compressed %s → %s msgs, " + "~%s → ~%s tokens", + _msg_count, _new_count, + f"{_approx_tokens:,}", f"{_new_tokens:,}", + ) + + if _new_tokens >= _warn_token_threshold: + logger.warning( + "Session hygiene: still ~%s tokens after " + "compression", + f"{_new_tokens:,}", + ) + + except Exception as e: + logger.warning( + "Session hygiene auto-compress failed: %s", e + ) + + # First-message onboarding -- only on the very first interaction ever + if not history and not self.session_store.has_any_sessions(): + context_prompt += ( + "\n\n[System note: This is the user's very first message ever. " + "Briefly introduce yourself and mention that /help shows available commands. " + "Keep the introduction concise -- one or two sentences max.]" + ) + + # One-time prompt if no home channel is set for this platform + # Skip for webhooks - they deliver directly to configured targets (github_comment, etc.) + if not history and source.platform and source.platform != Platform.LOCAL and source.platform != Platform.WEBHOOK: + platform_name = source.platform.value + env_key = f"{platform_name.upper()}_HOME_CHANNEL" + if not os.getenv(env_key): + adapter = self.adapters.get(source.platform) + if adapter: + await adapter.send( + source.chat_id, + f"📬 No home channel is set for {platform_name.title()}. " + f"A home channel is where Hermes delivers cron job results " + f"and cross-platform messages.\n\n" + f"Type /sethome to make this chat your home channel, " + f"or ignore to skip." + ) + + # ----------------------------------------------------------------- + # Voice channel awareness — inject current voice channel state + # into context so the agent knows who is in the channel and who + # is speaking, without needing a separate tool call. + # ----------------------------------------------------------------- + if source.platform == Platform.DISCORD: + adapter = self.adapters.get(Platform.DISCORD) + guild_id = self._get_guild_id(event) + if guild_id and adapter and hasattr(adapter, "get_voice_channel_context"): + vc_context = adapter.get_voice_channel_context(guild_id) + if vc_context: + context_prompt += f"\n\n{vc_context}" + + # ----------------------------------------------------------------- + # Auto-analyze images sent by the user + # + # If the user attached image(s), we run the vision tool eagerly so + # the conversation model always receives a text description. The + # local file path is also included so the model can re-examine the + # image later with a more targeted question via vision_analyze. + # + # We filter to image paths only (by media_type) so that non-image + # attachments (documents, audio, etc.) are not sent to the vision + # tool even when they appear in the same message. + # ----------------------------------------------------------------- + message_text = await self._prepare_inbound_message_text( + event=event, + source=source, + history=history, + ) + if message_text is None: + return + + try: + # Emit agent:start hook + hook_ctx = { + "platform": source.platform.value if source.platform else "", + "user_id": source.user_id, + "session_id": session_entry.session_id, + "message": message_text[:500], + } + await self.hooks.emit("agent:start", hook_ctx) + + # Run the agent + agent_result = await self._run_agent( + message=message_text, + context_prompt=context_prompt, + history=history, + source=source, + session_id=session_entry.session_id, + session_key=session_key, + event_message_id=event.message_id, + ) + + # Stop persistent typing indicator now that the agent is done + try: + _typing_adapter = self.adapters.get(source.platform) + if _typing_adapter and hasattr(_typing_adapter, "stop_typing"): + await _typing_adapter.stop_typing(source.chat_id) + except Exception: + pass + + response = agent_result.get("final_response") or "" + agent_messages = agent_result.get("messages", []) + _response_time = time.time() - _msg_start_time + _api_calls = agent_result.get("api_calls", 0) + _resp_len = len(response) + logger.info( + "response ready: platform=%s chat=%s time=%.1fs api_calls=%d response=%d chars", + _platform_name, source.chat_id or "unknown", + _response_time, _api_calls, _resp_len, + ) + + # Surface error details when the agent failed silently (final_response=None) + if not response and agent_result.get("failed"): + error_detail = agent_result.get("error", "unknown error") + error_str = str(error_detail).lower() + + # Detect context-overflow failures and give specific guidance. + # Generic 400 "Error" from Anthropic with large sessions is the + # most common cause of this (#1630). + _is_ctx_fail = any(p in error_str for p in ( + "context", "token", "too large", "too long", + "exceed", "payload", + )) or ( + "400" in error_str + and len(history) > 50 + ) + + if _is_ctx_fail: + response = ( + "⚠️ Session too large for the model's context window.\n" + "Use /compact to compress the conversation, or " + "/reset to start fresh." + ) + else: + response = ( + f"The request failed: {str(error_detail)[:300]}\n" + "Try again or use /reset to start a fresh session." + ) + + # If the agent's session_id changed during compression, update + # session_entry so transcript writes below go to the right session. + if agent_result.get("session_id") and agent_result["session_id"] != session_entry.session_id: + session_entry.session_id = agent_result["session_id"] + + # Prepend reasoning/thinking if display is enabled (per-platform) + try: + from gateway.display_config import resolve_display_setting as _rds + _show_reasoning_effective = _rds( + _load_gateway_config(), + _platform_config_key(source.platform), + "show_reasoning", + getattr(self, "_show_reasoning", False), + ) + except Exception: + _show_reasoning_effective = getattr(self, "_show_reasoning", False) + if _show_reasoning_effective and response: + last_reasoning = agent_result.get("last_reasoning") + if last_reasoning: + # Collapse long reasoning to keep messages readable + lines = last_reasoning.strip().splitlines() + if len(lines) > 15: + display_reasoning = "\n".join(lines[:15]) + display_reasoning += f"\n_... ({len(lines) - 15} more lines)_" + else: + display_reasoning = last_reasoning.strip() + response = f"💭 **Reasoning:**\n```\n{display_reasoning}\n```\n\n{response}" + + # Emit agent:end hook + await self.hooks.emit("agent:end", { + **hook_ctx, + "response": (response or "")[:500], + }) + + # Check for pending process watchers (check_interval on background processes) + try: + from tools.process_registry import process_registry + while process_registry.pending_watchers: + watcher = process_registry.pending_watchers.pop(0) + asyncio.create_task(self._run_process_watcher(watcher)) + except Exception as e: + logger.error("Process watcher setup error: %s", e) + + # Drain watch pattern notifications that arrived during the agent run. + # Watch events and completions share the same queue; completions are + # already handled by the per-process watcher task above, so we only + # inject watch-type events here. + try: + from tools.process_registry import process_registry as _pr + _watch_events = [] + while not _pr.completion_queue.empty(): + evt = _pr.completion_queue.get_nowait() + evt_type = evt.get("type", "completion") + if evt_type in ("watch_match", "watch_disabled"): + _watch_events.append(evt) + # else: completion events are handled by the watcher task + for evt in _watch_events: + synth_text = _format_gateway_process_notification(evt) + if synth_text: + try: + await self._inject_watch_notification(synth_text, event) + except Exception as e2: + logger.error("Watch notification injection error: %s", e2) + except Exception as e: + logger.debug("Watch queue drain error: %s", e) + + # NOTE: Dangerous command approvals are now handled inline by the + # blocking gateway approval mechanism in tools/approval.py. The agent + # thread blocks until the user responds with /approve or /deny, so by + # the time we reach here the approval has already been resolved. The + # old post-loop pop_pending + approval_hint code was removed in favour + # of the blocking approach that mirrors CLI's synchronous input(). + + # Save the full conversation to the transcript, including tool calls. + # This preserves the complete agent loop (tool_calls, tool results, + # intermediate reasoning) so sessions can be resumed with full context + # and transcripts are useful for debugging and training data. + # + # IMPORTANT: When the agent failed before producing any response + # (e.g. context-overflow 400), do NOT persist the user's message. + # Persisting it would make the session even larger, causing the + # same failure on the next attempt — an infinite loop. (#1630) + agent_failed_early = ( + agent_result.get("failed") + and not agent_result.get("final_response") + ) + if agent_failed_early: + logger.info( + "Skipping transcript persistence for failed request in " + "session %s to prevent session growth loop.", + session_entry.session_id, + ) + + ts = datetime.now().isoformat() + + # If this is a fresh session (no history), write the full tool + # definitions as the first entry so the transcript is self-describing + # -- the same list of dicts sent as tools=[...] in the API request. + if agent_failed_early: + pass # Skip all transcript writes — don't grow a broken session + elif not history: + tool_defs = agent_result.get("tools", []) + self.session_store.append_to_transcript( + session_entry.session_id, + { + "role": "session_meta", + "tools": tool_defs or [], + "model": _resolve_gateway_model(), + "platform": source.platform.value if source.platform else "", + "timestamp": ts, + } + ) + + # Find only the NEW messages from this turn (skip history we loaded). + # Use the filtered history length (history_offset) that was actually + # passed to the agent, not len(history) which includes session_meta + # entries that were stripped before the agent saw them. + if not agent_failed_early: + history_len = agent_result.get("history_offset", len(history)) + new_messages = agent_messages[history_len:] if len(agent_messages) > history_len else [] + + # If no new messages found (edge case), fall back to simple user/assistant + if not new_messages: + self.session_store.append_to_transcript( + session_entry.session_id, + {"role": "user", "content": message_text, "timestamp": ts} + ) + if response: + self.session_store.append_to_transcript( + session_entry.session_id, + {"role": "assistant", "content": response, "timestamp": ts} + ) + else: + # The agent already persisted these messages to SQLite via + # _flush_messages_to_session_db(), so skip the DB write here + # to prevent the duplicate-write bug (#860). We still write + # to JSONL for backward compatibility and as a backup. + agent_persisted = self._session_db is not None + for msg in new_messages: + # Skip system messages (they're rebuilt each run) + if msg.get("role") == "system": + continue + # Add timestamp to each message for debugging + entry = {**msg, "timestamp": ts} + self.session_store.append_to_transcript( + session_entry.session_id, entry, + skip_db=agent_persisted, + ) + + # Token counts and model are now persisted by the agent directly. + # Keep only last_prompt_tokens here for context-window tracking and + # compression decisions. + self.session_store.update_session( + session_entry.session_key, + last_prompt_tokens=agent_result.get("last_prompt_tokens", 0), + ) + + # Auto voice reply: send TTS audio before the text response + _already_sent = bool(agent_result.get("already_sent")) + if self._should_send_voice_reply(event, response, agent_messages, already_sent=_already_sent): + await self._send_voice_reply(event, response) + + # If streaming already delivered the response, extract and + # deliver any MEDIA: files before returning None. Streaming + # sends raw text chunks that include MEDIA: tags — the normal + # post-processing in _process_message_background is skipped + # when already_sent is True, so media files would never be + # delivered without this. + # + # Never skip when the agent failed — the error message is new + # content the user hasn't seen (streaming only sent earlier + # partial output before the failure). Without this guard, + # users see the agent "stop responding without explanation." + if agent_result.get("already_sent") and not agent_result.get("failed"): + if response: + _media_adapter = self.adapters.get(source.platform) + if _media_adapter: + await self._deliver_media_from_response( + response, event, _media_adapter, + ) + return None + + return response + + except Exception as e: + # Stop typing indicator on error too + try: + _err_adapter = self.adapters.get(source.platform) + if _err_adapter and hasattr(_err_adapter, "stop_typing"): + await _err_adapter.stop_typing(source.chat_id) + except Exception: + pass + logger.exception("Agent error in session %s", session_key) + error_type = type(e).__name__ + error_detail = str(e)[:300] if str(e) else "no details available" + status_hint = "" + status_code = getattr(e, "status_code", None) + _hist_len = len(history) if 'history' in locals() else 0 + if status_code == 401: + status_hint = " Check your API key or run `claude /login` to refresh OAuth credentials." + elif status_code == 429: + # Check if this is a plan usage limit (resets on a schedule) vs a transient rate limit + _err_body = getattr(e, "response", None) + _err_json = {} + try: + if _err_body is not None: + _err_json = _err_body.json().get("error", {}) + except Exception: + pass + if _err_json.get("type") == "usage_limit_reached": + _resets_in = _err_json.get("resets_in_seconds") + if _resets_in and _resets_in > 0: + import math + _hours = math.ceil(_resets_in / 3600) + status_hint = f" Your plan's usage limit has been reached. It resets in ~{_hours}h." + else: + status_hint = " Your plan's usage limit has been reached. Please wait until it resets." + else: + status_hint = " You are being rate-limited. Please wait a moment and try again." + elif status_code == 529: + status_hint = " The API is temporarily overloaded. Please try again shortly." + elif status_code in (400, 500): + # 400 with a large session is context overflow. + # 500 with a large session often means the payload is too large + # for the API to process — treat it the same way. + if _hist_len > 50: + return ( + "⚠️ Session too large for the model's context window.\n" + "Use /compact to compress the conversation, or " + "/reset to start fresh." + ) + elif status_code == 400: + status_hint = " The request was rejected by the API." + return ( + f"Sorry, I encountered an error ({error_type}).\n" + f"{error_detail}\n" + f"{status_hint}" + "Try again or use /reset to start a fresh session." + ) + finally: + # Restore session context variables to their pre-handler state + self._clear_session_env(_session_env_tokens) + + def _format_session_info(self) -> str: + """Resolve current model config and return a formatted info block. + + Surfaces model, provider, context length, and endpoint so gateway + users can immediately see if context detection went wrong (e.g. + local models falling to the 128K default). + """ + from agent.model_metadata import get_model_context_length, DEFAULT_FALLBACK_CONTEXT + + model = _resolve_gateway_model() + config_context_length = None + provider = None + base_url = None + api_key = None + + try: + cfg_path = _hermes_home / "config.yaml" + if cfg_path.exists(): + import yaml as _info_yaml + with open(cfg_path, encoding="utf-8") as f: + data = _info_yaml.safe_load(f) or {} + model_cfg = data.get("model", {}) + if isinstance(model_cfg, dict): + raw_ctx = model_cfg.get("context_length") + if raw_ctx is not None: + try: + config_context_length = int(raw_ctx) + except (TypeError, ValueError): + pass + provider = model_cfg.get("provider") or None + base_url = model_cfg.get("base_url") or None + except Exception: + pass + + # Resolve runtime credentials for probing + try: + runtime = _resolve_runtime_agent_kwargs() + provider = provider or runtime.get("provider") + base_url = base_url or runtime.get("base_url") + api_key = runtime.get("api_key") + except Exception: + pass + + context_length = get_model_context_length( + model, + base_url=base_url or "", + api_key=api_key or "", + config_context_length=config_context_length, + provider=provider or "", + ) + + # Format context source hint + if config_context_length is not None: + ctx_source = "config" + elif context_length == DEFAULT_FALLBACK_CONTEXT: + ctx_source = "default — set model.context_length in config to override" + else: + ctx_source = "detected" + + # Format context length for display + if context_length >= 1_000_000: + ctx_display = f"{context_length / 1_000_000:.1f}M" + elif context_length >= 1_000: + ctx_display = f"{context_length // 1_000}K" + else: + ctx_display = str(context_length) + + lines = [ + f"◆ Model: `{model}`", + f"◆ Provider: {provider or 'openrouter'}", + f"◆ Context: {ctx_display} tokens ({ctx_source})", + ] + + # Show endpoint for local/custom setups + if base_url and ("localhost" in base_url or "127.0.0.1" in base_url or "0.0.0.0" in base_url): + lines.append(f"◆ Endpoint: {base_url}") + + return "\n".join(lines) + + async def _handle_reset_command(self, event: MessageEvent) -> str: + """Handle /new or /reset command.""" + source = event.source + + # Get existing session key + session_key = self._session_key_for_source(source) + + # Flush memories in the background (fire-and-forget) so the user + # gets the "Session reset!" response immediately. + try: + old_entry = self.session_store._entries.get(session_key) + if old_entry: + _flush_task = asyncio.create_task( + self._async_flush_memories(old_entry.session_id, session_key) + ) + self._background_tasks.add(_flush_task) + _flush_task.add_done_callback(self._background_tasks.discard) + except Exception as e: + logger.debug("Gateway memory flush on reset failed: %s", e) + # Close tool resources on the old agent (terminal sandboxes, browser + # daemons, background processes) before evicting from cache. + # Guard with getattr because test fixtures may skip __init__. + _cache_lock = getattr(self, "_agent_cache_lock", None) + if _cache_lock is not None: + with _cache_lock: + _cached = self._agent_cache.get(session_key) + _old_agent = _cached[0] if isinstance(_cached, tuple) else _cached if _cached else None + if _old_agent is not None: + try: + if hasattr(_old_agent, "close"): + _old_agent.close() + except Exception: + pass + self._evict_cached_agent(session_key) + + try: + from tools.env_passthrough import clear_env_passthrough + clear_env_passthrough() + except Exception: + pass + + try: + from tools.credential_files import clear_credential_files + clear_credential_files() + except Exception: + pass + + # Reset the session + new_entry = self.session_store.reset_session(session_key) + + # Clear any session-scoped model override so the next agent picks up + # the configured default instead of the previously switched model. + self._session_model_overrides.pop(session_key, None) + + # Fire plugin on_session_finalize hook (session boundary) + try: + from hermes_cli.plugins import invoke_hook as _invoke_hook + _old_sid = old_entry.session_id if old_entry else None + _invoke_hook("on_session_finalize", session_id=_old_sid, + platform=source.platform.value if source.platform else "") + except Exception: + pass + + # Emit session:end hook (session is ending) + await self.hooks.emit("session:end", { + "platform": source.platform.value if source.platform else "", + "user_id": source.user_id, + "session_key": session_key, + }) + + # Emit session:reset hook + await self.hooks.emit("session:reset", { + "platform": source.platform.value if source.platform else "", + "user_id": source.user_id, + "session_key": session_key, + }) + + # Resolve session config info to surface to the user + try: + session_info = self._format_session_info() + except Exception: + session_info = "" + + if new_entry: + header = "✨ Session reset! Starting fresh." + else: + # No existing session, just create one + new_entry = self.session_store.get_or_create_session(source, force_new=True) + header = "✨ New session started!" + + # Fire plugin on_session_reset hook (new session guaranteed to exist) + try: + from hermes_cli.plugins import invoke_hook as _invoke_hook + _new_sid = new_entry.session_id if new_entry else None + _invoke_hook("on_session_reset", session_id=_new_sid, + platform=source.platform.value if source.platform else "") + except Exception: + pass + + # Append a random tip to the reset message + try: + from hermes_cli.tips import get_random_tip + _tip_line = f"\n✦ Tip: {get_random_tip()}" + except Exception: + _tip_line = "" + + if session_info: + return f"{header}\n\n{session_info}{_tip_line}" + return f"{header}{_tip_line}" + + async def _handle_profile_command(self, event: MessageEvent) -> str: + """Handle /profile — show active profile name and home directory.""" + from hermes_constants import get_hermes_home, display_hermes_home + from pathlib import Path + + home = get_hermes_home() + display = display_hermes_home() + + # Detect profile name from HERMES_HOME path + # Profile paths look like: ~/.hermes/profiles/<name> + profiles_parent = Path.home() / ".hermes" / "profiles" + try: + rel = home.relative_to(profiles_parent) + profile_name = str(rel).split("/")[0] + except ValueError: + profile_name = None + + if profile_name: + lines = [ + f"👤 **Profile:** `{profile_name}`", + f"📂 **Home:** `{display}`", + ] + else: + lines = [ + "👤 **Profile:** default", + f"📂 **Home:** `{display}`", + ] + + return "\n".join(lines) + + async def _handle_status_command(self, event: MessageEvent) -> str: + """Handle /status command.""" + source = event.source + session_entry = self.session_store.get_or_create_session(source) + + connected_platforms = [p.value for p in self.adapters.keys()] + + # Check if there's an active agent + session_key = session_entry.session_key + is_running = session_key in self._running_agents + + title = None + if self._session_db: + try: + title = self._session_db.get_session_title(session_entry.session_id) + except Exception: + title = None + + lines = [ + "📊 **Hermes Gateway Status**", + "", + f"**Session ID:** `{session_entry.session_id}`", + ] + if title: + lines.append(f"**Title:** {title}") + lines.extend([ + f"**Created:** {session_entry.created_at.strftime('%Y-%m-%d %H:%M')}", + f"**Last Activity:** {session_entry.updated_at.strftime('%Y-%m-%d %H:%M')}", + f"**Tokens:** {session_entry.total_tokens:,}", + f"**Agent Running:** {'Yes ⚡' if is_running else 'No'}", + "", + f"**Connected Platforms:** {', '.join(connected_platforms)}", + ]) + + return "\n".join(lines) + + async def _handle_stop_command(self, event: MessageEvent) -> str: + """Handle /stop command - interrupt a running agent. + + When an agent is truly hung (blocked thread that never checks + _interrupt_requested), the early intercept in _handle_message() + handles /stop before this method is reached. This handler fires + only through normal command dispatch (no running agent) or as a + fallback. Force-clean the session lock in all cases for safety. + + The session is preserved so the user can continue the conversation. + """ + source = event.source + session_entry = self.session_store.get_or_create_session(source) + session_key = session_entry.session_key + + agent = self._running_agents.get(session_key) + if agent is _AGENT_PENDING_SENTINEL: + # Force-clean the sentinel so the session is unlocked. + if session_key in self._running_agents: + del self._running_agents[session_key] + logger.info("STOP (pending) for session %s — sentinel cleared", session_key[:20]) + return "⚡ Stopped. The agent hadn't started yet — you can continue this session." + if agent: + agent.interrupt("Stop requested") + # Force-clean the session lock so a truly hung agent doesn't + # keep it locked forever. + if session_key in self._running_agents: + del self._running_agents[session_key] + return "⚡ Stopped. You can continue this session." + else: + return "No active task to stop." + + async def _handle_restart_command(self, event: MessageEvent) -> str: + """Handle /restart command - drain active work, then restart the gateway.""" + if self._restart_requested or self._draining: + count = self._running_agent_count() + if count: + return f"⏳ Draining {count} active agent(s) before restart..." + return "⏳ Gateway restart already in progress..." + + # Save the requester's routing info so the new gateway process can + # notify them once it comes back online. + try: + import json as _json + notify_data = { + "platform": event.source.platform.value if event.source.platform else None, + "chat_id": event.source.chat_id, + } + if event.source.thread_id: + notify_data["thread_id"] = event.source.thread_id + (_hermes_home / ".restart_notify.json").write_text( + _json.dumps(notify_data) + ) + except Exception as e: + logger.debug("Failed to write restart notify file: %s", e) + + active_agents = self._running_agent_count() + # When running under a service manager (systemd/launchd), use the + # service restart path: exit with code 75 so the service manager + # restarts us. The detached subprocess approach (setsid + bash) + # doesn't work under systemd because KillMode=mixed kills all + # processes in the cgroup, including the detached helper. + _under_service = bool(os.environ.get("INVOCATION_ID")) # systemd sets this + if _under_service: + self.request_restart(detached=False, via_service=True) + else: + self.request_restart(detached=True, via_service=False) + if active_agents: + return f"⏳ Draining {active_agents} active agent(s) before restart..." + return "♻ Restarting gateway. If you aren't notified within 60 seconds, restart from the console with `hermes gateway restart`." + + async def _handle_help_command(self, event: MessageEvent) -> str: + """Handle /help command - list available commands.""" + from hermes_cli.commands import gateway_help_lines + lines = [ + "📖 **Hermes Commands**\n", + *gateway_help_lines(), + ] + try: + from agent.skill_commands import get_skill_commands + skill_cmds = get_skill_commands() + if skill_cmds: + lines.append(f"\n⚡ **Skill Commands** ({len(skill_cmds)} active):") + # Show first 10, then point to /commands for the rest + sorted_cmds = sorted(skill_cmds) + for cmd in sorted_cmds[:10]: + lines.append(f"`{cmd}` — {skill_cmds[cmd]['description']}") + if len(sorted_cmds) > 10: + lines.append(f"\n... and {len(sorted_cmds) - 10} more. Use `/commands` for the full paginated list.") + except Exception: + pass + return "\n".join(lines) + + async def _handle_commands_command(self, event: MessageEvent) -> str: + """Handle /commands [page] - paginated list of all commands and skills.""" + from hermes_cli.commands import gateway_help_lines + + raw_args = event.get_command_args().strip() + if raw_args: + try: + requested_page = int(raw_args) + except ValueError: + return "Usage: `/commands [page]`" + else: + requested_page = 1 + + # Build combined entry list: built-in commands + skill commands + entries = list(gateway_help_lines()) + try: + from agent.skill_commands import get_skill_commands + skill_cmds = get_skill_commands() + if skill_cmds: + entries.append("") + entries.append("⚡ **Skill Commands**:") + for cmd in sorted(skill_cmds): + desc = skill_cmds[cmd].get("description", "").strip() or "Skill command" + entries.append(f"`{cmd}` — {desc}") + except Exception: + pass + + if not entries: + return "No commands available." + + from gateway.config import Platform + page_size = 15 if event.source.platform == Platform.TELEGRAM else 20 + total_pages = max(1, (len(entries) + page_size - 1) // page_size) + page = max(1, min(requested_page, total_pages)) + start = (page - 1) * page_size + page_entries = entries[start:start + page_size] + + lines = [ + f"📚 **Commands** ({len(entries)} total, page {page}/{total_pages})", + "", + *page_entries, + ] + if total_pages > 1: + nav_parts = [] + if page > 1: + nav_parts.append(f"`/commands {page - 1}` ← prev") + if page < total_pages: + nav_parts.append(f"next → `/commands {page + 1}`") + lines.extend(["", " | ".join(nav_parts)]) + if page != requested_page: + lines.append(f"_(Requested page {requested_page} was out of range, showing page {page}.)_") + return "\n".join(lines) + + async def _handle_model_command(self, event: MessageEvent) -> Optional[str]: + """Handle /model command — switch model for this session. + + Supports: + /model — interactive picker (Telegram/Discord) or text list + /model <name> — switch for this session only + /model <name> --global — switch and persist to config.yaml + /model <name> --provider <provider> — switch provider + model + /model --provider <provider> — switch to provider, auto-detect model + """ + import yaml + from hermes_cli.model_switch import ( + switch_model as _switch_model, parse_model_flags, + list_authenticated_providers, + ) + from hermes_cli.providers import get_label + + raw_args = event.get_command_args().strip() + + # Parse --provider and --global flags + model_input, explicit_provider, persist_global = parse_model_flags(raw_args) + + # Read current model/provider from config + current_model = "" + current_provider = "openrouter" + current_base_url = "" + current_api_key = "" + user_provs = None + custom_provs = None + config_path = _hermes_home / "config.yaml" + try: + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, dict): + current_model = model_cfg.get("default", "") + current_provider = model_cfg.get("provider", current_provider) + current_base_url = model_cfg.get("base_url", "") + user_provs = cfg.get("providers") + try: + from hermes_cli.config import get_compatible_custom_providers + custom_provs = get_compatible_custom_providers(cfg) + except Exception: + custom_provs = cfg.get("custom_providers") + except Exception: + pass + + # Check for session override + source = event.source + session_key = self._session_key_for_source(source) + override = self._session_model_overrides.get(session_key, {}) + if override: + current_model = override.get("model", current_model) + current_provider = override.get("provider", current_provider) + current_base_url = override.get("base_url", current_base_url) + current_api_key = override.get("api_key", current_api_key) + + # No args: show interactive picker (Telegram/Discord) or text list + if not model_input and not explicit_provider: + # Try interactive picker if the platform supports it + adapter = self.adapters.get(source.platform) + has_picker = ( + adapter is not None + and getattr(type(adapter), "send_model_picker", None) is not None + ) + + if has_picker: + try: + providers = list_authenticated_providers( + current_provider=current_provider, + user_providers=user_provs, + custom_providers=custom_provs, + max_models=50, + ) + except Exception: + providers = [] + + if providers: + # Build a callback closure for when the user picks a model. + # Captures self + locals needed for the switch logic. + _self = self + _session_key = session_key + _cur_model = current_model + _cur_provider = current_provider + _cur_base_url = current_base_url + _cur_api_key = current_api_key + + async def _on_model_selected( + _chat_id: str, model_id: str, provider_slug: str + ) -> str: + """Perform the model switch and return confirmation text.""" + result = _switch_model( + raw_input=model_id, + current_provider=_cur_provider, + current_model=_cur_model, + current_base_url=_cur_base_url, + current_api_key=_cur_api_key, + is_global=False, + explicit_provider=provider_slug, + user_providers=user_provs, + custom_providers=custom_provs, + ) + if not result.success: + return f"Error: {result.error_message}" + + # Update cached agent in-place + cached_entry = None + _cache_lock = getattr(_self, "_agent_cache_lock", None) + _cache = getattr(_self, "_agent_cache", None) + if _cache_lock and _cache is not None: + with _cache_lock: + cached_entry = _cache.get(_session_key) + if cached_entry and cached_entry[0] is not None: + try: + cached_entry[0].switch_model( + new_model=result.new_model, + new_provider=result.target_provider, + api_key=result.api_key, + base_url=result.base_url, + api_mode=result.api_mode, + ) + except Exception as exc: + logger.warning("Picker model switch failed for cached agent: %s", exc) + + # Store model note + session override + if not hasattr(_self, "_pending_model_notes"): + _self._pending_model_notes = {} + _self._pending_model_notes[_session_key] = ( + f"[Note: model was just switched from {_cur_model} to {result.new_model} " + f"via {result.provider_label or result.target_provider}. " + f"Adjust your self-identification accordingly.]" + ) + _self._session_model_overrides[_session_key] = { + "model": result.new_model, + "provider": result.target_provider, + "api_key": result.api_key, + "base_url": result.base_url, + "api_mode": result.api_mode, + } + + # Evict cached agent so the next turn creates a fresh + # agent from the override rather than relying on the + # stale cache signature to trigger a rebuild. + _self._evict_cached_agent(_session_key) + + # Build confirmation text + plabel = result.provider_label or result.target_provider + lines = [f"Model switched to `{result.new_model}`"] + lines.append(f"Provider: {plabel}") + mi = result.model_info + if mi: + if mi.context_window: + lines.append(f"Context: {mi.context_window:,} tokens") + if mi.max_output: + lines.append(f"Max output: {mi.max_output:,} tokens") + if mi.has_cost_data(): + lines.append(f"Cost: {mi.format_cost()}") + lines.append(f"Capabilities: {mi.format_capabilities()}") + lines.append("_(session only — use `/model <name> --global` to persist)_") + return "\n".join(lines) + + metadata = {"thread_id": source.thread_id} if source.thread_id else None + result = await adapter.send_model_picker( + chat_id=source.chat_id, + providers=providers, + current_model=current_model, + current_provider=current_provider, + session_key=session_key, + on_model_selected=_on_model_selected, + metadata=metadata, + ) + if result.success: + return None # Picker sent — adapter handles the response + + # Fallback: text list (for platforms without picker or if picker failed) + provider_label = get_label(current_provider) + lines = [f"Current: `{current_model or 'unknown'}` on {provider_label}", ""] + + try: + providers = list_authenticated_providers( + current_provider=current_provider, + user_providers=user_provs, + custom_providers=custom_provs, + max_models=5, + ) + for p in providers: + tag = " (current)" if p["is_current"] else "" + lines.append(f"**{p['name']}** `--provider {p['slug']}`{tag}:") + if p["models"]: + model_strs = ", ".join(f"`{m}`" for m in p["models"]) + extra = f" (+{p['total_models'] - len(p['models'])} more)" if p["total_models"] > len(p["models"]) else "" + lines.append(f" {model_strs}{extra}") + elif p.get("api_url"): + lines.append(f" `{p['api_url']}`") + lines.append("") + except Exception: + pass + + lines.append("`/model <name>` — switch model") + lines.append("`/model <name> --provider <slug>` — switch provider") + lines.append("`/model <name> --global` — persist") + return "\n".join(lines) + + # Perform the switch + result = _switch_model( + raw_input=model_input, + current_provider=current_provider, + current_model=current_model, + current_base_url=current_base_url, + current_api_key=current_api_key, + is_global=persist_global, + explicit_provider=explicit_provider, + user_providers=user_provs, + custom_providers=custom_provs, + ) + + if not result.success: + return f"Error: {result.error_message}" + + # If there's a cached agent, update it in-place + cached_entry = None + _cache_lock = getattr(self, "_agent_cache_lock", None) + _cache = getattr(self, "_agent_cache", None) + if _cache_lock and _cache is not None: + with _cache_lock: + cached_entry = _cache.get(session_key) + + if cached_entry and cached_entry[0] is not None: + try: + cached_entry[0].switch_model( + new_model=result.new_model, + new_provider=result.target_provider, + api_key=result.api_key, + base_url=result.base_url, + api_mode=result.api_mode, + ) + except Exception as exc: + logger.warning("In-place model switch failed for cached agent: %s", exc) + + # Store a note to prepend to the next user message so the model + # knows about the switch (avoids system messages mid-history). + if not hasattr(self, "_pending_model_notes"): + self._pending_model_notes = {} + self._pending_model_notes[session_key] = ( + f"[Note: model was just switched from {current_model} to {result.new_model} " + f"via {result.provider_label or result.target_provider}. " + f"Adjust your self-identification accordingly.]" + ) + + # Store session override so next agent creation uses the new model + self._session_model_overrides[session_key] = { + "model": result.new_model, + "provider": result.target_provider, + "api_key": result.api_key, + "base_url": result.base_url, + "api_mode": result.api_mode, + } + + # Evict cached agent so the next turn creates a fresh agent from the + # override rather than relying on cache signature mismatch detection. + self._evict_cached_agent(session_key) + + # Persist to config if --global + if persist_global: + try: + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + else: + cfg = {} + model_cfg = cfg.setdefault("model", {}) + model_cfg["default"] = result.new_model + model_cfg["provider"] = result.target_provider + if result.base_url: + model_cfg["base_url"] = result.base_url + from hermes_cli.config import save_config + save_config(cfg) + except Exception as e: + logger.warning("Failed to persist model switch: %s", e) + + # Build confirmation message with full metadata + provider_label = result.provider_label or result.target_provider + lines = [f"Model switched to `{result.new_model}`"] + lines.append(f"Provider: {provider_label}") + + # Rich metadata from models.dev + mi = result.model_info + if mi: + if mi.context_window: + lines.append(f"Context: {mi.context_window:,} tokens") + if mi.max_output: + lines.append(f"Max output: {mi.max_output:,} tokens") + if mi.has_cost_data(): + lines.append(f"Cost: {mi.format_cost()}") + lines.append(f"Capabilities: {mi.format_capabilities()}") + else: + try: + from agent.model_metadata import get_model_context_length + ctx = get_model_context_length( + result.new_model, + base_url=result.base_url or current_base_url, + api_key=result.api_key or current_api_key, + provider=result.target_provider, + ) + lines.append(f"Context: {ctx:,} tokens") + except Exception: + pass + + # Cache notice + cache_enabled = ( + ("openrouter" in (result.base_url or "").lower() and "claude" in result.new_model.lower()) + or result.api_mode == "anthropic_messages" + ) + if cache_enabled: + lines.append("Prompt caching: enabled") + + if result.warning_message: + lines.append(f"Warning: {result.warning_message}") + + if persist_global: + lines.append("Saved to config.yaml (`--global`)") + else: + lines.append("_(session only -- add `--global` to persist)_") + + return "\n".join(lines) + + async def _handle_provider_command(self, event: MessageEvent) -> str: + """Handle /provider command - show available providers.""" + import yaml + from hermes_cli.models import ( + list_available_providers, + normalize_provider, + _PROVIDER_LABELS, + ) + + # Resolve current provider from config + current_provider = "openrouter" + model_cfg = {} + config_path = _hermes_home / 'config.yaml' + try: + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, dict): + current_provider = model_cfg.get("provider", current_provider) + except Exception: + pass + + current_provider = normalize_provider(current_provider) + if current_provider == "auto": + try: + from hermes_cli.auth import resolve_provider as _resolve_provider + current_provider = _resolve_provider(current_provider) + except Exception: + current_provider = "openrouter" + + # Detect custom endpoint from config base_url + if current_provider == "openrouter": + _cfg_base = model_cfg.get("base_url", "") if isinstance(model_cfg, dict) else "" + if _cfg_base and "openrouter.ai" not in _cfg_base: + current_provider = "custom" + + current_label = _PROVIDER_LABELS.get(current_provider, current_provider) + + lines = [ + f"🔌 **Current provider:** {current_label} (`{current_provider}`)", + "", + "**Available providers:**", + ] + + providers = list_available_providers() + for p in providers: + marker = " ← active" if p["id"] == current_provider else "" + auth = "✅" if p["authenticated"] else "❌" + aliases = f" _(also: {', '.join(p['aliases'])})_" if p["aliases"] else "" + lines.append(f"{auth} `{p['id']}` — {p['label']}{aliases}{marker}") + + lines.append("") + lines.append("Switch: `/model provider:model-name`") + lines.append("Setup: `hermes setup`") + return "\n".join(lines) + + async def _handle_personality_command(self, event: MessageEvent) -> str: + """Handle /personality command - list or set a personality.""" + import yaml + + args = event.get_command_args().strip().lower() + config_path = _hermes_home / 'config.yaml' + + try: + if config_path.exists(): + with open(config_path, 'r', encoding="utf-8") as f: + config = yaml.safe_load(f) or {} + personalities = config.get("agent", {}).get("personalities", {}) + else: + config = {} + personalities = {} + except Exception: + config = {} + personalities = {} + + if not personalities: + return "No personalities configured in `~/.hermes/config.yaml`" + + if not args: + lines = ["🎭 **Available Personalities**\n"] + lines.append("• `none` — (no personality overlay)") + for name, prompt in personalities.items(): + if isinstance(prompt, dict): + preview = prompt.get("description") or prompt.get("system_prompt", "")[:50] + else: + preview = prompt[:50] + "..." if len(prompt) > 50 else prompt + lines.append(f"• `{name}` — {preview}") + lines.append("\nUsage: `/personality <name>`") + return "\n".join(lines) + + def _resolve_prompt(value): + if isinstance(value, dict): + parts = [value.get("system_prompt", "")] + if value.get("tone"): + parts.append(f'Tone: {value["tone"]}') + if value.get("style"): + parts.append(f'Style: {value["style"]}') + return "\n".join(p for p in parts if p) + return str(value) + + if args in ("none", "default", "neutral"): + try: + if "agent" not in config or not isinstance(config.get("agent"), dict): + config["agent"] = {} + config["agent"]["system_prompt"] = "" + atomic_yaml_write(config_path, config) + except Exception as e: + return f"⚠️ Failed to save personality change: {e}" + self._ephemeral_system_prompt = "" + return "🎭 Personality cleared — using base agent behavior.\n_(takes effect on next message)_" + elif args in personalities: + new_prompt = _resolve_prompt(personalities[args]) + + # Write to config.yaml, same pattern as CLI save_config_value. + try: + if "agent" not in config or not isinstance(config.get("agent"), dict): + config["agent"] = {} + config["agent"]["system_prompt"] = new_prompt + atomic_yaml_write(config_path, config) + except Exception as e: + return f"⚠️ Failed to save personality change: {e}" + + # Update in-memory so it takes effect on the very next message. + self._ephemeral_system_prompt = new_prompt + + return f"🎭 Personality set to **{args}**\n_(takes effect on next message)_" + + available = "`none`, " + ", ".join(f"`{n}`" for n in personalities) + return f"Unknown personality: `{args}`\n\nAvailable: {available}" + + async def _handle_retry_command(self, event: MessageEvent) -> str: + """Handle /retry command - re-send the last user message.""" + source = event.source + session_entry = self.session_store.get_or_create_session(source) + history = self.session_store.load_transcript(session_entry.session_id) + + # Find the last user message + last_user_msg = None + last_user_idx = None + for i in range(len(history) - 1, -1, -1): + if history[i].get("role") == "user": + last_user_msg = history[i].get("content", "") + last_user_idx = i + break + + if not last_user_msg: + return "No previous message to retry." + + # Truncate history to before the last user message and persist + truncated = history[:last_user_idx] + self.session_store.rewrite_transcript(session_entry.session_id, truncated) + # Reset stored token count — transcript was truncated + session_entry.last_prompt_tokens = 0 + + # Re-send by creating a fake text event with the old message + retry_event = MessageEvent( + text=last_user_msg, + message_type=MessageType.TEXT, + source=source, + raw_message=event.raw_message, + ) + + # Let the normal message handler process it + return await self._handle_message(retry_event) + + async def _handle_undo_command(self, event: MessageEvent) -> str: + """Handle /undo command - remove the last user/assistant exchange.""" + source = event.source + session_entry = self.session_store.get_or_create_session(source) + history = self.session_store.load_transcript(session_entry.session_id) + + # Find the last user message and remove everything from it onward + last_user_idx = None + for i in range(len(history) - 1, -1, -1): + if history[i].get("role") == "user": + last_user_idx = i + break + + if last_user_idx is None: + return "Nothing to undo." + + removed_msg = history[last_user_idx].get("content", "") + removed_count = len(history) - last_user_idx + self.session_store.rewrite_transcript(session_entry.session_id, history[:last_user_idx]) + # Reset stored token count — transcript was truncated + session_entry.last_prompt_tokens = 0 + + preview = removed_msg[:40] + "..." if len(removed_msg) > 40 else removed_msg + return f"↩️ Undid {removed_count} message(s).\nRemoved: \"{preview}\"" + + async def _handle_set_home_command(self, event: MessageEvent) -> str: + """Handle /sethome command -- set the current chat as the platform's home channel.""" + source = event.source + platform_name = source.platform.value if source.platform else "unknown" + chat_id = source.chat_id + chat_name = source.chat_name or chat_id + + env_key = f"{platform_name.upper()}_HOME_CHANNEL" + + # Save to config.yaml + try: + import yaml + config_path = _hermes_home / 'config.yaml' + user_config = {} + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + user_config = yaml.safe_load(f) or {} + user_config[env_key] = chat_id + atomic_yaml_write(config_path, user_config) + # Also set in the current environment so it takes effect immediately + os.environ[env_key] = str(chat_id) + except Exception as e: + return f"Failed to save home channel: {e}" + + return ( + f"✅ Home channel set to **{chat_name}** (ID: {chat_id}).\n" + f"Cron jobs and cross-platform messages will be delivered here." + ) + + @staticmethod + def _get_guild_id(event: MessageEvent) -> Optional[int]: + """Extract Discord guild_id from the raw message object.""" + raw = getattr(event, "raw_message", None) + if raw is None: + return None + # Slash command interaction + if hasattr(raw, "guild_id") and raw.guild_id: + return int(raw.guild_id) + # Regular message + if hasattr(raw, "guild") and raw.guild: + return raw.guild.id + return None + + async def _handle_voice_command(self, event: MessageEvent) -> str: + """Handle /voice [on|off|tts|channel|leave|status] command.""" + args = event.get_command_args().strip().lower() + chat_id = event.source.chat_id + + adapter = self.adapters.get(event.source.platform) + + if args in ("on", "enable"): + self._voice_mode[chat_id] = "voice_only" + self._save_voice_modes() + if adapter: + self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False) + return ( + "Voice mode enabled.\n" + "I'll reply with voice when you send voice messages.\n" + "Use /voice tts to get voice replies for all messages." + ) + elif args in ("off", "disable"): + self._voice_mode[chat_id] = "off" + self._save_voice_modes() + if adapter: + self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True) + return "Voice mode disabled. Text-only replies." + elif args == "tts": + self._voice_mode[chat_id] = "all" + self._save_voice_modes() + if adapter: + self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False) + return ( + "Auto-TTS enabled.\n" + "All replies will include a voice message." + ) + elif args in ("channel", "join"): + return await self._handle_voice_channel_join(event) + elif args == "leave": + return await self._handle_voice_channel_leave(event) + elif args == "status": + mode = self._voice_mode.get(chat_id, "off") + labels = { + "off": "Off (text only)", + "voice_only": "On (voice reply to voice messages)", + "all": "TTS (voice reply to all messages)", + } + # Append voice channel info if connected + adapter = self.adapters.get(event.source.platform) + guild_id = self._get_guild_id(event) + if guild_id and hasattr(adapter, "get_voice_channel_info"): + info = adapter.get_voice_channel_info(guild_id) + if info: + lines = [ + f"Voice mode: {labels.get(mode, mode)}", + f"Voice channel: #{info['channel_name']}", + f"Participants: {info['member_count']}", + ] + for m in info["members"]: + status = " (speaking)" if m.get("is_speaking") else "" + lines.append(f" - {m['display_name']}{status}") + return "\n".join(lines) + return f"Voice mode: {labels.get(mode, mode)}" + else: + # Toggle: off → on, on/all → off + current = self._voice_mode.get(chat_id, "off") + if current == "off": + self._voice_mode[chat_id] = "voice_only" + self._save_voice_modes() + if adapter: + self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False) + return "Voice mode enabled." + else: + self._voice_mode[chat_id] = "off" + self._save_voice_modes() + if adapter: + self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True) + return "Voice mode disabled." + + async def _handle_voice_channel_join(self, event: MessageEvent) -> str: + """Join the user's current Discord voice channel.""" + adapter = self.adapters.get(event.source.platform) + if not hasattr(adapter, "join_voice_channel"): + return "Voice channels are not supported on this platform." + + guild_id = self._get_guild_id(event) + if not guild_id: + return "This command only works in a Discord server." + + voice_channel = await adapter.get_user_voice_channel( + guild_id, event.source.user_id + ) + if not voice_channel: + return "You need to be in a voice channel first." + + # Wire callbacks BEFORE join so voice input arriving immediately + # after connection is not lost. + if hasattr(adapter, "_voice_input_callback"): + adapter._voice_input_callback = self._handle_voice_channel_input + if hasattr(adapter, "_on_voice_disconnect"): + adapter._on_voice_disconnect = self._handle_voice_timeout_cleanup + + try: + success = await adapter.join_voice_channel(voice_channel) + except Exception as e: + logger.warning("Failed to join voice channel: %s", e) + adapter._voice_input_callback = None + err_lower = str(e).lower() + if "pynacl" in err_lower or "nacl" in err_lower or "davey" in err_lower: + return ( + "Voice dependencies are missing (PyNaCl / davey). " + "Install or reinstall Hermes with the messaging extra, e.g. " + "`pip install hermes-agent[messaging]`." + ) + return f"Failed to join voice channel: {e}" + + if success: + adapter._voice_text_channels[guild_id] = int(event.source.chat_id) + if hasattr(adapter, "_voice_sources"): + adapter._voice_sources[guild_id] = event.source.to_dict() + self._voice_mode[event.source.chat_id] = "all" + self._save_voice_modes() + self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=False) + return ( + f"Joined voice channel **{voice_channel.name}**.\n" + f"I'll speak my replies and listen to you. Use /voice leave to disconnect." + ) + # Join failed — clear callback + adapter._voice_input_callback = None + return "Failed to join voice channel. Check bot permissions (Connect + Speak)." + + async def _handle_voice_channel_leave(self, event: MessageEvent) -> str: + """Leave the Discord voice channel.""" + adapter = self.adapters.get(event.source.platform) + guild_id = self._get_guild_id(event) + + if not guild_id or not hasattr(adapter, "leave_voice_channel"): + return "Not in a voice channel." + + if not hasattr(adapter, "is_in_voice_channel") or not adapter.is_in_voice_channel(guild_id): + return "Not in a voice channel." + + try: + await adapter.leave_voice_channel(guild_id) + except Exception as e: + logger.warning("Error leaving voice channel: %s", e) + # Always clean up state even if leave raised an exception + self._voice_mode[event.source.chat_id] = "off" + self._save_voice_modes() + self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=True) + if hasattr(adapter, "_voice_input_callback"): + adapter._voice_input_callback = None + return "Left voice channel." + + def _handle_voice_timeout_cleanup(self, chat_id: str) -> None: + """Called by the adapter when a voice channel times out. + + Cleans up runner-side voice_mode state that the adapter cannot reach. + """ + self._voice_mode[chat_id] = "off" + self._save_voice_modes() + adapter = self.adapters.get(Platform.DISCORD) + self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True) + + async def _handle_voice_channel_input( + self, guild_id: int, user_id: int, transcript: str + ): + """Handle transcribed voice from a user in a voice channel. + + Creates a synthetic MessageEvent and processes it through the + adapter's full message pipeline (session, typing, agent, TTS reply). + """ + adapter = self.adapters.get(Platform.DISCORD) + if not adapter: + return + + text_ch_id = adapter._voice_text_channels.get(guild_id) + if not text_ch_id: + return + + # Build source — reuse the linked text channel's metadata when available + # so voice input shares the same session as the bound text conversation. + source_data = getattr(adapter, "_voice_sources", {}).get(guild_id) + if source_data: + source = SessionSource.from_dict(source_data) + source.user_id = str(user_id) + source.user_name = str(user_id) + else: + source = SessionSource( + platform=Platform.DISCORD, + chat_id=str(text_ch_id), + user_id=str(user_id), + user_name=str(user_id), + chat_type="channel", + ) + + # Check authorization before processing voice input + if not self._is_user_authorized(source): + logger.debug("Unauthorized voice input from user %d, ignoring", user_id) + return + + # Show transcript in text channel (after auth, with mention sanitization) + try: + channel = adapter._client.get_channel(text_ch_id) + if channel: + safe_text = transcript[:2000].replace("@everyone", "@\u200beveryone").replace("@here", "@\u200bhere") + await channel.send(f"**[Voice]** <@{user_id}>: {safe_text}") + except Exception: + pass + + # Build a synthetic MessageEvent and feed through the normal pipeline + # Use SimpleNamespace as raw_message so _get_guild_id() can extract + # guild_id and _send_voice_reply() plays audio in the voice channel. + from types import SimpleNamespace + event = MessageEvent( + source=source, + text=transcript, + message_type=MessageType.VOICE, + raw_message=SimpleNamespace(guild_id=guild_id, guild=None), + ) + + await adapter.handle_message(event) + + def _should_send_voice_reply( + self, + event: MessageEvent, + response: str, + agent_messages: list, + already_sent: bool = False, + ) -> bool: + """Decide whether the runner should send a TTS voice reply. + + Returns False when: + - voice_mode is off for this chat + - response is empty or an error + - agent already called text_to_speech tool (dedup) + - voice input and base adapter auto-TTS already handled it (skip_double) + UNLESS streaming already consumed the response (already_sent=True), + in which case the base adapter won't have text for auto-TTS so the + runner must handle it. + """ + if not response or response.startswith("Error:"): + return False + + chat_id = event.source.chat_id + voice_mode = self._voice_mode.get(chat_id, "off") + is_voice_input = (event.message_type == MessageType.VOICE) + + should = ( + (voice_mode == "all") + or (voice_mode == "voice_only" and is_voice_input) + ) + if not should: + return False + + # Dedup: agent already called TTS tool + has_agent_tts = any( + msg.get("role") == "assistant" + and any( + tc.get("function", {}).get("name") == "text_to_speech" + for tc in (msg.get("tool_calls") or []) + ) + for msg in agent_messages + ) + if has_agent_tts: + return False + + # Dedup: base adapter auto-TTS already handles voice input + # (play_tts plays in VC when connected, so runner can skip). + # When streaming already delivered the text (already_sent=True), + # the base adapter will receive None and can't run auto-TTS, + # so the runner must take over. + if is_voice_input and not already_sent: + return False + + return True + + async def _send_voice_reply(self, event: MessageEvent, text: str) -> None: + """Generate TTS audio and send as a voice message before the text reply.""" + import uuid as _uuid + audio_path = None + actual_path = None + try: + from tools.tts_tool import text_to_speech_tool, _strip_markdown_for_tts + + tts_text = _strip_markdown_for_tts(text[:4000]) + if not tts_text: + return + + # Use .mp3 extension so edge-tts conversion to opus works correctly. + # The TTS tool may convert to .ogg — use file_path from result. + audio_path = os.path.join( + tempfile.gettempdir(), "hermes_voice", + f"tts_reply_{_uuid.uuid4().hex[:12]}.mp3", + ) + os.makedirs(os.path.dirname(audio_path), exist_ok=True) + + result_json = await asyncio.to_thread( + text_to_speech_tool, text=tts_text, output_path=audio_path + ) + result = json.loads(result_json) + + # Use the actual file path from result (may differ after opus conversion) + actual_path = result.get("file_path", audio_path) + if not result.get("success") or not os.path.isfile(actual_path): + logger.warning("Auto voice reply TTS failed: %s", result.get("error")) + return + + adapter = self.adapters.get(event.source.platform) + + # If connected to a voice channel, play there instead of sending a file + guild_id = self._get_guild_id(event) + if (guild_id + and hasattr(adapter, "play_in_voice_channel") + and hasattr(adapter, "is_in_voice_channel") + and adapter.is_in_voice_channel(guild_id)): + await adapter.play_in_voice_channel(guild_id, actual_path) + elif adapter and hasattr(adapter, "send_voice"): + send_kwargs: Dict[str, Any] = { + "chat_id": event.source.chat_id, + "audio_path": actual_path, + "reply_to": event.message_id, + } + if event.source.thread_id: + send_kwargs["metadata"] = {"thread_id": event.source.thread_id} + await adapter.send_voice(**send_kwargs) + except Exception as e: + logger.warning("Auto voice reply failed: %s", e, exc_info=True) + finally: + for p in {audio_path, actual_path} - {None}: + try: + os.unlink(p) + except OSError: + pass + + async def _deliver_media_from_response( + self, + response: str, + event: MessageEvent, + adapter, + ) -> None: + """Extract MEDIA: tags and local file paths from a response and deliver them. + + Called after streaming has already sent the text to the user, so the + text itself is already delivered — this only handles file attachments + that the normal _process_message_background path would have caught. + """ + from pathlib import Path + + try: + media_files, _ = adapter.extract_media(response) + _, cleaned = adapter.extract_images(response) + local_files, _ = adapter.extract_local_files(cleaned) + + _thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None + + _AUDIO_EXTS = {'.ogg', '.opus', '.mp3', '.wav', '.m4a'} + _VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'} + _IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'} + + for media_path, is_voice in media_files: + try: + ext = Path(media_path).suffix.lower() + if ext in _AUDIO_EXTS: + await adapter.send_voice( + chat_id=event.source.chat_id, + audio_path=media_path, + metadata=_thread_meta, + ) + elif ext in _VIDEO_EXTS: + await adapter.send_video( + chat_id=event.source.chat_id, + video_path=media_path, + metadata=_thread_meta, + ) + elif ext in _IMAGE_EXTS: + await adapter.send_image_file( + chat_id=event.source.chat_id, + image_path=media_path, + metadata=_thread_meta, + ) + else: + await adapter.send_document( + chat_id=event.source.chat_id, + file_path=media_path, + metadata=_thread_meta, + ) + except Exception as e: + logger.warning("[%s] Post-stream media delivery failed: %s", adapter.name, e) + + for file_path in local_files: + try: + ext = Path(file_path).suffix.lower() + if ext in _IMAGE_EXTS: + await adapter.send_image_file( + chat_id=event.source.chat_id, + image_path=file_path, + metadata=_thread_meta, + ) + else: + await adapter.send_document( + chat_id=event.source.chat_id, + file_path=file_path, + metadata=_thread_meta, + ) + except Exception as e: + logger.warning("[%s] Post-stream file delivery failed: %s", adapter.name, e) + + except Exception as e: + logger.warning("Post-stream media extraction failed: %s", e) + + async def _handle_rollback_command(self, event: MessageEvent) -> str: + """Handle /rollback command — list or restore filesystem checkpoints.""" + from tools.checkpoint_manager import CheckpointManager, format_checkpoint_list + + # Read checkpoint config from config.yaml + cp_cfg = {} + try: + import yaml as _y + _cfg_path = _hermes_home / "config.yaml" + if _cfg_path.exists(): + with open(_cfg_path, encoding="utf-8") as _f: + _data = _y.safe_load(_f) or {} + cp_cfg = _data.get("checkpoints", {}) + if isinstance(cp_cfg, bool): + cp_cfg = {"enabled": cp_cfg} + except Exception: + pass + + if not cp_cfg.get("enabled", False): + return ( + "Checkpoints are not enabled.\n" + "Enable in config.yaml:\n```\ncheckpoints:\n enabled: true\n```" + ) + + mgr = CheckpointManager( + enabled=True, + max_snapshots=cp_cfg.get("max_snapshots", 50), + ) + + cwd = os.getenv("MESSAGING_CWD", str(Path.home())) + arg = event.get_command_args().strip() + + if not arg: + checkpoints = mgr.list_checkpoints(cwd) + return format_checkpoint_list(checkpoints, cwd) + + # Restore by number or hash + checkpoints = mgr.list_checkpoints(cwd) + if not checkpoints: + return f"No checkpoints found for {cwd}" + + target_hash = None + try: + idx = int(arg) - 1 + if 0 <= idx < len(checkpoints): + target_hash = checkpoints[idx]["hash"] + else: + return f"Invalid checkpoint number. Use 1-{len(checkpoints)}." + except ValueError: + target_hash = arg + + result = mgr.restore(cwd, target_hash) + if result["success"]: + return ( + f"✅ Restored to checkpoint {result['restored_to']}: {result['reason']}\n" + f"A pre-rollback snapshot was saved automatically." + ) + return f"❌ {result['error']}" + + async def _handle_background_command(self, event: MessageEvent) -> str: + """Handle /background <prompt> — run a prompt in a separate background session. + + Spawns a new AIAgent in a background thread with its own session. + When it completes, sends the result back to the same chat without + modifying the active session's conversation history. + """ + prompt = event.get_command_args().strip() + if not prompt: + return ( + "Usage: /background <prompt>\n" + "Example: /background Summarize the top HN stories today\n\n" + "Runs the prompt in a separate session. " + "You can keep chatting — the result will appear here when done." + ) + + source = event.source + task_id = f"bg_{datetime.now().strftime('%H%M%S')}_{os.urandom(3).hex()}" + + # Fire-and-forget the background task + _task = asyncio.create_task( + self._run_background_task(prompt, source, task_id) + ) + self._background_tasks.add(_task) + _task.add_done_callback(self._background_tasks.discard) + + preview = prompt[:60] + ("..." if len(prompt) > 60 else "") + return f'🔄 Background task started: "{preview}"\nTask ID: {task_id}\nYou can keep chatting — results will appear when done.' + + async def _run_background_task( + self, prompt: str, source: "SessionSource", task_id: str + ) -> None: + """Execute a background agent task and deliver the result to the chat.""" + from run_agent import AIAgent + + adapter = self.adapters.get(source.platform) + if not adapter: + logger.warning("No adapter for platform %s in background task %s", source.platform, task_id) + return + + _thread_metadata = {"thread_id": source.thread_id} if source.thread_id else None + + try: + user_config = _load_gateway_config() + model, runtime_kwargs = self._resolve_session_agent_runtime( + source=source, + user_config=user_config, + ) + if not runtime_kwargs.get("api_key"): + await adapter.send( + source.chat_id, + f"❌ Background task {task_id} failed: no provider credentials configured.", + metadata=_thread_metadata, + ) + return + + platform_key = _platform_config_key(source.platform) + + from hermes_cli.tools_config import _get_platform_tools + enabled_toolsets = sorted(_get_platform_tools(user_config, platform_key)) + + pr = self._provider_routing + max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90")) + reasoning_config = self._load_reasoning_config() + self._reasoning_config = reasoning_config + self._service_tier = self._load_service_tier() + turn_route = self._resolve_turn_agent_config(prompt, model, runtime_kwargs) + + def run_sync(): + agent = AIAgent( + model=turn_route["model"], + **turn_route["runtime"], + max_iterations=max_iterations, + quiet_mode=True, + verbose_logging=False, + enabled_toolsets=enabled_toolsets, + reasoning_config=reasoning_config, + service_tier=self._service_tier, + request_overrides=turn_route.get("request_overrides"), + providers_allowed=pr.get("only"), + providers_ignored=pr.get("ignore"), + providers_order=pr.get("order"), + provider_sort=pr.get("sort"), + provider_require_parameters=pr.get("require_parameters", False), + provider_data_collection=pr.get("data_collection"), + session_id=task_id, + platform=platform_key, + user_id=source.user_id, + session_db=self._session_db, + fallback_model=self._fallback_model, + ) + + return agent.run_conversation( + user_message=prompt, + task_id=task_id, + ) + + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(None, run_sync) + + response = result.get("final_response", "") if result else "" + if not response and result and result.get("error"): + response = f"Error: {result['error']}" + + # Extract media files from the response + if response: + media_files, response = adapter.extract_media(response) + images, text_content = adapter.extract_images(response) + + preview = prompt[:60] + ("..." if len(prompt) > 60 else "") + header = f'✅ Background task complete\nPrompt: "{preview}"\n\n' + + if text_content: + await adapter.send( + chat_id=source.chat_id, + content=header + text_content, + metadata=_thread_metadata, + ) + elif not images and not media_files: + await adapter.send( + chat_id=source.chat_id, + content=header + "(No response generated)", + metadata=_thread_metadata, + ) + + # Send extracted images + for image_url, alt_text in (images or []): + try: + await adapter.send_image( + chat_id=source.chat_id, + image_url=image_url, + caption=alt_text, + ) + except Exception: + pass + + # Send media files + for media_path in (media_files or []): + try: + await adapter.send_document( + chat_id=source.chat_id, + file_path=media_path, + ) + except Exception: + pass + else: + preview = prompt[:60] + ("..." if len(prompt) > 60 else "") + await adapter.send( + chat_id=source.chat_id, + content=f'✅ Background task complete\nPrompt: "{preview}"\n\n(No response generated)', + metadata=_thread_metadata, + ) + + except Exception as e: + logger.exception("Background task %s failed", task_id) + try: + await adapter.send( + chat_id=source.chat_id, + content=f"❌ Background task {task_id} failed: {e}", + metadata=_thread_metadata, + ) + except Exception: + pass + + async def _handle_btw_command(self, event: MessageEvent) -> str: + """Handle /btw <question> — ephemeral side question in the same chat.""" + question = event.get_command_args().strip() + if not question: + return ( + "Usage: /btw <question>\n" + "Example: /btw what module owns session title sanitization?\n\n" + "Answers using session context. No tools, not persisted." + ) + + source = event.source + session_key = self._session_key_for_source(source) + + # Guard: one /btw at a time per session + existing = getattr(self, "_active_btw_tasks", {}).get(session_key) + if existing and not existing.done(): + return "A /btw is already running for this chat. Wait for it to finish." + + if not hasattr(self, "_active_btw_tasks"): + self._active_btw_tasks: dict = {} + + import uuid as _uuid + task_id = f"btw_{datetime.now().strftime('%H%M%S')}_{_uuid.uuid4().hex[:6]}" + _task = asyncio.create_task(self._run_btw_task(question, source, session_key, task_id)) + self._background_tasks.add(_task) + self._active_btw_tasks[session_key] = _task + + def _cleanup(task): + self._background_tasks.discard(task) + if self._active_btw_tasks.get(session_key) is task: + self._active_btw_tasks.pop(session_key, None) + + _task.add_done_callback(_cleanup) + + preview = question[:60] + ("..." if len(question) > 60 else "") + return f'💬 /btw: "{preview}"\nReply will appear here shortly.' + + async def _run_btw_task( + self, question: str, source, session_key: str, task_id: str, + ) -> None: + """Execute an ephemeral /btw side question and deliver the answer.""" + from run_agent import AIAgent + + adapter = self.adapters.get(source.platform) + if not adapter: + logger.warning("No adapter for platform %s in /btw task %s", source.platform, task_id) + return + + _thread_meta = {"thread_id": source.thread_id} if source.thread_id else None + + try: + user_config = _load_gateway_config() + model, runtime_kwargs = self._resolve_session_agent_runtime( + source=source, + session_key=session_key, + user_config=user_config, + ) + if not runtime_kwargs.get("api_key"): + await adapter.send( + source.chat_id, + "❌ /btw failed: no provider credentials configured.", + metadata=_thread_meta, + ) + return + + platform_key = _platform_config_key(source.platform) + reasoning_config = self._load_reasoning_config() + self._service_tier = self._load_service_tier() + turn_route = self._resolve_turn_agent_config(question, model, runtime_kwargs) + pr = self._provider_routing + + # Snapshot history from running agent or stored transcript + running_agent = self._running_agents.get(session_key) + if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: + history_snapshot = list(getattr(running_agent, "_session_messages", []) or []) + else: + session_entry = self.session_store.get_or_create_session(source) + history_snapshot = self.session_store.load_transcript(session_entry.session_id) + + btw_prompt = ( + "[Ephemeral /btw side question. Answer using the conversation " + "context. No tools available. Be direct and concise.]\n\n" + + question + ) + + def run_sync(): + agent = AIAgent( + model=turn_route["model"], + **turn_route["runtime"], + max_iterations=8, + quiet_mode=True, + verbose_logging=False, + enabled_toolsets=[], + reasoning_config=reasoning_config, + service_tier=self._service_tier, + request_overrides=turn_route.get("request_overrides"), + providers_allowed=pr.get("only"), + providers_ignored=pr.get("ignore"), + providers_order=pr.get("order"), + provider_sort=pr.get("sort"), + provider_require_parameters=pr.get("require_parameters", False), + provider_data_collection=pr.get("data_collection"), + session_id=task_id, + platform=platform_key, + session_db=None, + fallback_model=self._fallback_model, + skip_memory=True, + skip_context_files=True, + persist_session=False, + ) + return agent.run_conversation( + user_message=btw_prompt, + conversation_history=history_snapshot, + task_id=task_id, + ) + + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(None, run_sync) + + response = (result.get("final_response") or "") if result else "" + if not response and result and result.get("error"): + response = f"Error: {result['error']}" + if not response: + response = "(No response generated)" + + media_files, response = adapter.extract_media(response) + images, text_content = adapter.extract_images(response) + preview = question[:60] + ("..." if len(question) > 60 else "") + header = f'💬 /btw: "{preview}"\n\n' + + if text_content: + await adapter.send( + chat_id=source.chat_id, + content=header + text_content, + metadata=_thread_meta, + ) + elif not images and not media_files: + await adapter.send( + chat_id=source.chat_id, + content=header + "(No response generated)", + metadata=_thread_meta, + ) + + for image_url, alt_text in (images or []): + try: + await adapter.send_image(chat_id=source.chat_id, image_url=image_url, caption=alt_text) + except Exception: + pass + + for media_path in (media_files or []): + try: + await adapter.send_file(chat_id=source.chat_id, file_path=media_path) + except Exception: + pass + + except Exception as e: + logger.exception("/btw task %s failed", task_id) + try: + await adapter.send( + chat_id=source.chat_id, + content=f"❌ /btw failed: {e}", + metadata=_thread_meta, + ) + except Exception: + pass + + async def _handle_reasoning_command(self, event: MessageEvent) -> str: + """Handle /reasoning command — manage reasoning effort and display toggle. + + Usage: + /reasoning Show current effort level and display state + /reasoning <level> Set reasoning effort (none, minimal, low, medium, high, xhigh) + /reasoning show|on Show model reasoning in responses + /reasoning hide|off Hide model reasoning from responses + """ + import yaml + + args = event.get_command_args().strip().lower() + config_path = _hermes_home / "config.yaml" + self._reasoning_config = self._load_reasoning_config() + self._show_reasoning = self._load_show_reasoning() + + def _save_config_key(key_path: str, value): + """Save a dot-separated key to config.yaml.""" + try: + user_config = {} + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + user_config = yaml.safe_load(f) or {} + keys = key_path.split(".") + current = user_config + for k in keys[:-1]: + if k not in current or not isinstance(current[k], dict): + current[k] = {} + current = current[k] + current[keys[-1]] = value + atomic_yaml_write(config_path, user_config) + return True + except Exception as e: + logger.error("Failed to save config key %s: %s", key_path, e) + return False + + if not args: + # Show current state + rc = self._reasoning_config + if rc is None: + level = "medium (default)" + elif rc.get("enabled") is False: + level = "none (disabled)" + else: + level = rc.get("effort", "medium") + display_state = "on ✓" if self._show_reasoning else "off" + return ( + "🧠 **Reasoning Settings**\n\n" + f"**Effort:** `{level}`\n" + f"**Display:** {display_state}\n\n" + "_Usage:_ `/reasoning <none|minimal|low|medium|high|xhigh|show|hide>`" + ) + + # Display toggle (per-platform) + platform_key = _platform_config_key(event.source.platform) + if args in ("show", "on"): + self._show_reasoning = True + _save_config_key(f"display.platforms.{platform_key}.show_reasoning", True) + return ( + "🧠 ✓ Reasoning display: **ON**\n" + f"Model thinking will be shown before each response on **{platform_key}**." + ) + + if args in ("hide", "off"): + self._show_reasoning = False + _save_config_key(f"display.platforms.{platform_key}.show_reasoning", False) + return f"🧠 ✓ Reasoning display: **OFF** for **{platform_key}**" + + # Effort level change + effort = args.strip() + if effort == "none": + parsed = {"enabled": False} + elif effort in ("minimal", "low", "medium", "high", "xhigh"): + parsed = {"enabled": True, "effort": effort} + else: + return ( + f"⚠️ Unknown argument: `{effort}`\n\n" + "**Valid levels:** none, minimal, low, medium, high, xhigh\n" + "**Display:** show, hide" + ) + + self._reasoning_config = parsed + if _save_config_key("agent.reasoning_effort", effort): + return f"🧠 ✓ Reasoning effort set to `{effort}` (saved to config)\n_(takes effect on next message)_" + else: + return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)" + + async def _handle_fast_command(self, event: MessageEvent) -> str: + """Handle /fast — mirror the CLI Priority Processing toggle in gateway chats.""" + import yaml + from hermes_cli.models import model_supports_fast_mode + + args = event.get_command_args().strip().lower() + config_path = _hermes_home / "config.yaml" + self._service_tier = self._load_service_tier() + + user_config = _load_gateway_config() + model = _resolve_gateway_model(user_config) + if not model_supports_fast_mode(model): + return "⚡ /fast is only available for OpenAI models that support Priority Processing." + + def _save_config_key(key_path: str, value): + """Save a dot-separated key to config.yaml.""" + try: + user_config = {} + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + user_config = yaml.safe_load(f) or {} + keys = key_path.split(".") + current = user_config + for k in keys[:-1]: + if k not in current or not isinstance(current[k], dict): + current[k] = {} + current = current[k] + current[keys[-1]] = value + atomic_yaml_write(config_path, user_config) + return True + except Exception as e: + logger.error("Failed to save config key %s: %s", key_path, e) + return False + + if not args or args == "status": + status = "fast" if self._service_tier == "priority" else "normal" + return ( + "⚡ Priority Processing\n\n" + f"Current mode: `{status}`\n\n" + "_Usage:_ `/fast <normal|fast|status>`" + ) + + if args in {"fast", "on"}: + self._service_tier = "priority" + saved_value = "fast" + label = "FAST" + elif args in {"normal", "off"}: + self._service_tier = None + saved_value = "normal" + label = "NORMAL" + else: + return ( + f"⚠️ Unknown argument: `{args}`\n\n" + "**Valid options:** normal, fast, status" + ) + + if _save_config_key("agent.service_tier", saved_value): + return f"⚡ ✓ Priority Processing: **{label}** (saved to config)\n_(takes effect on next message)_" + return f"⚡ ✓ Priority Processing: **{label}** (this session only)" + + async def _handle_yolo_command(self, event: MessageEvent) -> str: + """Handle /yolo — toggle dangerous command approval bypass for this session only.""" + from tools.approval import ( + disable_session_yolo, + enable_session_yolo, + is_session_yolo_enabled, + ) + + session_key = self._session_key_for_source(event.source) + current = is_session_yolo_enabled(session_key) + if current: + disable_session_yolo(session_key) + return "⚠️ YOLO mode **OFF** for this session — dangerous commands will require approval." + else: + enable_session_yolo(session_key) + return "⚡ YOLO mode **ON** for this session — all commands auto-approved. Use with caution." + + async def _handle_verbose_command(self, event: MessageEvent) -> str: + """Handle /verbose command — cycle tool progress display mode. + + Gated by ``display.tool_progress_command`` in config.yaml (default off). + When enabled, cycles the tool progress mode through off → new → all → + verbose → off for the *current platform*. The setting is saved to + ``display.platforms.<platform>.tool_progress`` so each channel can + have its own verbosity level independently. + """ + import yaml + + config_path = _hermes_home / "config.yaml" + platform_key = _platform_config_key(event.source.platform) + + # --- check config gate ------------------------------------------------ + try: + user_config = {} + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + user_config = yaml.safe_load(f) or {} + gate_enabled = user_config.get("display", {}).get("tool_progress_command", False) + except Exception: + gate_enabled = False + + if not gate_enabled: + return ( + "The `/verbose` command is not enabled for messaging platforms.\n\n" + "Enable it in `config.yaml`:\n```yaml\n" + "display:\n tool_progress_command: true\n```" + ) + + # --- cycle mode (per-platform) ---------------------------------------- + cycle = ["off", "new", "all", "verbose"] + descriptions = { + "off": "⚙️ Tool progress: **OFF** — no tool activity shown.", + "new": "⚙️ Tool progress: **NEW** — shown when tool changes (preview length: `display.tool_preview_length`, default 40).", + "all": "⚙️ Tool progress: **ALL** — every tool call shown (preview length: `display.tool_preview_length`, default 40).", + "verbose": "⚙️ Tool progress: **VERBOSE** — every tool call with full arguments.", + } + + # Read current effective mode for this platform via the resolver + from gateway.display_config import resolve_display_setting + current = resolve_display_setting(user_config, platform_key, "tool_progress", "all") + if current not in cycle: + current = "all" + idx = (cycle.index(current) + 1) % len(cycle) + new_mode = cycle[idx] + + # Save to display.platforms.<platform>.tool_progress + try: + if "display" not in user_config or not isinstance(user_config.get("display"), dict): + user_config["display"] = {} + display = user_config["display"] + if "platforms" not in display or not isinstance(display.get("platforms"), dict): + display["platforms"] = {} + if platform_key not in display["platforms"] or not isinstance(display["platforms"].get(platform_key), dict): + display["platforms"][platform_key] = {} + display["platforms"][platform_key]["tool_progress"] = new_mode + atomic_yaml_write(config_path, user_config) + return ( + f"{descriptions[new_mode]}\n" + f"_(saved for **{platform_key}** — takes effect on next message)_" + ) + except Exception as e: + logger.warning("Failed to save tool_progress mode: %s", e) + return f"{descriptions[new_mode]}\n_(could not save to config: {e})_" + + async def _handle_compress_command(self, event: MessageEvent) -> str: + """Handle /compress command -- manually compress conversation context. + + Accepts an optional focus topic: ``/compress <focus>`` guides the + summariser to preserve information related to *focus* while being + more aggressive about discarding everything else. + """ + source = event.source + session_entry = self.session_store.get_or_create_session(source) + history = self.session_store.load_transcript(session_entry.session_id) + + if not history or len(history) < 4: + return "Not enough conversation to compress (need at least 4 messages)." + + # Extract optional focus topic from command args + focus_topic = (event.get_command_args() or "").strip() or None + + try: + from run_agent import AIAgent + from agent.manual_compression_feedback import summarize_manual_compression + from agent.model_metadata import estimate_messages_tokens_rough + + session_key = self._session_key_for_source(source) + model, runtime_kwargs = self._resolve_session_agent_runtime( + source=source, + session_key=session_key, + ) + if not runtime_kwargs.get("api_key"): + return "No provider configured -- cannot compress." + + msgs = [ + {"role": m.get("role"), "content": m.get("content")} + for m in history + if m.get("role") in ("user", "assistant") and m.get("content") + ] + original_count = len(msgs) + approx_tokens = estimate_messages_tokens_rough(msgs) + + tmp_agent = AIAgent( + **runtime_kwargs, + model=model, + max_iterations=4, + quiet_mode=True, + enabled_toolsets=["memory"], + session_id=session_entry.session_id, + ) + tmp_agent._print_fn = lambda *a, **kw: None + + compressor = tmp_agent.context_compressor + compress_start = compressor.protect_first_n + compress_start = compressor._align_boundary_forward(msgs, compress_start) + compress_end = compressor._find_tail_cut_by_tokens(msgs, compress_start) + if compress_start >= compress_end: + return "Nothing to compress yet (the transcript is still all protected context)." + + loop = asyncio.get_event_loop() + compressed, _ = await loop.run_in_executor( + None, + lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens, focus_topic=focus_topic) + ) + + # _compress_context already calls end_session() on the old session + # (preserving its full transcript in SQLite) and creates a new + # session_id for the continuation. Write the compressed messages + # into the NEW session so the original history stays searchable. + new_session_id = tmp_agent.session_id + if new_session_id != session_entry.session_id: + session_entry.session_id = new_session_id + self.session_store._save() + + self.session_store.rewrite_transcript(new_session_id, compressed) + # Reset stored token count — transcript changed, old value is stale + self.session_store.update_session( + session_entry.session_key, last_prompt_tokens=0 + ) + new_tokens = estimate_messages_tokens_rough(compressed) + summary = summarize_manual_compression( + msgs, + compressed, + approx_tokens, + new_tokens, + ) + lines = [f"🗜️ {summary['headline']}"] + if focus_topic: + lines.append(f"Focus: \"{focus_topic}\"") + lines.append(summary["token_line"]) + if summary["note"]: + lines.append(summary["note"]) + return "\n".join(lines) + except Exception as e: + logger.warning("Manual compress failed: %s", e) + return f"Compression failed: {e}" + + async def _handle_title_command(self, event: MessageEvent) -> str: + """Handle /title command — set or show the current session's title.""" + source = event.source + session_entry = self.session_store.get_or_create_session(source) + session_id = session_entry.session_id + + if not self._session_db: + return "Session database not available." + + # Ensure session exists in SQLite DB (it may only exist in session_store + # if this is the first command in a new session) + existing_title = self._session_db.get_session_title(session_id) + if existing_title is None: + # Session doesn't exist in DB yet — create it + try: + self._session_db.create_session( + session_id=session_id, + source=source.platform.value if source.platform else "unknown", + user_id=source.user_id, + ) + except Exception: + pass # Session might already exist, ignore errors + + title_arg = event.get_command_args().strip() + if title_arg: + # Sanitize the title before setting + try: + sanitized = self._session_db.sanitize_title(title_arg) + except ValueError as e: + return f"⚠️ {e}" + if not sanitized: + return "⚠️ Title is empty after cleanup. Please use printable characters." + # Set the title + try: + if self._session_db.set_session_title(session_id, sanitized): + return f"✏️ Session title set: **{sanitized}**" + else: + return "Session not found in database." + except ValueError as e: + return f"⚠️ {e}" + else: + # Show the current title and session ID + title = self._session_db.get_session_title(session_id) + if title: + return f"📌 Session: `{session_id}`\nTitle: **{title}**" + else: + return f"📌 Session: `{session_id}`\nNo title set. Usage: `/title My Session Name`" + + async def _handle_resume_command(self, event: MessageEvent) -> str: + """Handle /resume command — switch to a previously-named session.""" + if not self._session_db: + return "Session database not available." + + source = event.source + session_key = self._session_key_for_source(source) + name = event.get_command_args().strip() + + if not name: + # List recent titled sessions for this user/platform + try: + user_source = source.platform.value if source.platform else None + sessions = self._session_db.list_sessions_rich( + source=user_source, limit=10 + ) + titled = [s for s in sessions if s.get("title")] + if not titled: + return ( + "No named sessions found.\n" + "Use `/title My Session` to name your current session, " + "then `/resume My Session` to return to it later." + ) + lines = ["📋 **Named Sessions**\n"] + for s in titled[:10]: + title = s["title"] + preview = s.get("preview", "")[:40] + preview_part = f" — _{preview}_" if preview else "" + lines.append(f"• **{title}**{preview_part}") + lines.append("\nUsage: `/resume <session name>`") + return "\n".join(lines) + except Exception as e: + logger.debug("Failed to list titled sessions: %s", e) + return f"Could not list sessions: {e}" + + # Resolve the name to a session ID + target_id = self._session_db.resolve_session_by_title(name) + if not target_id: + return ( + f"No session found matching '**{name}**'.\n" + "Use `/resume` with no arguments to see available sessions." + ) + + # Check if already on that session + current_entry = self.session_store.get_or_create_session(source) + if current_entry.session_id == target_id: + return f"📌 Already on session **{name}**." + + # Flush memories for current session before switching + try: + _flush_task = asyncio.create_task( + self._async_flush_memories(current_entry.session_id, session_key) + ) + self._background_tasks.add(_flush_task) + _flush_task.add_done_callback(self._background_tasks.discard) + except Exception as e: + logger.debug("Memory flush on resume failed: %s", e) + + # Clear any running agent for this session key + if session_key in self._running_agents: + del self._running_agents[session_key] + + # Switch the session entry to point at the old session + new_entry = self.session_store.switch_session(session_key, target_id) + if not new_entry: + return "Failed to switch session." + + # Get the title for confirmation + title = self._session_db.get_session_title(target_id) or name + + # Count messages for context + history = self.session_store.load_transcript(target_id) + msg_count = len([m for m in history if m.get("role") == "user"]) if history else 0 + msg_part = f" ({msg_count} message{'s' if msg_count != 1 else ''})" if msg_count else "" + + return f"↻ Resumed session **{title}**{msg_part}. Conversation restored." + + async def _handle_branch_command(self, event: MessageEvent) -> str: + """Handle /branch [name] — fork the current session into a new independent copy. + + Copies conversation history to a new session so the user can explore + a different approach without losing the original. + Inspired by Claude Code's /branch command. + """ + import uuid as _uuid + + if not self._session_db: + return "Session database not available." + + source = event.source + session_key = self._session_key_for_source(source) + + # Load the current session and its transcript + current_entry = self.session_store.get_or_create_session(source) + history = self.session_store.load_transcript(current_entry.session_id) + if not history: + return "No conversation to branch — send a message first." + + branch_name = event.get_command_args().strip() + + # Generate the new session ID + from datetime import datetime as _dt + now = _dt.now() + timestamp_str = now.strftime("%Y%m%d_%H%M%S") + short_uuid = _uuid.uuid4().hex[:6] + new_session_id = f"{timestamp_str}_{short_uuid}" + + # Determine branch title + if branch_name: + branch_title = branch_name + else: + current_title = self._session_db.get_session_title(current_entry.session_id) + base = current_title or "branch" + branch_title = self._session_db.get_next_title_in_lineage(base) + + parent_session_id = current_entry.session_id + + # Create the new session with parent link + try: + self._session_db.create_session( + session_id=new_session_id, + source=source.platform.value if source.platform else "gateway", + model=(self.config.get("model", {}) or {}).get("default") if isinstance(self.config, dict) else None, + parent_session_id=parent_session_id, + ) + except Exception as e: + logger.error("Failed to create branch session: %s", e) + return f"Failed to create branch: {e}" + + # Copy conversation history to the new session + for msg in history: + try: + self._session_db.append_message( + session_id=new_session_id, + role=msg.get("role", "user"), + content=msg.get("content"), + tool_name=msg.get("tool_name") or msg.get("name"), + tool_calls=msg.get("tool_calls"), + tool_call_id=msg.get("tool_call_id"), + reasoning=msg.get("reasoning"), + ) + except Exception: + pass # Best-effort copy + + # Set title + try: + self._session_db.set_session_title(new_session_id, branch_title) + except Exception: + pass + + # Switch the session store entry to the new session + new_entry = self.session_store.switch_session(session_key, new_session_id) + if not new_entry: + return "Branch created but failed to switch to it." + + # Evict any cached agent for this session + self._evict_cached_agent(session_key) + + msg_count = len([m for m in history if m.get("role") == "user"]) + return ( + f"⑂ Branched to **{branch_title}**" + f" ({msg_count} message{'s' if msg_count != 1 else ''} copied)\n" + f"Original: `{parent_session_id}`\n" + f"Branch: `{new_session_id}`\n" + f"Use `/resume` to switch back to the original." + ) + + async def _handle_usage_command(self, event: MessageEvent) -> str: + """Handle /usage command -- show token usage for the current session. + + Checks both _running_agents (mid-turn) and _agent_cache (between turns) + so that rate limits, cost estimates, and detailed token breakdowns are + available whenever the user asks, not only while the agent is running. + """ + source = event.source + session_key = self._session_key_for_source(source) + + # Try running agent first (mid-turn), then cached agent (between turns) + agent = self._running_agents.get(session_key) + if not agent or agent is _AGENT_PENDING_SENTINEL: + _cache_lock = getattr(self, "_agent_cache_lock", None) + _cache = getattr(self, "_agent_cache", None) + if _cache_lock and _cache is not None: + with _cache_lock: + cached = _cache.get(session_key) + if cached: + agent = cached[0] + + if agent and hasattr(agent, "session_total_tokens") and agent.session_api_calls > 0: + lines = [] + + # Rate limits (when available from provider headers) + rl_state = agent.get_rate_limit_state() + if rl_state and rl_state.has_data: + from agent.rate_limit_tracker import format_rate_limit_compact + lines.append(f"⏱️ **Rate Limits:** {format_rate_limit_compact(rl_state)}") + lines.append("") + + # Session token usage — detailed breakdown matching CLI + input_tokens = getattr(agent, "session_input_tokens", 0) or 0 + output_tokens = getattr(agent, "session_output_tokens", 0) or 0 + cache_read = getattr(agent, "session_cache_read_tokens", 0) or 0 + cache_write = getattr(agent, "session_cache_write_tokens", 0) or 0 + + lines.append("📊 **Session Token Usage**") + lines.append(f"Model: `{agent.model}`") + lines.append(f"Input tokens: {input_tokens:,}") + if cache_read: + lines.append(f"Cache read tokens: {cache_read:,}") + if cache_write: + lines.append(f"Cache write tokens: {cache_write:,}") + lines.append(f"Output tokens: {output_tokens:,}") + lines.append(f"Total: {agent.session_total_tokens:,}") + lines.append(f"API calls: {agent.session_api_calls}") + + # Cost estimation + try: + from agent.usage_pricing import CanonicalUsage, estimate_usage_cost + cost_result = estimate_usage_cost( + agent.model, + CanonicalUsage( + input_tokens=input_tokens, + output_tokens=output_tokens, + cache_read_tokens=cache_read, + cache_write_tokens=cache_write, + ), + provider=getattr(agent, "provider", None), + base_url=getattr(agent, "base_url", None), + ) + if cost_result.amount_usd is not None: + prefix = "~" if cost_result.status == "estimated" else "" + lines.append(f"Cost: {prefix}${float(cost_result.amount_usd):.4f}") + elif cost_result.status == "included": + lines.append("Cost: included") + except Exception: + pass + + # Context window and compressions + ctx = agent.context_compressor + if ctx.last_prompt_tokens: + pct = min(100, ctx.last_prompt_tokens / ctx.context_length * 100) if ctx.context_length else 0 + lines.append(f"Context: {ctx.last_prompt_tokens:,} / {ctx.context_length:,} ({pct:.0f}%)") + if ctx.compression_count: + lines.append(f"Compressions: {ctx.compression_count}") + + return "\n".join(lines) + + # No agent at all -- check session history for a rough count + session_entry = self.session_store.get_or_create_session(source) + history = self.session_store.load_transcript(session_entry.session_id) + if history: + from agent.model_metadata import estimate_messages_tokens_rough + msgs = [m for m in history if m.get("role") in ("user", "assistant") and m.get("content")] + approx = estimate_messages_tokens_rough(msgs) + return ( + f"📊 **Session Info**\n" + f"Messages: {len(msgs)}\n" + f"Estimated context: ~{approx:,} tokens\n" + f"_(Detailed usage available after the first agent response)_" + ) + return "No usage data available for this session." + + async def _handle_insights_command(self, event: MessageEvent) -> str: + """Handle /insights command -- show usage insights and analytics.""" + import asyncio as _asyncio + + args = event.get_command_args().strip() + days = 30 + source = None + + # Parse simple args: /insights 7 or /insights --days 7 + if args: + parts = args.split() + i = 0 + while i < len(parts): + if parts[i] == "--days" and i + 1 < len(parts): + try: + days = int(parts[i + 1]) + except ValueError: + return f"Invalid --days value: {parts[i + 1]}" + i += 2 + elif parts[i] == "--source" and i + 1 < len(parts): + source = parts[i + 1] + i += 2 + elif parts[i].isdigit(): + days = int(parts[i]) + i += 1 + else: + i += 1 + + try: + from hermes_state import SessionDB + from agent.insights import InsightsEngine + + loop = _asyncio.get_event_loop() + + def _run_insights(): + db = SessionDB() + engine = InsightsEngine(db) + report = engine.generate(days=days, source=source) + result = engine.format_gateway(report) + db.close() + return result + + return await loop.run_in_executor(None, _run_insights) + except Exception as e: + logger.error("Insights command error: %s", e, exc_info=True) + return f"Error generating insights: {e}" + + async def _handle_reload_mcp_command(self, event: MessageEvent) -> str: + """Handle /reload-mcp command -- disconnect and reconnect all MCP servers.""" + loop = asyncio.get_event_loop() + try: + from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _servers, _lock + + # Capture old server names before shutdown + with _lock: + old_servers = set(_servers.keys()) + + # Read new config before shutting down, so we know what will be added/removed + # Shutdown existing connections + await loop.run_in_executor(None, shutdown_mcp_servers) + + # Reconnect by discovering tools (reads config.yaml fresh) + new_tools = await loop.run_in_executor(None, discover_mcp_tools) + + # Compute what changed + with _lock: + connected_servers = set(_servers.keys()) + + added = connected_servers - old_servers + removed = old_servers - connected_servers + reconnected = connected_servers & old_servers + + lines = ["🔄 **MCP Servers Reloaded**\n"] + if reconnected: + lines.append(f"♻️ Reconnected: {', '.join(sorted(reconnected))}") + if added: + lines.append(f"➕ Added: {', '.join(sorted(added))}") + if removed: + lines.append(f"➖ Removed: {', '.join(sorted(removed))}") + if not connected_servers: + lines.append("No MCP servers connected.") + else: + lines.append(f"\n🔧 {len(new_tools)} tool(s) available from {len(connected_servers)} server(s)") + + # Inject a message at the END of the session history so the + # model knows tools changed on its next turn. Appended after + # all existing messages to preserve prompt-cache for the prefix. + change_parts = [] + if added: + change_parts.append(f"Added servers: {', '.join(sorted(added))}") + if removed: + change_parts.append(f"Removed servers: {', '.join(sorted(removed))}") + if reconnected: + change_parts.append(f"Reconnected servers: {', '.join(sorted(reconnected))}") + tool_summary = f"{len(new_tools)} MCP tool(s) now available" if new_tools else "No MCP tools available" + change_detail = ". ".join(change_parts) + ". " if change_parts else "" + reload_msg = { + "role": "user", + "content": f"[SYSTEM: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]", + } + try: + session_entry = self.session_store.get_or_create_session(event.source) + self.session_store.append_to_transcript( + session_entry.session_id, reload_msg + ) + except Exception: + pass # Best-effort; don't fail the reload over a transcript write + + return "\n".join(lines) + + except Exception as e: + logger.warning("MCP reload failed: %s", e) + return f"❌ MCP reload failed: {e}" + + # ------------------------------------------------------------------ + # /approve & /deny — explicit dangerous-command approval + # ------------------------------------------------------------------ + + _APPROVAL_TIMEOUT_SECONDS = 300 # 5 minutes + + async def _handle_approve_command(self, event: MessageEvent) -> Optional[str]: + """Handle /approve command — unblock waiting agent thread(s). + + The agent thread(s) are blocked inside tools/approval.py waiting for + the user to respond. This handler signals the event so the agent + resumes and the terminal_tool executes the command inline — the same + flow as the CLI's synchronous input() approval. + + Supports multiple concurrent approvals (parallel subagents, + execute_code). ``/approve`` resolves the oldest pending command; + ``/approve all`` resolves every pending command at once. + + Usage: + /approve — approve oldest pending command once + /approve all — approve ALL pending commands at once + /approve session — approve oldest + remember for session + /approve all session — approve all + remember for session + /approve always — approve oldest + remember permanently + /approve all always — approve all + remember permanently + """ + source = event.source + session_key = self._session_key_for_source(source) + + from tools.approval import ( + resolve_gateway_approval, has_blocking_approval, + ) + + if not has_blocking_approval(session_key): + if session_key in self._pending_approvals: + self._pending_approvals.pop(session_key) + return "⚠️ Approval expired (agent is no longer waiting). Ask the agent to try again." + return "No pending command to approve." + + # Parse args: support "all", "all session", "all always", "session", "always" + args = event.get_command_args().strip().lower().split() + resolve_all = "all" in args + remaining = [a for a in args if a != "all"] + + if any(a in ("always", "permanent", "permanently") for a in remaining): + choice = "always" + scope_msg = " (pattern approved permanently)" + elif any(a in ("session", "ses") for a in remaining): + choice = "session" + scope_msg = " (pattern approved for this session)" + else: + choice = "once" + scope_msg = "" + + count = resolve_gateway_approval(session_key, choice, resolve_all=resolve_all) + if not count: + return "No pending command to approve." + + # Resume typing indicator — agent is about to continue processing. + _adapter = self.adapters.get(source.platform) + if _adapter: + _adapter.resume_typing_for_chat(source.chat_id) + + count_msg = f" ({count} commands)" if count > 1 else "" + logger.info("User approved %d dangerous command(s) via /approve%s", count, scope_msg) + return f"✅ Command{'s' if count > 1 else ''} approved{scope_msg}{count_msg}. The agent is resuming..." + + async def _handle_deny_command(self, event: MessageEvent) -> str: + """Handle /deny command — reject pending dangerous command(s). + + Signals blocked agent thread(s) with a 'deny' result so they receive + a definitive BLOCKED message, same as the CLI deny flow. + + ``/deny`` denies the oldest; ``/deny all`` denies everything. + """ + source = event.source + session_key = self._session_key_for_source(source) + + from tools.approval import ( + resolve_gateway_approval, has_blocking_approval, + ) + + if not has_blocking_approval(session_key): + if session_key in self._pending_approvals: + self._pending_approvals.pop(session_key) + return "❌ Command denied (approval was stale)." + return "No pending command to deny." + + args = event.get_command_args().strip().lower() + resolve_all = "all" in args + + count = resolve_gateway_approval(session_key, "deny", resolve_all=resolve_all) + if not count: + return "No pending command to deny." + + # Resume typing indicator — agent continues (with BLOCKED result). + _adapter = self.adapters.get(source.platform) + if _adapter: + _adapter.resume_typing_for_chat(source.chat_id) + + count_msg = f" ({count} commands)" if count > 1 else "" + logger.info("User denied %d dangerous command(s) via /deny", count) + return f"❌ Command{'s' if count > 1 else ''} denied{count_msg}." + + # Platforms where /update is allowed. ACP, API server, and webhooks are + # programmatic interfaces that should not trigger system updates. + _UPDATE_ALLOWED_PLATFORMS = frozenset({ + Platform.TELEGRAM, Platform.DISCORD, Platform.SLACK, Platform.WHATSAPP, + Platform.SIGNAL, Platform.MATTERMOST, Platform.MATRIX, + Platform.HOMEASSISTANT, Platform.EMAIL, Platform.SMS, Platform.DINGTALK, + Platform.FEISHU, Platform.WECOM, Platform.WECOM_CALLBACK, Platform.WEIXIN, Platform.BLUEBUBBLES, Platform.QQBOT, Platform.LOCAL, + }) + + async def _handle_debug_command(self, event: MessageEvent) -> str: + """Handle /debug — upload debug report + logs and return paste URLs.""" + import asyncio + from hermes_cli.debug import ( + _capture_dump, collect_debug_report, _read_full_log, + upload_to_pastebin, + ) + + loop = asyncio.get_running_loop() + + # Run blocking I/O (dump capture, log reads, uploads) in a thread. + def _collect_and_upload(): + dump_text = _capture_dump() + report = collect_debug_report(log_lines=200, dump_text=dump_text) + agent_log = _read_full_log("agent") + gateway_log = _read_full_log("gateway") + + if agent_log: + agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log + if gateway_log: + gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log + + urls = {} + failures = [] + + try: + urls["Report"] = upload_to_pastebin(report) + except Exception as exc: + return f"✗ Failed to upload debug report: {exc}" + + if agent_log: + try: + urls["agent.log"] = upload_to_pastebin(agent_log) + except Exception: + failures.append("agent.log") + + if gateway_log: + try: + urls["gateway.log"] = upload_to_pastebin(gateway_log) + except Exception: + failures.append("gateway.log") + + lines = ["**Debug report uploaded:**", ""] + label_width = max(len(k) for k in urls) + for label, url in urls.items(): + lines.append(f"`{label:<{label_width}}` {url}") + + if failures: + lines.append(f"\n_(failed to upload: {', '.join(failures)})_") + + lines.append("\nShare these links with the Hermes team for support.") + return "\n".join(lines) + + return await loop.run_in_executor(None, _collect_and_upload) + + async def _handle_update_command(self, event: MessageEvent) -> str: + """Handle /update command — update Hermes Agent to the latest version. + + Spawns ``hermes update`` in a detached session (via ``setsid``) so it + survives the gateway restart that ``hermes update`` may trigger. Marker + files are written so either the current gateway process or the next one + can notify the user when the update finishes. + """ + import json + import shutil + import subprocess + from datetime import datetime + from hermes_cli.config import is_managed, format_managed_message + + # Block non-messaging platforms (API server, webhooks, ACP) + platform = event.source.platform + if platform not in self._UPDATE_ALLOWED_PLATFORMS: + return "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal." + + if is_managed(): + return f"✗ {format_managed_message('update Hermes Agent')}" + + project_root = Path(__file__).parent.parent.resolve() + git_dir = project_root / '.git' + + if not git_dir.exists(): + return "✗ Not a git repository — cannot update." + + hermes_cmd = _resolve_hermes_bin() + if not hermes_cmd: + return ( + "✗ Could not locate the `hermes` command. " + "Hermes is running, but the update command could not find the " + "executable on PATH or via the current Python interpreter. " + "Try running `hermes update` manually in your terminal." + ) + + pending_path = _hermes_home / ".update_pending.json" + output_path = _hermes_home / ".update_output.txt" + exit_code_path = _hermes_home / ".update_exit_code" + session_key = self._session_key_for_source(event.source) + pending = { + "platform": event.source.platform.value, + "chat_id": event.source.chat_id, + "user_id": event.source.user_id, + "session_key": session_key, + "timestamp": datetime.now().isoformat(), + } + _tmp_pending = pending_path.with_suffix(".tmp") + _tmp_pending.write_text(json.dumps(pending)) + _tmp_pending.replace(pending_path) + exit_code_path.unlink(missing_ok=True) + + # Spawn `hermes update --gateway` detached so it survives gateway restart. + # --gateway enables file-based IPC for interactive prompts (stash + # restore, config migration) so the gateway can forward them to the + # user instead of silently skipping them. + # Use setsid for portable session detach (works under system services + # where systemd-run --user fails due to missing D-Bus session). + # PYTHONUNBUFFERED ensures output is flushed line-by-line so the + # gateway can stream it to the messenger in near-real-time. + hermes_cmd_str = " ".join(shlex.quote(part) for part in hermes_cmd) + update_cmd = ( + f"PYTHONUNBUFFERED=1 {hermes_cmd_str} update --gateway" + f" > {shlex.quote(str(output_path))} 2>&1; " + f"status=$?; printf '%s' \"$status\" > {shlex.quote(str(exit_code_path))}" + ) + try: + setsid_bin = shutil.which("setsid") + if setsid_bin: + # Preferred: setsid creates a new session, fully detached + subprocess.Popen( + [setsid_bin, "bash", "-c", update_cmd], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + else: + # Fallback: start_new_session=True calls os.setsid() in child + subprocess.Popen( + ["bash", "-c", update_cmd], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except Exception as e: + pending_path.unlink(missing_ok=True) + exit_code_path.unlink(missing_ok=True) + return f"✗ Failed to start update: {e}" + + self._schedule_update_notification_watch() + return "⚕ Starting Hermes update… I'll stream progress here." + + def _schedule_update_notification_watch(self) -> None: + """Ensure a background task is watching for update completion.""" + existing_task = getattr(self, "_update_notification_task", None) + if existing_task and not existing_task.done(): + return + + try: + self._update_notification_task = asyncio.create_task( + self._watch_update_progress() + ) + except RuntimeError: + logger.debug("Skipping update notification watcher: no running event loop") + + async def _watch_update_progress( + self, + poll_interval: float = 2.0, + stream_interval: float = 4.0, + timeout: float = 1800.0, + ) -> None: + """Watch ``hermes update --gateway``, streaming output + forwarding prompts. + + Polls ``.update_output.txt`` for new content and sends chunks to the + user periodically. Detects ``.update_prompt.json`` (written by the + update process when it needs user input) and forwards the prompt to + the messenger. The user's next message is intercepted by + ``_handle_message`` and written to ``.update_response``. + """ + import json + import re as _re + + pending_path = _hermes_home / ".update_pending.json" + claimed_path = _hermes_home / ".update_pending.claimed.json" + output_path = _hermes_home / ".update_output.txt" + exit_code_path = _hermes_home / ".update_exit_code" + prompt_path = _hermes_home / ".update_prompt.json" + + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + + # Resolve the adapter and chat_id for sending messages + adapter = None + chat_id = None + session_key = None + for path in (claimed_path, pending_path): + if path.exists(): + try: + pending = json.loads(path.read_text()) + platform_str = pending.get("platform") + chat_id = pending.get("chat_id") + session_key = pending.get("session_key") + if platform_str and chat_id: + platform = Platform(platform_str) + adapter = self.adapters.get(platform) + # Fallback session key if not stored (old pending files) + if not session_key: + session_key = f"{platform_str}:{chat_id}" + break + except Exception: + pass + + if not adapter or not chat_id: + logger.warning("Update watcher: cannot resolve adapter/chat_id, falling back to completion-only") + # Fall back to old behavior: wait for exit code and send final notification + while (pending_path.exists() or claimed_path.exists()) and loop.time() < deadline: + if exit_code_path.exists(): + await self._send_update_notification() + return + await asyncio.sleep(poll_interval) + if (pending_path.exists() or claimed_path.exists()) and not exit_code_path.exists(): + exit_code_path.write_text("124") + await self._send_update_notification() + return + + def _strip_ansi(text: str) -> str: + return _re.sub(r'\x1b\[[0-9;]*[A-Za-z]', '', text) + + bytes_sent = 0 + last_stream_time = loop.time() + buffer = "" + + async def _flush_buffer() -> None: + """Send buffered output to the user.""" + nonlocal buffer, last_stream_time + if not buffer.strip(): + buffer = "" + return + # Chunk to fit message limits (Telegram: 4096, others: generous) + clean = _strip_ansi(buffer).strip() + buffer = "" + last_stream_time = loop.time() + if not clean: + return + # Split into chunks if too long + max_chunk = 3500 + chunks = [clean[i:i + max_chunk] for i in range(0, len(clean), max_chunk)] + for chunk in chunks: + try: + await adapter.send(chat_id, f"```\n{chunk}\n```") + except Exception as e: + logger.debug("Update stream send failed: %s", e) + + while loop.time() < deadline: + # Check for completion + if exit_code_path.exists(): + # Read any remaining output + if output_path.exists(): + try: + content = output_path.read_text() + if len(content) > bytes_sent: + buffer += content[bytes_sent:] + bytes_sent = len(content) + except OSError: + pass + await _flush_buffer() + + # Send final status + try: + exit_code_raw = exit_code_path.read_text().strip() or "1" + exit_code = int(exit_code_raw) + if exit_code == 0: + await adapter.send(chat_id, "✅ Hermes update finished.") + else: + await adapter.send(chat_id, "❌ Hermes update failed (exit code {}).".format(exit_code)) + logger.info("Update finished (exit=%s), notified %s", exit_code, session_key) + except Exception as e: + logger.warning("Update final notification failed: %s", e) + + # Cleanup + for p in (pending_path, claimed_path, output_path, + exit_code_path, prompt_path): + p.unlink(missing_ok=True) + (_hermes_home / ".update_response").unlink(missing_ok=True) + self._update_prompt_pending.pop(session_key, None) + return + + # Check for new output + if output_path.exists(): + try: + content = output_path.read_text() + if len(content) > bytes_sent: + buffer += content[bytes_sent:] + bytes_sent = len(content) + except OSError: + pass + + # Flush buffer periodically + if buffer.strip() and (loop.time() - last_stream_time) >= stream_interval: + await _flush_buffer() + + # Check for prompts — only forward if we haven't already sent + # one that's still awaiting a response. Without this guard the + # watcher would re-read the same .update_prompt.json every poll + # cycle and spam the user with duplicate prompt messages. + if (prompt_path.exists() and session_key + and not self._update_prompt_pending.get(session_key)): + try: + prompt_data = json.loads(prompt_path.read_text()) + prompt_text = prompt_data.get("prompt", "") + default = prompt_data.get("default", "") + if prompt_text: + # Flush any buffered output first so the user sees + # context before the prompt + await _flush_buffer() + # Try platform-native buttons first (Discord, Telegram) + sent_buttons = False + if getattr(type(adapter), "send_update_prompt", None) is not None: + try: + await adapter.send_update_prompt( + chat_id=chat_id, + prompt=prompt_text, + default=default, + session_key=session_key, + ) + sent_buttons = True + except Exception as btn_err: + logger.debug("Button-based update prompt failed: %s", btn_err) + if not sent_buttons: + default_hint = f" (default: {default})" if default else "" + await adapter.send( + chat_id, + f"⚕ **Update needs your input:**\n\n" + f"{prompt_text}{default_hint}\n\n" + f"Reply `/approve` (yes) or `/deny` (no), " + f"or type your answer directly." + ) + self._update_prompt_pending[session_key] = True + # Remove the prompt file so it isn't re-read on the + # next poll cycle. The update process only needs + # .update_response to continue — it doesn't re-check + # .update_prompt.json while waiting. + prompt_path.unlink(missing_ok=True) + logger.info("Forwarded update prompt to %s: %s", session_key, prompt_text[:80]) + except (json.JSONDecodeError, OSError) as e: + logger.debug("Failed to read update prompt: %s", e) + + await asyncio.sleep(poll_interval) + + # Timeout + if not exit_code_path.exists(): + logger.warning("Update watcher timed out after %.0fs", timeout) + exit_code_path.write_text("124") + await _flush_buffer() + try: + await adapter.send(chat_id, "❌ Hermes update timed out after 30 minutes.") + except Exception: + pass + for p in (pending_path, claimed_path, output_path, + exit_code_path, prompt_path): + p.unlink(missing_ok=True) + (_hermes_home / ".update_response").unlink(missing_ok=True) + self._update_prompt_pending.pop(session_key, None) + + async def _send_update_notification(self) -> bool: + """If an update finished, notify the user. + + Returns False when the update is still running so a caller can retry + later. Returns True after a definitive send/skip decision. + + This is the legacy notification path used when the streaming watcher + cannot resolve the adapter (e.g. after a gateway restart where the + platform hasn't reconnected yet). + """ + import json + import re as _re + + pending_path = _hermes_home / ".update_pending.json" + claimed_path = _hermes_home / ".update_pending.claimed.json" + output_path = _hermes_home / ".update_output.txt" + exit_code_path = _hermes_home / ".update_exit_code" + + if not pending_path.exists() and not claimed_path.exists(): + return False + + cleanup = True + active_pending_path = claimed_path + try: + if pending_path.exists(): + try: + pending_path.replace(claimed_path) + except FileNotFoundError: + if not claimed_path.exists(): + return True + elif not claimed_path.exists(): + return True + + pending = json.loads(claimed_path.read_text()) + platform_str = pending.get("platform") + chat_id = pending.get("chat_id") + + if not exit_code_path.exists(): + logger.info("Update notification deferred: update still running") + cleanup = False + active_pending_path = pending_path + claimed_path.replace(pending_path) + return False + + exit_code_raw = exit_code_path.read_text().strip() or "1" + exit_code = int(exit_code_raw) + + # Read the captured update output + output = "" + if output_path.exists(): + output = output_path.read_text() + + # Resolve adapter + platform = Platform(platform_str) + adapter = self.adapters.get(platform) + + if adapter and chat_id: + # Strip ANSI escape codes for clean display + output = _re.sub(r'\x1b\[[0-9;]*m', '', output).strip() + if output: + if len(output) > 3500: + output = "…" + output[-3500:] + if exit_code == 0: + msg = f"✅ Hermes update finished.\n\n```\n{output}\n```" + else: + msg = f"❌ Hermes update failed.\n\n```\n{output}\n```" + else: + if exit_code == 0: + msg = "✅ Hermes update finished successfully." + else: + msg = "❌ Hermes update failed. Check the gateway logs or run `hermes update` manually for details." + await adapter.send(chat_id, msg) + logger.info( + "Sent post-update notification to %s:%s (exit=%s)", + platform_str, + chat_id, + exit_code, + ) + except Exception as e: + logger.warning("Post-update notification failed: %s", e) + finally: + if cleanup: + active_pending_path.unlink(missing_ok=True) + claimed_path.unlink(missing_ok=True) + output_path.unlink(missing_ok=True) + exit_code_path.unlink(missing_ok=True) + + return True + + async def _send_restart_notification(self) -> None: + """Notify the chat that initiated /restart that the gateway is back.""" + import json as _json + + notify_path = _hermes_home / ".restart_notify.json" + if not notify_path.exists(): + return + + try: + data = _json.loads(notify_path.read_text()) + platform_str = data.get("platform") + chat_id = data.get("chat_id") + thread_id = data.get("thread_id") + + if not platform_str or not chat_id: + return + + platform = Platform(platform_str) + adapter = self.adapters.get(platform) + if not adapter: + logger.debug( + "Restart notification skipped: %s adapter not connected", + platform_str, + ) + return + + metadata = {"thread_id": thread_id} if thread_id else None + await adapter.send( + chat_id, + "♻ Gateway restarted successfully. Your session continues.", + metadata=metadata, + ) + logger.info( + "Sent restart notification to %s:%s", + platform_str, + chat_id, + ) + except Exception as e: + logger.warning("Restart notification failed: %s", e) + finally: + notify_path.unlink(missing_ok=True) + + def _set_session_env(self, context: SessionContext) -> list: + """Set session context variables for the current async task. + + Uses ``contextvars`` instead of ``os.environ`` so that concurrent + gateway messages cannot overwrite each other's session state. + + Returns a list of reset tokens; pass them to ``_clear_session_env`` + in a ``finally`` block. + """ + from gateway.session_context import set_session_vars + return set_session_vars( + platform=context.source.platform.value, + chat_id=context.source.chat_id, + chat_name=context.source.chat_name or "", + thread_id=str(context.source.thread_id) if context.source.thread_id else "", + user_id=str(context.source.user_id) if context.source.user_id else "", + user_name=str(context.source.user_name) if context.source.user_name else "", + session_key=context.session_key, + ) + + def _clear_session_env(self, tokens: list) -> None: + """Restore session context variables to their pre-handler values.""" + from gateway.session_context import clear_session_vars + clear_session_vars(tokens) + + async def _enrich_message_with_vision( + self, + user_text: str, + image_paths: List[str], + ) -> str: + """ + Auto-analyze user-attached images with the vision tool and prepend + the descriptions to the message text. + + Each image is analyzed with a general-purpose prompt. The resulting + description *and* the local cache path are injected so the model can: + 1. Immediately understand what the user sent (no extra tool call). + 2. Re-examine the image with vision_analyze if it needs more detail. + + Args: + user_text: The user's original caption / message text. + image_paths: List of local file paths to cached images. + + Returns: + The enriched message string with vision descriptions prepended. + """ + from tools.vision_tools import vision_analyze_tool + import json as _json + + analysis_prompt = ( + "Describe everything visible in this image in thorough detail. " + "Include any text, code, data, objects, people, layout, colors, " + "and any other notable visual information." + ) + + enriched_parts = [] + for path in image_paths: + try: + logger.debug("Auto-analyzing user image: %s", path) + result_json = await vision_analyze_tool( + image_url=path, + user_prompt=analysis_prompt, + ) + result = _json.loads(result_json) + if result.get("success"): + description = result.get("analysis", "") + enriched_parts.append( + f"[The user sent an image~ Here's what I can see:\n{description}]\n" + f"[If you need a closer look, use vision_analyze with " + f"image_url: {path} ~]" + ) + else: + enriched_parts.append( + "[The user sent an image but I couldn't quite see it " + "this time (>_<) You can try looking at it yourself " + f"with vision_analyze using image_url: {path}]" + ) + except Exception as e: + logger.error("Vision auto-analysis error: %s", e) + enriched_parts.append( + f"[The user sent an image but something went wrong when I " + f"tried to look at it~ You can try examining it yourself " + f"with vision_analyze using image_url: {path}]" + ) + + # Combine: vision descriptions first, then the user's original text + if enriched_parts: + prefix = "\n\n".join(enriched_parts) + if user_text: + return f"{prefix}\n\n{user_text}" + return prefix + return user_text + + async def _enrich_message_with_transcription( + self, + user_text: str, + audio_paths: List[str], + ) -> str: + """ + Auto-transcribe user voice/audio messages using the configured STT provider + and prepend the transcript to the message text. + + Args: + user_text: The user's original caption / message text. + audio_paths: List of local file paths to cached audio files. + + Returns: + The enriched message string with transcriptions prepended. + """ + if not getattr(self.config, "stt_enabled", True): + disabled_note = "[The user sent voice message(s), but transcription is disabled in config." + if self._has_setup_skill(): + disabled_note += ( + " You have a skill called hermes-agent-setup that can help " + "users configure Hermes features including voice, tools, and more." + ) + disabled_note += "]" + if user_text: + return f"{disabled_note}\n\n{user_text}" + return disabled_note + + from tools.transcription_tools import transcribe_audio + import asyncio + + enriched_parts = [] + for path in audio_paths: + try: + logger.debug("Transcribing user voice: %s", path) + result = await asyncio.to_thread(transcribe_audio, path) + if result["success"]: + transcript = result["transcript"] + enriched_parts.append( + f'[The user sent a voice message~ ' + f'Here\'s what they said: "{transcript}"]' + ) + else: + error = result.get("error", "unknown error") + if ( + "No STT provider" in error + or error.startswith("Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set") + ): + _no_stt_note = ( + "[The user sent a voice message but I can't listen " + "to it right now — no STT provider is configured. " + "A direct message has already been sent to the user " + "with setup instructions." + ) + if self._has_setup_skill(): + _no_stt_note += ( + " You have a skill called hermes-agent-setup " + "that can help users configure Hermes features " + "including voice, tools, and more." + ) + _no_stt_note += "]" + enriched_parts.append(_no_stt_note) + else: + enriched_parts.append( + "[The user sent a voice message but I had trouble " + f"transcribing it~ ({error})]" + ) + except Exception as e: + logger.error("Transcription error: %s", e) + enriched_parts.append( + "[The user sent a voice message but something went wrong " + "when I tried to listen to it~ Let them know!]" + ) + + if enriched_parts: + prefix = "\n\n".join(enriched_parts) + # Strip the empty-content placeholder from the Discord adapter + # when we successfully transcribed the audio — it's redundant. + _placeholder = "(The user sent a message with no text content)" + if user_text and user_text.strip() == _placeholder: + return prefix + if user_text: + return f"{prefix}\n\n{user_text}" + return prefix + return user_text + + async def _inject_watch_notification(self, synth_text: str, original_event) -> None: + """Inject a watch-pattern notification as a synthetic message event. + + Uses the source from the original user event to route the notification + back to the correct chat/adapter. + """ + source = getattr(original_event, "source", None) + if not source: + return + platform_name = source.platform.value if hasattr(source.platform, "value") else str(source.platform) + adapter = None + for p, a in self.adapters.items(): + if p.value == platform_name: + adapter = a + break + if not adapter: + return + try: + from gateway.platforms.base import MessageEvent, MessageType + synth_event = MessageEvent( + text=synth_text, + message_type=MessageType.TEXT, + source=source, + internal=True, + ) + logger.info("Watch pattern notification — injecting for %s", platform_name) + await adapter.handle_message(synth_event) + except Exception as e: + logger.error("Watch notification injection error: %s", e) + + async def _run_process_watcher(self, watcher: dict) -> None: + """ + Periodically check a background process and push updates to the user. + + Runs as an asyncio task. Stays silent when nothing changed. + Auto-removes when the process exits or is killed. + + Notification mode (from ``display.background_process_notifications``): + - ``all`` — running-output updates + final message + - ``result`` — final completion message only + - ``error`` — final message only when exit code != 0 + - ``off`` — no messages at all + """ + from tools.process_registry import process_registry + + session_id = watcher["session_id"] + interval = watcher["check_interval"] + session_key = watcher.get("session_key", "") + platform_name = watcher.get("platform", "") + chat_id = watcher.get("chat_id", "") + thread_id = watcher.get("thread_id", "") + user_id = watcher.get("user_id", "") + user_name = watcher.get("user_name", "") + agent_notify = watcher.get("notify_on_complete", False) + notify_mode = self._load_background_notifications_mode() + + logger.debug("Process watcher started: %s (every %ss, notify=%s, agent_notify=%s)", + session_id, interval, notify_mode, agent_notify) + + if notify_mode == "off" and not agent_notify: + # Still wait for the process to exit so we can log it, but don't + # push any messages to the user. + while True: + await asyncio.sleep(interval) + session = process_registry.get(session_id) + if session is None or session.exited: + break + logger.debug("Process watcher ended (silent): %s", session_id) + return + + last_output_len = 0 + while True: + await asyncio.sleep(interval) + + session = process_registry.get(session_id) + if session is None: + break + + current_output_len = len(session.output_buffer) + has_new_output = current_output_len > last_output_len + last_output_len = current_output_len + + if session.exited: + # --- Agent-triggered completion: inject synthetic message --- + # Skip if the agent already consumed the result via wait/poll/log + from tools.process_registry import process_registry as _pr_check + if agent_notify and not _pr_check.is_completion_consumed(session_id): + from tools.ansi_strip import strip_ansi + _out = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else "" + synth_text = ( + f"[SYSTEM: Background process {session_id} completed " + f"(exit code {session.exit_code}).\n" + f"Command: {session.command}\n" + f"Output:\n{_out}]" + ) + adapter = None + for p, a in self.adapters.items(): + if p.value == platform_name: + adapter = a + break + if adapter and chat_id: + try: + from gateway.platforms.base import MessageEvent, MessageType + from gateway.session import SessionSource + from gateway.config import Platform + _platform_enum = Platform(platform_name) + _source = SessionSource( + platform=_platform_enum, + chat_id=chat_id, + thread_id=thread_id or None, + user_id=user_id or None, + user_name=user_name or None, + ) + synth_event = MessageEvent( + text=synth_text, + message_type=MessageType.TEXT, + source=_source, + internal=True, + ) + logger.info( + "Process %s finished — injecting agent notification for session %s", + session_id, session_key, + ) + await adapter.handle_message(synth_event) + except Exception as e: + logger.error("Agent notify injection error: %s", e) + break + + # --- Normal text-only notification --- + # Decide whether to notify based on mode + should_notify = ( + notify_mode in ("all", "result") + or (notify_mode == "error" and session.exit_code not in (0, None)) + ) + if should_notify: + new_output = session.output_buffer[-1000:] if session.output_buffer else "" + message_text = ( + f"[Background process {session_id} finished with exit code {session.exit_code}~ " + f"Here's the final output:\n{new_output}]" + ) + adapter = None + for p, a in self.adapters.items(): + if p.value == platform_name: + adapter = a + break + if adapter and chat_id: + try: + send_meta = {"thread_id": thread_id} if thread_id else None + await adapter.send(chat_id, message_text, metadata=send_meta) + except Exception as e: + logger.error("Watcher delivery error: %s", e) + break + + elif has_new_output and notify_mode == "all" and not agent_notify: + # New output available -- deliver status update (only in "all" mode) + # Skip periodic updates for agent_notify watchers (they only care about completion) + new_output = session.output_buffer[-500:] if session.output_buffer else "" + message_text = ( + f"[Background process {session_id} is still running~ " + f"New output:\n{new_output}]" + ) + adapter = None + for p, a in self.adapters.items(): + if p.value == platform_name: + adapter = a + break + if adapter and chat_id: + try: + send_meta = {"thread_id": thread_id} if thread_id else None + await adapter.send(chat_id, message_text, metadata=send_meta) + except Exception as e: + logger.error("Watcher delivery error: %s", e) + + logger.debug("Process watcher ended: %s", session_id) + + _MAX_INTERRUPT_DEPTH = 3 # Cap recursive interrupt handling (#816) + + @staticmethod + def _agent_config_signature( + model: str, + runtime: dict, + enabled_toolsets: list, + ephemeral_prompt: str, + ) -> str: + """Compute a stable string key from agent config values. + + When this signature changes between messages, the cached AIAgent is + discarded and rebuilt. When it stays the same, the cached agent is + reused — preserving the frozen system prompt and tool schemas for + prompt cache hits. + """ + import hashlib, json as _j + + # Fingerprint the FULL credential string instead of using a short + # prefix. OAuth/JWT-style tokens frequently share a common prefix + # (e.g. "eyJhbGci"), which can cause false cache hits across auth + # switches if only the first few characters are considered. + _api_key = str(runtime.get("api_key", "") or "") + _api_key_fingerprint = hashlib.sha256(_api_key.encode()).hexdigest() if _api_key else "" + + blob = _j.dumps( + [ + model, + _api_key_fingerprint, + runtime.get("base_url", ""), + runtime.get("provider", ""), + runtime.get("api_mode", ""), + sorted(enabled_toolsets) if enabled_toolsets else [], + # reasoning_config excluded — it's set per-message on the + # cached agent and doesn't affect system prompt or tools. + ephemeral_prompt or "", + ], + sort_keys=True, + default=str, + ) + return hashlib.sha256(blob.encode()).hexdigest()[:16] + + def _apply_session_model_override( + self, session_key: str, model: str, runtime_kwargs: dict + ) -> tuple: + """Apply /model session overrides if present, returning (model, runtime_kwargs). + + The gateway /model command stores per-session overrides in + ``_session_model_overrides``. These must take precedence over + config.yaml defaults so the switched model is actually used for + subsequent messages. Fields with ``None`` values are skipped so + partial overrides don't clobber valid config defaults. + """ + override = self._session_model_overrides.get(session_key) + if not override: + return model, runtime_kwargs + model = override.get("model", model) + for key in ("provider", "api_key", "base_url", "api_mode"): + val = override.get(key) + if val is not None: + runtime_kwargs[key] = val + return model, runtime_kwargs + + def _is_intentional_model_switch(self, session_key: str, agent_model: str) -> bool: + """Return True if *agent_model* matches an active /model session override.""" + override = self._session_model_overrides.get(session_key) + return override is not None and override.get("model") == agent_model + + def _evict_cached_agent(self, session_key: str) -> None: + """Remove a cached agent for a session (called on /new, /model, etc).""" + _lock = getattr(self, "_agent_cache_lock", None) + if _lock: + with _lock: + self._agent_cache.pop(session_key, None) + + async def _run_agent( + self, + message: str, + context_prompt: str, + history: List[Dict[str, Any]], + source: SessionSource, + session_id: str, + session_key: str = None, + _interrupt_depth: int = 0, + event_message_id: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Run the agent with the given message and context. + + Returns the full result dict from run_conversation, including: + - "final_response": str (the text to send back) + - "messages": list (full conversation including tool calls) + - "api_calls": int + - "completed": bool + + This is run in a thread pool to not block the event loop. + Supports interruption via new messages. + """ + from run_agent import AIAgent + import queue + + user_config = _load_gateway_config() + platform_key = _platform_config_key(source.platform) + + from hermes_cli.tools_config import _get_platform_tools + enabled_toolsets = sorted(_get_platform_tools(user_config, platform_key)) + + display_config = user_config.get("display", {}) + if not isinstance(display_config, dict): + display_config = {} + + # Per-platform display settings — resolve via display_config module + # which checks display.platforms.<platform>.<key> first, then + # display.<key> global, then built-in platform defaults. + from gateway.display_config import resolve_display_setting + + # Apply tool preview length config (0 = no limit) + try: + from agent.display import set_tool_preview_max_len + _tpl = resolve_display_setting(user_config, platform_key, "tool_preview_length", 0) + set_tool_preview_max_len(int(_tpl) if _tpl else 0) + except Exception: + pass + + # Tool progress mode — resolved per-platform with env var fallback + _resolved_tp = resolve_display_setting(user_config, platform_key, "tool_progress") + progress_mode = ( + _resolved_tp + or os.getenv("HERMES_TOOL_PROGRESS_MODE") + or "all" + ) + # Disable tool progress for webhooks - they don't support message editing, + # so each progress line would be sent as a separate message. + from gateway.config import Platform + tool_progress_enabled = progress_mode != "off" and source.platform != Platform.WEBHOOK + # Natural assistant status messages are intentionally independent from + # tool progress and token streaming. Users can keep tool_progress quiet + # in chat platforms while opting into concise mid-turn updates. + interim_assistant_messages_enabled = ( + source.platform != Platform.WEBHOOK + and is_truthy_value( + display_config.get("interim_assistant_messages"), + default=True, + ) + ) + + # Queue for progress messages (thread-safe) + progress_queue = queue.Queue() if tool_progress_enabled else None + last_tool = [None] # Mutable container for tracking in closure + last_progress_msg = [None] # Track last message for dedup + repeat_count = [0] # How many times the same message repeated + + def progress_callback(event_type: str, tool_name: str = None, preview: str = None, args: dict = None, **kwargs): + """Callback invoked by agent on tool lifecycle events.""" + if not progress_queue: + return + + # Only act on tool.started events (ignore tool.completed, reasoning.available, etc.) + if event_type not in ("tool.started",): + return + + # "new" mode: only report when tool changes + if progress_mode == "new" and tool_name == last_tool[0]: + return + last_tool[0] = tool_name + + # Build progress message with primary argument preview + from agent.display import get_tool_emoji + emoji = get_tool_emoji(tool_name, default="⚙️") + + # Verbose mode: show detailed arguments, respects tool_preview_length + if progress_mode == "verbose": + if args: + from agent.display import get_tool_preview_max_len + _pl = get_tool_preview_max_len() + import json as _json + args_str = _json.dumps(args, ensure_ascii=False, default=str) + # When tool_preview_length is 0 (default), don't truncate + # in verbose mode — the user explicitly asked for full + # detail. Platform message-length limits handle the rest. + if _pl > 0 and len(args_str) > _pl: + args_str = args_str[:_pl - 3] + "..." + msg = f"{emoji} {tool_name}({list(args.keys())})\n{args_str}" + elif preview: + msg = f"{emoji} {tool_name}: \"{preview}\"" + else: + msg = f"{emoji} {tool_name}..." + progress_queue.put(msg) + return + + # "all" / "new" modes: short preview, respects tool_preview_length + # config (defaults to 40 chars when unset to keep gateway messages + # compact — unlike CLI spinners, these persist as permanent messages). + if preview: + from agent.display import get_tool_preview_max_len + _pl = get_tool_preview_max_len() + _cap = _pl if _pl > 0 else 40 + if len(preview) > _cap: + preview = preview[:_cap - 3] + "..." + msg = f"{emoji} {tool_name}: \"{preview}\"" + else: + msg = f"{emoji} {tool_name}..." + + # Dedup: collapse consecutive identical progress messages. + # Common with execute_code where models iterate with the same + # code (same boilerplate imports → identical previews). + if msg == last_progress_msg[0]: + repeat_count[0] += 1 + # Update the last line in progress_lines with a counter + # via a special "dedup" queue message. + progress_queue.put(("__dedup__", msg, repeat_count[0])) + return + last_progress_msg[0] = msg + repeat_count[0] = 0 + + progress_queue.put(msg) + + # Background task to send progress messages + # Accumulates tool lines into a single message that gets edited. + # + # Threading metadata is platform-specific: + # - Slack DM threading needs event_message_id fallback (reply thread) + # - Telegram uses message_thread_id only for forum topics; passing a + # normal DM/group message id as thread_id causes send failures + # - Other platforms should use explicit source.thread_id only + if source.platform == Platform.SLACK: + _progress_thread_id = source.thread_id or event_message_id + else: + _progress_thread_id = source.thread_id + _progress_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None + + async def send_progress_messages(): + if not progress_queue: + return + + adapter = self.adapters.get(source.platform) + if not adapter: + return + + # Skip tool progress for platforms that don't support message + # editing (e.g. iMessage/BlueBubbles) — each progress update + # would become a separate message bubble, which is noisy. + from gateway.platforms.base import BasePlatformAdapter as _BaseAdapter + if type(adapter).edit_message is _BaseAdapter.edit_message: + while not progress_queue.empty(): + try: + progress_queue.get_nowait() + except Exception: + break + return + + progress_lines = [] # Accumulated tool lines + progress_msg_id = None # ID of the progress message to edit + can_edit = True # False once an edit fails (platform doesn't support it) + _last_edit_ts = 0.0 # Throttle edits to avoid Telegram flood control + _PROGRESS_EDIT_INTERVAL = 1.5 # Minimum seconds between edits + + while True: + try: + raw = progress_queue.get_nowait() + + # Handle dedup messages: update last line with repeat counter + if isinstance(raw, tuple) and len(raw) == 3 and raw[0] == "__dedup__": + _, base_msg, count = raw + if progress_lines: + progress_lines[-1] = f"{base_msg} (×{count + 1})" + msg = progress_lines[-1] if progress_lines else base_msg + else: + msg = raw + progress_lines.append(msg) + + # Throttle edits: batch rapid tool updates into fewer + # API calls to avoid hitting Telegram flood control. + # (grammY auto-retry pattern: proactively rate-limit + # instead of reacting to 429s.) + _now = time.monotonic() + _remaining = _PROGRESS_EDIT_INTERVAL - (_now - _last_edit_ts) + if _remaining > 0: + # Wait out the throttle interval, then loop back to + # drain any additional queued messages before sending + # a single batched edit. + await asyncio.sleep(_remaining) + continue + + if can_edit and progress_msg_id is not None: + # Try to edit the existing progress message + full_text = "\n".join(progress_lines) + result = await adapter.edit_message( + chat_id=source.chat_id, + message_id=progress_msg_id, + content=full_text, + ) + if not result.success: + _err = (getattr(result, "error", "") or "").lower() + if "flood" in _err or "retry after" in _err: + # Flood control hit — disable further edits, + # switch to sending new messages only for + # important updates. Don't block 23s. + logger.info( + "[%s] Progress edits disabled due to flood control", + adapter.name, + ) + can_edit = False + await adapter.send(chat_id=source.chat_id, content=msg, metadata=_progress_metadata) + else: + if can_edit: + # First tool: send all accumulated text as new message + full_text = "\n".join(progress_lines) + result = await adapter.send(chat_id=source.chat_id, content=full_text, metadata=_progress_metadata) + else: + # Editing unsupported: send just this line + result = await adapter.send(chat_id=source.chat_id, content=msg, metadata=_progress_metadata) + if result.success and result.message_id: + progress_msg_id = result.message_id + + _last_edit_ts = time.monotonic() + + # Restore typing indicator + await asyncio.sleep(0.3) + await adapter.send_typing(source.chat_id, metadata=_progress_metadata) + + except queue.Empty: + await asyncio.sleep(0.3) + except asyncio.CancelledError: + # Drain remaining queued messages + while not progress_queue.empty(): + try: + raw = progress_queue.get_nowait() + if isinstance(raw, tuple) and len(raw) == 3 and raw[0] == "__dedup__": + _, base_msg, count = raw + if progress_lines: + progress_lines[-1] = f"{base_msg} (×{count + 1})" + else: + progress_lines.append(raw) + except Exception: + break + # Final edit with all remaining tools (only if editing works) + if can_edit and progress_lines and progress_msg_id: + full_text = "\n".join(progress_lines) + try: + await adapter.edit_message( + chat_id=source.chat_id, + message_id=progress_msg_id, + content=full_text, + ) + except Exception: + pass + return + except Exception as e: + logger.error("Progress message error: %s", e) + await asyncio.sleep(1) + + # We need to share the agent instance for interrupt support + agent_holder = [None] # Mutable container for the agent instance + result_holder = [None] # Mutable container for the result + tools_holder = [None] # Mutable container for the tool definitions + stream_consumer_holder = [None] # Mutable container for stream consumer + + # Bridge sync step_callback → async hooks.emit for agent:step events + _loop_for_step = asyncio.get_event_loop() + _hooks_ref = self.hooks + + def _step_callback_sync(iteration: int, prev_tools: list) -> None: + try: + # prev_tools may be list[str] or list[dict] with "name"/"result" + # keys. Normalise to keep "tool_names" backward-compatible for + # user-authored hooks that do ', '.join(tool_names)'. + _names: list[str] = [] + for _t in (prev_tools or []): + if isinstance(_t, dict): + _names.append(_t.get("name") or "") + else: + _names.append(str(_t)) + asyncio.run_coroutine_threadsafe( + _hooks_ref.emit("agent:step", { + "platform": source.platform.value if source.platform else "", + "user_id": source.user_id, + "session_id": session_id, + "iteration": iteration, + "tool_names": _names, + "tools": prev_tools, + }), + _loop_for_step, + ) + except Exception as _e: + logger.debug("agent:step hook error: %s", _e) + + # Bridge sync status_callback → async adapter.send for context pressure + _status_adapter = self.adapters.get(source.platform) + _status_chat_id = source.chat_id + _status_thread_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None + + def _status_callback_sync(event_type: str, message: str) -> None: + if not _status_adapter: + return + try: + asyncio.run_coroutine_threadsafe( + _status_adapter.send( + _status_chat_id, + message, + metadata=_status_thread_metadata, + ), + _loop_for_step, + ) + except Exception as _e: + logger.debug("status_callback error (%s): %s", event_type, _e) + + def run_sync(): + # The conditional re-assignment of `message` further below + # (prepending model-switch notes) makes Python treat it as a + # local variable in the entire function. `nonlocal` lets us + # read *and* reassign the outer `_run_agent` parameter without + # triggering an UnboundLocalError on the earlier read at + # `_resolve_turn_agent_config(message, …)`. + nonlocal message + + # session_key is now set via contextvars in _set_session_env() + # (concurrency-safe). Keep os.environ as fallback for CLI/cron. + os.environ["HERMES_SESSION_KEY"] = session_key or "" + + # Read from env var or use default (same as CLI) + max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90")) + + # Map platform enum to the platform hint key the agent understands. + # Platform.LOCAL ("local") maps to "cli"; others pass through as-is. + platform_key = "cli" if source.platform == Platform.LOCAL else source.platform.value + + # Combine platform context with user-configured ephemeral system prompt + combined_ephemeral = context_prompt or "" + if self._ephemeral_system_prompt: + combined_ephemeral = (combined_ephemeral + "\n\n" + self._ephemeral_system_prompt).strip() + + # Re-read .env and config for fresh credentials (gateway is long-lived, + # keys may change without restart). + try: + load_dotenv(_env_path, override=True, encoding="utf-8") + except UnicodeDecodeError: + load_dotenv(_env_path, override=True, encoding="latin-1") + except Exception: + pass + + try: + model, runtime_kwargs = self._resolve_session_agent_runtime( + source=source, + session_key=session_key, + user_config=user_config, + ) + logger.debug( + "run_agent resolved: model=%s provider=%s session=%s", + model, runtime_kwargs.get("provider"), (session_key or "")[:30], + ) + except Exception as exc: + return { + "final_response": f"⚠️ Provider authentication failed: {exc}", + "messages": [], + "api_calls": 0, + "tools": [], + } + + pr = self._provider_routing + reasoning_config = self._load_reasoning_config() + self._reasoning_config = reasoning_config + self._service_tier = self._load_service_tier() + # Set up stream consumer for token streaming or interim commentary. + _stream_consumer = None + _stream_delta_cb = None + _scfg = getattr(getattr(self, 'config', None), 'streaming', None) + if _scfg is None: + from gateway.config import StreamingConfig + _scfg = StreamingConfig() + + # Per-platform streaming gate: display.platforms.<plat>.streaming + # can disable streaming for specific platforms even when the global + # streaming config is enabled. + _plat_streaming = resolve_display_setting( + user_config, platform_key, "streaming" + ) + # None = no per-platform override → follow global config + _streaming_enabled = ( + _scfg.enabled and _scfg.transport != "off" + if _plat_streaming is None + else bool(_plat_streaming) + ) + _want_stream_deltas = _streaming_enabled + _want_interim_messages = interim_assistant_messages_enabled + _want_interim_consumer = _want_interim_messages + if _want_stream_deltas or _want_interim_consumer: + try: + from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig + _adapter = self.adapters.get(source.platform) + if _adapter: + # Platforms that don't support editing sent messages + # (e.g. QQ, WeChat) should skip streaming entirely — + # without edit support, the consumer sends a partial + # first message that can never be updated, resulting in + # duplicate messages (partial + final). + _adapter_supports_edit = getattr(_adapter, "SUPPORTS_MESSAGE_EDITING", True) + if not _adapter_supports_edit: + raise RuntimeError("skip streaming for non-editable platform") + _effective_cursor = _scfg.cursor + # Some Matrix clients render the streaming cursor + # as a visible tofu/white-box artifact. Keep + # streaming text on Matrix, but suppress the cursor. + if source.platform == Platform.MATRIX: + _effective_cursor = "" + _consumer_cfg = StreamConsumerConfig( + edit_interval=_scfg.edit_interval, + buffer_threshold=_scfg.buffer_threshold, + cursor=_effective_cursor, + ) + _stream_consumer = GatewayStreamConsumer( + adapter=_adapter, + chat_id=source.chat_id, + config=_consumer_cfg, + metadata={"thread_id": _progress_thread_id} if _progress_thread_id else None, + ) + if _want_stream_deltas: + _stream_delta_cb = _stream_consumer.on_delta + stream_consumer_holder[0] = _stream_consumer + except Exception as _sc_err: + logger.debug("Could not set up stream consumer: %s", _sc_err) + + def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: + if _stream_consumer is not None: + if already_streamed: + _stream_consumer.on_segment_break() + else: + _stream_consumer.on_commentary(text) + return + if already_streamed or not _status_adapter or not str(text or "").strip(): + return + try: + asyncio.run_coroutine_threadsafe( + _status_adapter.send( + _status_chat_id, + text, + metadata=_status_thread_metadata, + ), + _loop_for_step, + ) + except Exception as _e: + logger.debug("interim_assistant_callback error: %s", _e) + + turn_route = self._resolve_turn_agent_config(message, model, runtime_kwargs) + + # Check agent cache — reuse the AIAgent from the previous message + # in this session to preserve the frozen system prompt and tool + # schemas for prompt cache hits. + _sig = self._agent_config_signature( + turn_route["model"], + turn_route["runtime"], + enabled_toolsets, + combined_ephemeral, + ) + agent = None + _cache_lock = getattr(self, "_agent_cache_lock", None) + _cache = getattr(self, "_agent_cache", None) + if _cache_lock and _cache is not None: + with _cache_lock: + cached = _cache.get(session_key) + if cached and cached[1] == _sig: + agent = cached[0] + logger.debug("Reusing cached agent for session %s", session_key) + + if agent is None: + # Config changed or first message — create fresh agent + agent = AIAgent( + model=turn_route["model"], + **turn_route["runtime"], + max_iterations=max_iterations, + quiet_mode=True, + verbose_logging=False, + enabled_toolsets=enabled_toolsets, + ephemeral_system_prompt=combined_ephemeral or None, + prefill_messages=self._prefill_messages or None, + reasoning_config=reasoning_config, + service_tier=self._service_tier, + request_overrides=turn_route.get("request_overrides"), + providers_allowed=pr.get("only"), + providers_ignored=pr.get("ignore"), + providers_order=pr.get("order"), + provider_sort=pr.get("sort"), + provider_require_parameters=pr.get("require_parameters", False), + provider_data_collection=pr.get("data_collection"), + session_id=session_id, + platform=platform_key, + user_id=source.user_id, + session_db=self._session_db, + fallback_model=self._fallback_model, + ) + if _cache_lock and _cache is not None: + with _cache_lock: + _cache[session_key] = (agent, _sig) + logger.debug("Created new agent for session %s (sig=%s)", session_key, _sig) + + # Per-message state — callbacks and reasoning config change every + # turn and must not be baked into the cached agent constructor. + agent.tool_progress_callback = progress_callback if tool_progress_enabled else None + agent.step_callback = _step_callback_sync if _hooks_ref.loaded_hooks else None + agent.stream_delta_callback = _stream_delta_cb + agent.interim_assistant_callback = _interim_assistant_cb if _want_interim_messages else None + agent.status_callback = _status_callback_sync + agent.reasoning_config = reasoning_config + agent.service_tier = self._service_tier + agent.request_overrides = turn_route.get("request_overrides") + + # Background review delivery — send "💾 Memory updated" etc. to user + def _bg_review_send(message: str) -> None: + if not _status_adapter: + return + try: + asyncio.run_coroutine_threadsafe( + _status_adapter.send( + _status_chat_id, + message, + metadata=_status_thread_metadata, + ), + _loop_for_step, + ) + except Exception as _e: + logger.debug("background_review_callback error: %s", _e) + + agent.background_review_callback = _bg_review_send + + # Store agent reference for interrupt support + agent_holder[0] = agent + # Capture the full tool definitions for transcript logging + tools_holder[0] = agent.tools if hasattr(agent, 'tools') else None + + # Convert history to agent format. + # Two cases: + # 1. Normal path (from transcript): simple {role, content, timestamp} dicts + # - Strip timestamps, keep role+content + # 2. Interrupt path (from agent result["messages"]): full agent messages + # that may include tool_calls, tool_call_id, reasoning, etc. + # - These must be passed through intact so the API sees valid + # assistant→tool sequences (dropping tool_calls causes 500 errors) + agent_history = [] + for msg in history: + role = msg.get("role") + if not role: + continue + + # Skip metadata entries (tool definitions, session info) + # -- these are for transcript logging, not for the LLM + if role in ("session_meta",): + continue + + # Skip system messages -- the agent rebuilds its own system prompt + if role == "system": + continue + + # Rich agent messages (tool_calls, tool results) must be passed + # through intact so the API sees valid assistant→tool sequences + has_tool_calls = "tool_calls" in msg + has_tool_call_id = "tool_call_id" in msg + is_tool_message = role == "tool" + + if has_tool_calls or has_tool_call_id or is_tool_message: + clean_msg = {k: v for k, v in msg.items() if k != "timestamp"} + agent_history.append(clean_msg) + else: + # Simple text message - just need role and content + content = msg.get("content") + if content: + # Tag cross-platform mirror messages so the agent knows their origin + if msg.get("mirror"): + mirror_src = msg.get("mirror_source", "another session") + content = f"[Delivered from {mirror_src}] {content}" + entry = {"role": role, "content": content} + # Preserve reasoning fields on assistant messages so + # multi-turn reasoning context survives session reload. + # The agent's _build_api_kwargs converts these to the + # provider-specific format (reasoning_content, etc.). + if role == "assistant": + for _rkey in ("reasoning", "reasoning_details", + "codex_reasoning_items"): + _rval = msg.get(_rkey) + if _rval: + entry[_rkey] = _rval + agent_history.append(entry) + + # Collect MEDIA paths already in history so we can exclude them + # from the current turn's extraction. This is compression-safe: + # even if the message list shrinks, we know which paths are old. + _history_media_paths: set = set() + for _hm in agent_history: + if _hm.get("role") in ("tool", "function"): + _hc = _hm.get("content", "") + if "MEDIA:" in _hc: + for _match in re.finditer(r'MEDIA:(\S+)', _hc): + _p = _match.group(1).strip().rstrip('",}') + if _p: + _history_media_paths.add(_p) + + # Register per-session gateway approval callback so dangerous + # command approval blocks the agent thread (mirrors CLI input()). + # The callback bridges sync→async to send the approval request + # to the user immediately. + from tools.approval import ( + register_gateway_notify, + reset_current_session_key, + set_current_session_key, + unregister_gateway_notify, + ) + + def _approval_notify_sync(approval_data: dict) -> None: + """Send the approval request to the user from the agent thread. + + If the adapter supports interactive button-based approvals + (e.g. Discord's ``send_exec_approval``), use that for a richer + UX. Otherwise fall back to a plain text message with + ``/approve`` instructions. + """ + # Pause the typing indicator while the agent waits for + # user approval. Critical for Slack's Assistant API where + # assistant_threads_setStatus disables the compose box — the + # user literally cannot type /approve while "is thinking..." + # is active. The approval message send auto-clears the Slack + # status; pausing prevents _keep_typing from re-setting it. + # Typing resumes in _handle_approve_command/_handle_deny_command. + _status_adapter.pause_typing_for_chat(_status_chat_id) + + cmd = approval_data.get("command", "") + desc = approval_data.get("description", "dangerous command") + + # Prefer button-based approval when the adapter supports it. + # Check the *class* for the method, not the instance — avoids + # false positives from MagicMock auto-attribute creation in tests. + if getattr(type(_status_adapter), "send_exec_approval", None) is not None: + try: + asyncio.run_coroutine_threadsafe( + _status_adapter.send_exec_approval( + chat_id=_status_chat_id, + command=cmd, + session_key=_approval_session_key, + description=desc, + metadata=_status_thread_metadata, + ), + _loop_for_step, + ).result(timeout=15) + return + except Exception as _e: + logger.warning( + "Button-based approval failed, falling back to text: %s", _e + ) + + # Fallback: plain text approval prompt + cmd_preview = cmd[:200] + "..." if len(cmd) > 200 else cmd + msg = ( + f"⚠️ **Dangerous command requires approval:**\n" + f"```\n{cmd_preview}\n```\n" + f"Reason: {desc}\n\n" + f"Reply `/approve` to execute, `/approve session` to approve this pattern " + f"for the session, `/approve always` to approve permanently, or `/deny` to cancel." + ) + try: + asyncio.run_coroutine_threadsafe( + _status_adapter.send( + _status_chat_id, + msg, + metadata=_status_thread_metadata, + ), + _loop_for_step, + ).result(timeout=15) + except Exception as _e: + logger.error("Failed to send approval request: %s", _e) + + # Prepend pending model switch note so the model knows about the switch + _pending_notes = getattr(self, '_pending_model_notes', {}) + _msn = _pending_notes.pop(session_key, None) if session_key else None + if _msn: + message = _msn + "\n\n" + message + + _approval_session_key = session_key or "" + _approval_session_token = set_current_session_key(_approval_session_key) + register_gateway_notify(_approval_session_key, _approval_notify_sync) + try: + result = agent.run_conversation(message, conversation_history=agent_history, task_id=session_id) + finally: + unregister_gateway_notify(_approval_session_key) + reset_current_session_key(_approval_session_token) + result_holder[0] = result + + # Signal the stream consumer that the agent is done + if _stream_consumer is not None: + _stream_consumer.finish() + + # Return final response, or a message if something went wrong + final_response = result.get("final_response") + + # Extract actual token counts from the agent instance used for this run + _last_prompt_toks = 0 + _input_toks = 0 + _output_toks = 0 + _agent = agent_holder[0] + if _agent and hasattr(_agent, "context_compressor"): + _last_prompt_toks = getattr(_agent.context_compressor, "last_prompt_tokens", 0) + _input_toks = getattr(_agent, "session_prompt_tokens", 0) + _output_toks = getattr(_agent, "session_completion_tokens", 0) + _resolved_model = getattr(_agent, "model", None) if _agent else None + + if not final_response: + error_msg = f"⚠️ {result['error']}" if result.get("error") else "(No response generated)" + return { + "final_response": error_msg, + "messages": result.get("messages", []), + "api_calls": result.get("api_calls", 0), + "tools": tools_holder[0] or [], + "history_offset": len(agent_history), + "last_prompt_tokens": _last_prompt_toks, + "input_tokens": _input_toks, + "output_tokens": _output_toks, + "model": _resolved_model, + } + + # Scan tool results for MEDIA:<path> tags that need to be delivered + # as native audio/file attachments. The TTS tool embeds MEDIA: tags + # in its JSON response, but the model's final text reply usually + # doesn't include them. We collect unique tags from tool results and + # append any that aren't already present in the final response, so the + # adapter's extract_media() can find and deliver the files exactly once. + # + # Uses path-based deduplication against _history_media_paths (collected + # before run_conversation) instead of index slicing. This is safe even + # when context compression shrinks the message list. (Fixes #160) + if "MEDIA:" not in final_response: + media_tags = [] + has_voice_directive = False + for msg in result.get("messages", []): + if msg.get("role") in ("tool", "function"): + content = msg.get("content", "") + if "MEDIA:" in content: + for match in re.finditer(r'MEDIA:(\S+)', content): + path = match.group(1).strip().rstrip('",}') + if path and path not in _history_media_paths: + media_tags.append(f"MEDIA:{path}") + if "[[audio_as_voice]]" in content: + has_voice_directive = True + + if media_tags: + seen = set() + unique_tags = [] + for tag in media_tags: + if tag not in seen: + seen.add(tag) + unique_tags.append(tag) + if has_voice_directive: + unique_tags.insert(0, "[[audio_as_voice]]") + final_response = final_response + "\n" + "\n".join(unique_tags) + + # Sync session_id: the agent may have created a new session during + # mid-run context compression (_compress_context splits sessions). + # If so, update the session store entry so the NEXT message loads + # the compressed transcript, not the stale pre-compression one. + agent = agent_holder[0] + _session_was_split = False + if agent and session_key and hasattr(agent, 'session_id') and agent.session_id != session_id: + _session_was_split = True + logger.info( + "Session split detected: %s → %s (compression)", + session_id, agent.session_id, + ) + entry = self.session_store._entries.get(session_key) + if entry: + entry.session_id = agent.session_id + self.session_store._save() + + effective_session_id = getattr(agent, 'session_id', session_id) if agent else session_id + + # When compression created a new session, the messages list was + # shortened. Using the original history offset would produce an + # empty new_messages slice, causing the gateway to write only a + # user/assistant pair — losing the compressed summary and tail. + # Reset to 0 so the gateway writes ALL compressed messages. + _effective_history_offset = 0 if _session_was_split else len(agent_history) + + # Auto-generate session title after first exchange (non-blocking) + if final_response and self._session_db: + try: + from agent.title_generator import maybe_auto_title + all_msgs = result_holder[0].get("messages", []) if result_holder[0] else [] + maybe_auto_title( + self._session_db, + effective_session_id, + message, + final_response, + all_msgs, + ) + except Exception: + pass + + return { + "final_response": final_response, + "last_reasoning": result.get("last_reasoning"), + "messages": result_holder[0].get("messages", []) if result_holder[0] else [], + "api_calls": result_holder[0].get("api_calls", 0) if result_holder[0] else 0, + "tools": tools_holder[0] or [], + "history_offset": _effective_history_offset, + "last_prompt_tokens": _last_prompt_toks, + "input_tokens": _input_toks, + "output_tokens": _output_toks, + "model": _resolved_model, + "session_id": effective_session_id, + "response_previewed": result.get("response_previewed", False), + } + + # Start progress message sender if enabled + progress_task = None + if tool_progress_enabled: + progress_task = asyncio.create_task(send_progress_messages()) + + # Start stream consumer task — polls for consumer creation since it + # happens inside run_sync (thread pool) after the agent is constructed. + stream_task = None + + async def _start_stream_consumer(): + """Wait for the stream consumer to be created, then run it.""" + for _ in range(200): # Up to 10s wait + if stream_consumer_holder[0] is not None: + await stream_consumer_holder[0].run() + return + await asyncio.sleep(0.05) + + stream_task = asyncio.create_task(_start_stream_consumer()) + + # Track this agent as running for this session (for interrupt support) + # We do this in a callback after the agent is created + async def track_agent(): + # Wait for agent to be created + while agent_holder[0] is None: + await asyncio.sleep(0.05) + if session_key: + self._running_agents[session_key] = agent_holder[0] + if self._draining: + self._update_runtime_status("draining") + + tracking_task = asyncio.create_task(track_agent()) + + # Monitor for interrupts from the adapter (new messages arriving). + # This is the PRIMARY interrupt path for regular text messages — + # Level 1 (base.py) catches them before _handle_message() is reached, + # so the Level 2 running_agent.interrupt() path never fires. + # The inactivity poll loop below has a BACKUP check in case this + # task dies (no error handling = silent death = lost interrupts). + _interrupt_detected = asyncio.Event() # shared with backup check + + async def monitor_for_interrupt(): + if not session_key: + return + + while True: + await asyncio.sleep(0.2) # Check every 200ms + try: + # Re-resolve adapter each iteration so reconnects don't + # leave us holding a stale reference. + _adapter = self.adapters.get(source.platform) + if not _adapter: + continue + # Check if adapter has a pending interrupt for this session. + # Must use session_key (build_session_key output) — NOT + # source.chat_id — because the adapter stores interrupt events + # under the full session key. + if hasattr(_adapter, 'has_pending_interrupt') and _adapter.has_pending_interrupt(session_key): + agent = agent_holder[0] + if agent: + # Peek at the pending message text WITHOUT consuming it. + # The message must remain in _pending_messages so the + # post-run dequeue at _dequeue_pending_event() can + # retrieve the full MessageEvent (with media metadata). + # If we pop here, a race exists: the agent may finish + # before checking _interrupt_requested, and the message + # is lost — neither the interrupt path nor the dequeue + # path finds it. + _peek_event = _adapter._pending_messages.get(session_key) + pending_text = _peek_event.text if _peek_event else None + logger.debug("Interrupt detected from adapter, signaling agent...") + agent.interrupt(pending_text) + _interrupt_detected.set() + break + except asyncio.CancelledError: + raise + except Exception as _mon_err: + logger.debug("monitor_for_interrupt error (will retry): %s", _mon_err) + + interrupt_monitor = asyncio.create_task(monitor_for_interrupt()) + + # Periodic "still working" notifications for long-running tasks. + # Fires every N seconds so the user knows the agent hasn't died. + # Config: agent.gateway_notify_interval in config.yaml, or + # HERMES_AGENT_NOTIFY_INTERVAL env var. Default 600s (10 min). + # 0 = disable notifications. + _NOTIFY_INTERVAL_RAW = float(os.getenv("HERMES_AGENT_NOTIFY_INTERVAL", 600)) + _NOTIFY_INTERVAL = _NOTIFY_INTERVAL_RAW if _NOTIFY_INTERVAL_RAW > 0 else None + _notify_start = time.time() + + async def _notify_long_running(): + if _NOTIFY_INTERVAL is None: + return # Notifications disabled (gateway_notify_interval: 0) + _notify_adapter = self.adapters.get(source.platform) + if not _notify_adapter: + return + while True: + await asyncio.sleep(_NOTIFY_INTERVAL) + _elapsed_mins = int((time.time() - _notify_start) // 60) + # Include agent activity context if available. + _agent_ref = agent_holder[0] + _status_detail = "" + if _agent_ref and hasattr(_agent_ref, "get_activity_summary"): + try: + _a = _agent_ref.get_activity_summary() + _parts = [f"iteration {_a['api_call_count']}/{_a['max_iterations']}"] + if _a.get("current_tool"): + _parts.append(f"running: {_a['current_tool']}") + else: + _parts.append(_a.get("last_activity_desc", "")) + _status_detail = " — " + ", ".join(_parts) + except Exception: + pass + try: + await _notify_adapter.send( + source.chat_id, + f"⏳ Still working... ({_elapsed_mins} min elapsed{_status_detail})", + metadata=_status_thread_metadata, + ) + except Exception as _ne: + logger.debug("Long-running notification error: %s", _ne) + + _notify_task = asyncio.create_task(_notify_long_running()) + + try: + # Run in thread pool to not block. Use an *inactivity*-based + # timeout instead of a wall-clock limit: the agent can run for + # hours if it's actively calling tools / receiving stream tokens, + # but a hung API call or stuck tool with no activity for the + # configured duration is caught and killed. (#4815) + # + # Config: agent.gateway_timeout in config.yaml, or + # HERMES_AGENT_TIMEOUT env var (env var takes precedence). + # Default 1800s (30 min inactivity). 0 = unlimited. + _agent_timeout_raw = float(os.getenv("HERMES_AGENT_TIMEOUT", 1800)) + _agent_timeout = _agent_timeout_raw if _agent_timeout_raw > 0 else None + _agent_warning_raw = float(os.getenv("HERMES_AGENT_TIMEOUT_WARNING", 900)) + _agent_warning = _agent_warning_raw if _agent_warning_raw > 0 else None + _warning_fired = False + loop = asyncio.get_event_loop() + _executor_task = asyncio.ensure_future( + loop.run_in_executor(None, run_sync) + ) + + _inactivity_timeout = False + _POLL_INTERVAL = 5.0 + + if _agent_timeout is None: + # Unlimited — still poll periodically for backup interrupt + # detection in case monitor_for_interrupt() silently died. + response = None + while True: + done, _ = await asyncio.wait( + {_executor_task}, timeout=_POLL_INTERVAL + ) + if done: + response = _executor_task.result() + break + # Backup interrupt check: if the monitor task died or + # missed the interrupt, catch it here. + if not _interrupt_detected.is_set() and session_key: + _backup_adapter = self.adapters.get(source.platform) + _backup_agent = agent_holder[0] + if (_backup_adapter and _backup_agent + and hasattr(_backup_adapter, 'has_pending_interrupt') + and _backup_adapter.has_pending_interrupt(session_key)): + _bp_event = _backup_adapter._pending_messages.get(session_key) + _bp_text = _bp_event.text if _bp_event else None + logger.info( + "Backup interrupt detected for session %s " + "(monitor task state: %s)", + session_key[:20], + "done" if interrupt_monitor.done() else "running", + ) + _backup_agent.interrupt(_bp_text) + _interrupt_detected.set() + else: + # Poll loop: check the agent's built-in activity tracker + # (updated by _touch_activity() on every tool call, API + # call, and stream delta) every few seconds. + response = None + while True: + done, _ = await asyncio.wait( + {_executor_task}, timeout=_POLL_INTERVAL + ) + if done: + response = _executor_task.result() + break + # Agent still running — check inactivity. + _agent_ref = agent_holder[0] + _idle_secs = 0.0 + if _agent_ref and hasattr(_agent_ref, "get_activity_summary"): + try: + _act = _agent_ref.get_activity_summary() + _idle_secs = _act.get("seconds_since_activity", 0.0) + except Exception: + pass + # Staged warning: fire once before escalating to full timeout. + if (not _warning_fired and _agent_warning is not None + and _idle_secs >= _agent_warning): + _warning_fired = True + _warn_adapter = self.adapters.get(source.platform) + if _warn_adapter: + _elapsed_warn = int(_agent_warning // 60) or 1 + _remaining_mins = int((_agent_timeout - _agent_warning) // 60) or 1 + try: + await _warn_adapter.send( + source.chat_id, + f"⚠️ No activity for {_elapsed_warn} min. " + f"If the agent does not respond soon, it will " + f"be timed out in {_remaining_mins} min. " + f"You can continue waiting or use /reset.", + metadata=_status_thread_metadata, + ) + except Exception as _warn_err: + logger.debug("Inactivity warning send error: %s", _warn_err) + if _idle_secs >= _agent_timeout: + _inactivity_timeout = True + break + # Backup interrupt check (same as unlimited path). + if not _interrupt_detected.is_set() and session_key: + _backup_adapter = self.adapters.get(source.platform) + _backup_agent = agent_holder[0] + if (_backup_adapter and _backup_agent + and hasattr(_backup_adapter, 'has_pending_interrupt') + and _backup_adapter.has_pending_interrupt(session_key)): + _bp_event = _backup_adapter._pending_messages.get(session_key) + _bp_text = _bp_event.text if _bp_event else None + logger.info( + "Backup interrupt detected for session %s " + "(monitor task state: %s)", + session_key[:20], + "done" if interrupt_monitor.done() else "running", + ) + _backup_agent.interrupt(_bp_text) + _interrupt_detected.set() + + if _inactivity_timeout: + # Build a diagnostic summary from the agent's activity tracker. + _timed_out_agent = agent_holder[0] + _activity = {} + if _timed_out_agent and hasattr(_timed_out_agent, "get_activity_summary"): + try: + _activity = _timed_out_agent.get_activity_summary() + except Exception: + pass + + _last_desc = _activity.get("last_activity_desc", "unknown") + _secs_ago = _activity.get("seconds_since_activity", 0) + _cur_tool = _activity.get("current_tool") + _iter_n = _activity.get("api_call_count", 0) + _iter_max = _activity.get("max_iterations", 0) + + logger.error( + "Agent idle for %.0fs (timeout %.0fs) in session %s " + "| last_activity=%s | iteration=%s/%s | tool=%s", + _secs_ago, _agent_timeout, session_key, + _last_desc, _iter_n, _iter_max, + _cur_tool or "none", + ) + + # Interrupt the agent if it's still running so the thread + # pool worker is freed. + if _timed_out_agent and hasattr(_timed_out_agent, "interrupt"): + _timed_out_agent.interrupt("Execution timed out (inactivity)") + + _timeout_mins = int(_agent_timeout // 60) or 1 + + # Construct a user-facing message with diagnostic context. + _diag_lines = [ + f"⏱️ Agent inactive for {_timeout_mins} min — no tool calls " + f"or API responses." + ] + if _cur_tool: + _diag_lines.append( + f"The agent appears stuck on tool `{_cur_tool}` " + f"({_secs_ago:.0f}s since last activity, " + f"iteration {_iter_n}/{_iter_max})." + ) + else: + _diag_lines.append( + f"Last activity: {_last_desc} ({_secs_ago:.0f}s ago, " + f"iteration {_iter_n}/{_iter_max}). " + "The agent may have been waiting on an API response." + ) + _diag_lines.append( + "To increase the limit, set agent.gateway_timeout in config.yaml " + "(value in seconds, 0 = no limit) and restart the gateway.\n" + "Try again, or use /reset to start fresh." + ) + + response = { + "final_response": "\n".join(_diag_lines), + "messages": result_holder[0].get("messages", []) if result_holder[0] else [], + "api_calls": _iter_n, + "tools": tools_holder[0] or [], + "history_offset": 0, + "failed": True, + } + + # Track fallback model state: if the agent switched to a + # fallback model during this run, persist it so /model shows + # the actually-active model instead of the config default. + # Skip eviction when the run failed — evicting a failed agent + # forces MCP reinit on the next message for no benefit (the + # same error will recur). This was the root cause of #7130: + # a bad model ID triggered fallback → eviction → recreation → + # MCP reinit → same 400 → loop, burning 91% CPU for hours. + _agent = agent_holder[0] + _result_for_fb = result_holder[0] + _run_failed = _result_for_fb.get("failed") if _result_for_fb else False + if _agent is not None and hasattr(_agent, 'model') and not _run_failed: + _cfg_model = _resolve_gateway_model() + if _agent.model != _cfg_model and not self._is_intentional_model_switch(session_key, _agent.model): + # Fallback activated on a successful run — evict cached + # agent so the next message retries the primary model. + self._evict_cached_agent(session_key) + + # Check if we were interrupted OR have a queued message (/queue). + result = result_holder[0] + adapter = self.adapters.get(source.platform) + + # Get pending message from adapter. + # Use session_key (not source.chat_id) to match adapter's storage keys. + pending_event = None + pending = None + if result and adapter and session_key: + pending_event = _dequeue_pending_event(adapter, session_key) + if result.get("interrupted") and not pending_event and result.get("interrupt_message"): + pending = result.get("interrupt_message") + elif pending_event: + pending = pending_event.text or _build_media_placeholder(pending_event) + logger.debug("Processing queued message after agent completion: '%s...'", pending[:40]) + + # Safety net: if the pending text is a slash command (e.g. "/stop", + # "/new"), discard it — commands should never be passed to the agent + # as user input. The primary fix is in base.py (commands bypass the + # active-session guard), but this catches edge cases where command + # text leaks through the interrupt_message fallback. + if pending and pending.strip().startswith("/"): + _pending_parts = pending.strip().split(None, 1) + _pending_cmd_word = _pending_parts[0][1:].lower() if _pending_parts else "" + if _pending_cmd_word: + try: + from hermes_cli.commands import resolve_command as _rc_pending + if _rc_pending(_pending_cmd_word): + logger.info( + "Discarding command '/%s' from pending queue — " + "commands must not be passed as agent input", + _pending_cmd_word, + ) + pending_event = None + pending = None + except Exception: + pass + + if self._draining and (pending_event or pending): + logger.info( + "Discarding pending follow-up for session %s during gateway %s", + session_key[:20] if session_key else "?", + self._status_action_label(), + ) + pending_event = None + pending = None + + if pending_event or pending: + logger.debug("Processing pending message: '%s...'", pending[:40]) + + # Clear the adapter's interrupt event so the next _run_agent call + # doesn't immediately re-trigger the interrupt before the new agent + # even makes its first API call (this was causing an infinite loop). + if adapter and hasattr(adapter, '_active_sessions') and session_key and session_key in adapter._active_sessions: + adapter._active_sessions[session_key].clear() + + # Cap recursion depth to prevent resource exhaustion when the + # user sends multiple messages while the agent keeps failing. (#816) + if _interrupt_depth >= self._MAX_INTERRUPT_DEPTH: + logger.warning( + "Interrupt recursion depth %d reached for session %s — " + "queueing message instead of recursing.", + _interrupt_depth, session_key, + ) + adapter = self.adapters.get(source.platform) + if adapter and pending_event: + merge_pending_message_event(adapter._pending_messages, session_key, pending_event) + elif adapter and hasattr(adapter, 'queue_message'): + adapter.queue_message(session_key, pending) + return result_holder[0] or {"final_response": response, "messages": history} + + was_interrupted = result.get("interrupted") + if not was_interrupted: + # Queued message after normal completion — deliver the first + # response before processing the queued follow-up. + # Skip if streaming already delivered it. + _sc = stream_consumer_holder[0] + if _sc and stream_task: + try: + await asyncio.wait_for(stream_task, timeout=5.0) + except (asyncio.TimeoutError, asyncio.CancelledError): + stream_task.cancel() + try: + await stream_task + except asyncio.CancelledError: + pass + except Exception as e: + logger.debug("Stream consumer wait before queued message failed: %s", e) + _response_previewed = bool(result.get("response_previewed")) + _already_streamed = bool( + _sc + and ( + getattr(_sc, "final_response_sent", False) + or ( + _response_previewed + and getattr(_sc, "already_sent", False) + ) + ) + ) + first_response = result.get("final_response", "") + if first_response and not _already_streamed: + try: + await adapter.send( + source.chat_id, + first_response, + metadata=_status_thread_metadata, + ) + except Exception as e: + logger.warning("Failed to send first response before queued message: %s", e) + # else: interrupted — discard the interrupted response ("Operation + # interrupted." is just noise; the user already knows they sent a + # new message). + + updated_history = result.get("messages", history) + next_source = source + next_message = pending + next_message_id = None + if pending_event is not None: + next_source = getattr(pending_event, "source", None) or source + next_message = await self._prepare_inbound_message_text( + event=pending_event, + source=next_source, + history=updated_history, + ) + if next_message is None: + return result + next_message_id = getattr(pending_event, "message_id", None) + + return await self._run_agent( + message=next_message, + context_prompt=context_prompt, + history=updated_history, + source=next_source, + session_id=session_id, + session_key=session_key, + _interrupt_depth=_interrupt_depth + 1, + event_message_id=next_message_id, + ) + finally: + # Stop progress sender, interrupt monitor, and notification task + if progress_task: + progress_task.cancel() + interrupt_monitor.cancel() + _notify_task.cancel() + + # Wait for stream consumer to finish its final edit + if stream_task: + try: + await asyncio.wait_for(stream_task, timeout=5.0) + except (asyncio.TimeoutError, asyncio.CancelledError): + stream_task.cancel() + try: + await stream_task + except asyncio.CancelledError: + pass + + # Clean up tracking + tracking_task.cancel() + if session_key and session_key in self._running_agents: + del self._running_agents[session_key] + if session_key: + self._running_agents_ts.pop(session_key, None) + if self._draining: + self._update_runtime_status("draining") + + # Wait for cancelled tasks + for task in [progress_task, interrupt_monitor, tracking_task, _notify_task]: + if task: + try: + await task + except asyncio.CancelledError: + pass + + # If streaming already delivered the response, mark it so the + # caller's send() is skipped (avoiding duplicate messages). + # BUT: never suppress delivery when the agent failed — the error + # message is new content the user hasn't seen, and it must reach + # them even if streaming had sent earlier partial output. + _sc = stream_consumer_holder[0] + if _sc and isinstance(response, dict) and not response.get("failed"): + _response_previewed = bool(response.get("response_previewed")) + if ( + getattr(_sc, "final_response_sent", False) + or ( + _response_previewed + and getattr(_sc, "already_sent", False) + ) + ): + response["already_sent"] = True + + return response + + +def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, interval: int = 60): + """ + Background thread that ticks the cron scheduler at a regular interval. + + Runs inside the gateway process so cronjobs fire automatically without + needing a separate `hermes cron daemon` or system cron entry. + + When ``adapters`` and ``loop`` are provided, passes them through to the + cron delivery path so live adapters can be used for E2EE rooms. + + Also refreshes the channel directory every 5 minutes and prunes the + image/audio/document cache once per hour. + """ + from cron.scheduler import tick as cron_tick + from gateway.platforms.base import cleanup_image_cache, cleanup_document_cache + + IMAGE_CACHE_EVERY = 60 # ticks — once per hour at default 60s interval + CHANNEL_DIR_EVERY = 5 # ticks — every 5 minutes + + logger.info("Cron ticker started (interval=%ds)", interval) + tick_count = 0 + while not stop_event.is_set(): + try: + cron_tick(verbose=False, adapters=adapters, loop=loop) + except Exception as e: + logger.debug("Cron tick error: %s", e) + + tick_count += 1 + + if tick_count % CHANNEL_DIR_EVERY == 0 and adapters: + try: + from gateway.channel_directory import build_channel_directory + build_channel_directory(adapters) + except Exception as e: + logger.debug("Channel directory refresh error: %s", e) + + if tick_count % IMAGE_CACHE_EVERY == 0: + try: + removed = cleanup_image_cache(max_age_hours=24) + if removed: + logger.info("Image cache cleanup: removed %d stale file(s)", removed) + except Exception as e: + logger.debug("Image cache cleanup error: %s", e) + try: + removed = cleanup_document_cache(max_age_hours=24) + if removed: + logger.info("Document cache cleanup: removed %d stale file(s)", removed) + except Exception as e: + logger.debug("Document cache cleanup error: %s", e) + + stop_event.wait(timeout=interval) + logger.info("Cron ticker stopped") + + +async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = False, verbosity: Optional[int] = 0) -> bool: + """ + Start the gateway and run until interrupted. + + This is the main entry point for running the gateway. + Returns True if the gateway ran successfully, False if it failed to start. + A False return causes a non-zero exit code so systemd can auto-restart. + + Args: + config: Optional gateway configuration override. + replace: If True, kill any existing gateway instance before starting. + Useful for systemd services to avoid restart-loop deadlocks + when the previous process hasn't fully exited yet. + """ + # ── Duplicate-instance guard ────────────────────────────────────── + # Prevent two gateways from running under the same HERMES_HOME. + # The PID file is scoped to HERMES_HOME, so future multi-profile + # setups (each profile using a distinct HERMES_HOME) will naturally + # allow concurrent instances without tripping this guard. + import time as _time + from gateway.status import get_running_pid, remove_pid_file, terminate_pid + existing_pid = get_running_pid() + if existing_pid is not None and existing_pid != os.getpid(): + if replace: + logger.info( + "Replacing existing gateway instance (PID %d) with --replace.", + existing_pid, + ) + try: + terminate_pid(existing_pid, force=False) + except ProcessLookupError: + pass # Already gone + except (PermissionError, OSError): + logger.error( + "Permission denied killing PID %d. Cannot replace.", + existing_pid, + ) + return False + # Wait up to 10 seconds for the old process to exit + for _ in range(20): + try: + os.kill(existing_pid, 0) + _time.sleep(0.5) + except (ProcessLookupError, PermissionError): + break # Process is gone + else: + # Still alive after 10s — force kill + logger.warning( + "Old gateway (PID %d) did not exit after SIGTERM, sending SIGKILL.", + existing_pid, + ) + try: + terminate_pid(existing_pid, force=True) + _time.sleep(0.5) + except (ProcessLookupError, PermissionError, OSError): + pass + remove_pid_file() + # Also release all scoped locks left by the old process. + # Stopped (Ctrl+Z) processes don't release locks on exit, + # leaving stale lock files that block the new gateway from starting. + try: + from gateway.status import release_all_scoped_locks + _released = release_all_scoped_locks() + if _released: + logger.info("Released %d stale scoped lock(s) from old gateway.", _released) + except Exception: + pass + else: + hermes_home = str(get_hermes_home()) + logger.error( + "Another gateway instance is already running (PID %d, HERMES_HOME=%s). " + "Use 'hermes gateway restart' to replace it, or 'hermes gateway stop' first.", + existing_pid, hermes_home, + ) + print( + f"\n❌ Gateway already running (PID {existing_pid}).\n" + f" Use 'hermes gateway restart' to replace it,\n" + f" or 'hermes gateway stop' to kill it first.\n" + f" Or use 'hermes gateway run --replace' to auto-replace.\n" + ) + return False + + # Sync bundled skills on gateway start (fast -- skips unchanged) + try: + from tools.skills_sync import sync_skills + sync_skills(quiet=True) + except Exception: + pass + + # Centralized logging — agent.log (INFO+), errors.log (WARNING+), + # and gateway.log (INFO+, gateway-component records only). + # Idempotent, so repeated calls from AIAgent.__init__ won't duplicate. + from hermes_logging import setup_logging + setup_logging(hermes_home=_hermes_home, mode="gateway") + + # Optional stderr handler — level driven by -v/-q flags on the CLI. + # verbosity=None (-q/--quiet): no stderr output + # verbosity=0 (default): WARNING and above + # verbosity=1 (-v): INFO and above + # verbosity=2+ (-vv/-vvv): DEBUG + if verbosity is not None: + from agent.redact import RedactingFormatter + + _stderr_level = {0: logging.WARNING, 1: logging.INFO}.get(verbosity, logging.DEBUG) + _stderr_handler = logging.StreamHandler() + _stderr_handler.setLevel(_stderr_level) + _stderr_handler.setFormatter(RedactingFormatter('%(levelname)s %(name)s: %(message)s')) + logging.getLogger().addHandler(_stderr_handler) + # Lower root logger level if needed so DEBUG records can reach the handler + if _stderr_level < logging.getLogger().level: + logging.getLogger().setLevel(_stderr_level) + + runner = GatewayRunner(config) + + # Set up signal handlers + def shutdown_signal_handler(): + asyncio.create_task(runner.stop()) + + def restart_signal_handler(): + runner.request_restart(detached=False, via_service=True) + + loop = asyncio.get_event_loop() + if threading.current_thread() is threading.main_thread(): + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, shutdown_signal_handler) + except NotImplementedError: + pass + if hasattr(signal, "SIGUSR1"): + try: + loop.add_signal_handler(signal.SIGUSR1, restart_signal_handler) + except NotImplementedError: + pass + else: + logger.info("Skipping signal handlers (not running in main thread).") + + # Start the gateway + success = await runner.start() + if not success: + return False + if runner.should_exit_cleanly: + if runner.exit_reason: + logger.error("Gateway exiting cleanly: %s", runner.exit_reason) + return True + + # Write PID file so CLI can detect gateway is running + import atexit + from gateway.status import write_pid_file, remove_pid_file + write_pid_file() + atexit.register(remove_pid_file) + + # Start background cron ticker so scheduled jobs fire automatically. + # Pass the event loop so cron delivery can use live adapters (E2EE support). + cron_stop = threading.Event() + cron_thread = threading.Thread( + target=_start_cron_ticker, + args=(cron_stop,), + kwargs={"adapters": runner.adapters, "loop": asyncio.get_running_loop()}, + daemon=True, + name="cron-ticker", + ) + cron_thread.start() + + # Wait for shutdown + await runner.wait_for_shutdown() + + if runner.should_exit_with_failure: + if runner.exit_reason: + logger.error("Gateway exiting with failure: %s", runner.exit_reason) + return False + + # Stop cron ticker cleanly + cron_stop.set() + cron_thread.join(timeout=5) + + # Close MCP server connections + try: + from tools.mcp_tool import shutdown_mcp_servers + shutdown_mcp_servers() + except Exception: + pass + + if runner.exit_code is not None: + raise SystemExit(runner.exit_code) + + return True + + +def main(): + """CLI entry point for the gateway.""" + import argparse + + parser = argparse.ArgumentParser(description="Hermes Gateway - Multi-platform messaging") + parser.add_argument("--config", "-c", help="Path to gateway config file") + parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") + + args = parser.parse_args() + + config = None + if args.config: + import json + with open(args.config, encoding="utf-8") as f: + data = json.load(f) + config = GatewayConfig.from_dict(data) + + # Run the gateway - exit with code 1 if no platforms connected, + # so systemd Restart=on-failure will retry on transient errors (e.g. DNS) + success = asyncio.run(start_gateway(config)) + if not success: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/mindcli/_vendor/gateway/session.py b/mindcli/_vendor/gateway/session.py new file mode 100644 index 0000000..33165dc --- /dev/null +++ b/mindcli/_vendor/gateway/session.py @@ -0,0 +1,1086 @@ +""" +Session management for the gateway. + +Handles: +- Session context tracking (where messages come from) +- Session storage (conversations persisted to disk) +- Reset policy evaluation (when to start fresh) +- Dynamic system prompt injection (agent knows its context) +""" + +import hashlib +import logging +import os +import json +import threading +import uuid +from pathlib import Path +from datetime import datetime, timedelta +from dataclasses import dataclass +from typing import Dict, List, Optional, Any + +logger = logging.getLogger(__name__) + + +def _now() -> datetime: + """Return the current local time.""" + return datetime.now() + + +# --------------------------------------------------------------------------- +# PII redaction helpers +# --------------------------------------------------------------------------- + +def _hash_id(value: str) -> str: + """Deterministic 12-char hex hash of an identifier.""" + return hashlib.sha256(value.encode("utf-8")).hexdigest()[:12] + + +def _hash_sender_id(value: str) -> str: + """Hash a sender ID to ``user_<12hex>``.""" + return f"user_{_hash_id(value)}" + + +def _hash_chat_id(value: str) -> str: + """Hash the numeric portion of a chat ID, preserving platform prefix. + + ``telegram:12345`` → ``telegram:<hash>`` + ``12345`` → ``<hash>`` + """ + colon = value.find(":") + if colon > 0: + prefix = value[:colon] + return f"{prefix}:{_hash_id(value[colon + 1:])}" + return _hash_id(value) + + +from .config import ( + Platform, + GatewayConfig, + SessionResetPolicy, # noqa: F401 — re-exported via gateway/__init__.py + HomeChannel, +) + + +@dataclass +class SessionSource: + """ + Describes where a message originated from. + + This information is used to: + 1. Route responses back to the right place + 2. Inject context into the system prompt + 3. Track origin for cron job delivery + """ + platform: Platform + chat_id: str + chat_name: Optional[str] = None + chat_type: str = "dm" # "dm", "group", "channel", "thread" + user_id: Optional[str] = None + user_name: Optional[str] = None + thread_id: Optional[str] = None # For forum topics, Discord threads, etc. + chat_topic: Optional[str] = None # Channel topic/description (Discord, Slack) + user_id_alt: Optional[str] = None # Signal UUID (alternative to phone number) + chat_id_alt: Optional[str] = None # Signal group internal ID + + @property + def description(self) -> str: + """Human-readable description of the source.""" + if self.platform == Platform.LOCAL: + return "CLI terminal" + + parts = [] + if self.chat_type == "dm": + parts.append(f"DM with {self.user_name or self.user_id or 'user'}") + elif self.chat_type == "group": + parts.append(f"group: {self.chat_name or self.chat_id}") + elif self.chat_type == "channel": + parts.append(f"channel: {self.chat_name or self.chat_id}") + else: + parts.append(self.chat_name or self.chat_id) + + if self.thread_id: + parts.append(f"thread: {self.thread_id}") + + return ", ".join(parts) + + def to_dict(self) -> Dict[str, Any]: + d = { + "platform": self.platform.value, + "chat_id": self.chat_id, + "chat_name": self.chat_name, + "chat_type": self.chat_type, + "user_id": self.user_id, + "user_name": self.user_name, + "thread_id": self.thread_id, + "chat_topic": self.chat_topic, + } + if self.user_id_alt: + d["user_id_alt"] = self.user_id_alt + if self.chat_id_alt: + d["chat_id_alt"] = self.chat_id_alt + return d + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "SessionSource": + return cls( + platform=Platform(data["platform"]), + chat_id=str(data["chat_id"]), + chat_name=data.get("chat_name"), + chat_type=data.get("chat_type", "dm"), + user_id=data.get("user_id"), + user_name=data.get("user_name"), + thread_id=data.get("thread_id"), + chat_topic=data.get("chat_topic"), + user_id_alt=data.get("user_id_alt"), + chat_id_alt=data.get("chat_id_alt"), + ) + + + +@dataclass +class SessionContext: + """ + Full context for a session, used for dynamic system prompt injection. + + The agent receives this information to understand: + - Where messages are coming from + - What platforms are available + - Where it can deliver scheduled task outputs + """ + source: SessionSource + connected_platforms: List[Platform] + home_channels: Dict[Platform, HomeChannel] + + # Session metadata + session_key: str = "" + session_id: str = "" + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + def to_dict(self) -> Dict[str, Any]: + return { + "source": self.source.to_dict(), + "connected_platforms": [p.value for p in self.connected_platforms], + "home_channels": { + p.value: hc.to_dict() for p, hc in self.home_channels.items() + }, + "session_key": self.session_key, + "session_id": self.session_id, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + +_PII_SAFE_PLATFORMS = frozenset({ + Platform.WHATSAPP, + Platform.SIGNAL, + Platform.TELEGRAM, + Platform.BLUEBUBBLES, +}) +"""Platforms where user IDs can be safely redacted (no in-message mention system +that requires raw IDs). Discord is excluded because mentions use ``<@user_id>`` +and the LLM needs the real ID to tag users.""" + + +def build_session_context_prompt( + context: SessionContext, + *, + redact_pii: bool = False, +) -> str: + """ + Build the dynamic system prompt section that tells the agent about its context. + + This is injected into the system prompt so the agent knows: + - Where messages are coming from + - What platforms are connected + - Where it can deliver scheduled task outputs + + When *redact_pii* is True **and** the source platform is in + ``_PII_SAFE_PLATFORMS``, phone numbers are stripped and user/chat IDs + are replaced with deterministic hashes before being sent to the LLM. + Platforms like Discord are excluded because mentions need real IDs. + Routing still uses the original values (they stay in SessionSource). + """ + # Only apply redaction on platforms where IDs aren't needed for mentions + redact_pii = redact_pii and context.source.platform in _PII_SAFE_PLATFORMS + lines = [ + "## Current Session Context", + "", + ] + + # Source info + platform_name = context.source.platform.value.title() + if context.source.platform == Platform.LOCAL: + lines.append(f"**Source:** {platform_name} (the machine running this agent)") + else: + # Build a description that respects PII redaction + src = context.source + if redact_pii: + # Build a safe description without raw IDs + _uname = src.user_name or ( + _hash_sender_id(src.user_id) if src.user_id else "user" + ) + _cname = src.chat_name or _hash_chat_id(src.chat_id) + if src.chat_type == "dm": + desc = f"DM with {_uname}" + elif src.chat_type == "group": + desc = f"group: {_cname}" + elif src.chat_type == "channel": + desc = f"channel: {_cname}" + else: + desc = _cname + else: + desc = src.description + lines.append(f"**Source:** {platform_name} ({desc})") + + # Channel topic (if available - provides context about the channel's purpose) + if context.source.chat_topic: + lines.append(f"**Channel Topic:** {context.source.chat_topic}") + + # User identity. + # In shared thread sessions (non-DM with thread_id), multiple users + # contribute to the same conversation. Don't pin a single user name + # in the system prompt — it changes per-turn and would bust the prompt + # cache. Instead, note that this is a multi-user thread; individual + # sender names are prefixed on each user message by the gateway. + _is_shared_thread = ( + context.source.chat_type != "dm" + and context.source.thread_id + ) + if _is_shared_thread: + lines.append( + "**Session type:** Multi-user thread — messages are prefixed " + "with [sender name]. Multiple users may participate." + ) + elif context.source.user_name: + lines.append(f"**User:** {context.source.user_name}") + elif context.source.user_id: + uid = context.source.user_id + if redact_pii: + uid = _hash_sender_id(uid) + lines.append(f"**User ID:** {uid}") + + # Platform-specific behavioral notes + if context.source.platform == Platform.SLACK: + lines.append("") + lines.append( + "**Platform notes:** You are running inside Slack. " + "You do NOT have access to Slack-specific APIs — you cannot search " + "channel history, pin/unpin messages, manage channels, or list users. " + "Do not promise to perform these actions. If the user asks, explain " + "that you can only read messages sent directly to you and respond." + ) + elif context.source.platform == Platform.DISCORD: + lines.append("") + lines.append( + "**Platform notes:** You are running inside Discord. " + "You do NOT have access to Discord-specific APIs — you cannot search " + "channel history, pin messages, manage roles, or list server members. " + "Do not promise to perform these actions. If the user asks, explain " + "that you can only read messages sent directly to you and respond." + ) + + # Connected platforms + platforms_list = ["local (files on this machine)"] + for p in context.connected_platforms: + if p != Platform.LOCAL: + platforms_list.append(f"{p.value}: Connected ✓") + + lines.append(f"**Connected Platforms:** {', '.join(platforms_list)}") + + # Home channels + if context.home_channels: + lines.append("") + lines.append("**Home Channels (default destinations):**") + for platform, home in context.home_channels.items(): + hc_id = _hash_chat_id(home.chat_id) if redact_pii else home.chat_id + lines.append(f" - {platform.value}: {home.name} (ID: {hc_id})") + + # Delivery options for scheduled tasks + lines.append("") + lines.append("**Delivery options for scheduled tasks:**") + + # Origin delivery + if context.source.platform == Platform.LOCAL: + lines.append("- `\"origin\"` → Local output (saved to files)") + else: + _origin_label = context.source.chat_name or ( + _hash_chat_id(context.source.chat_id) if redact_pii else context.source.chat_id + ) + lines.append(f"- `\"origin\"` → Back to this chat ({_origin_label})") + + # Local always available + lines.append("- `\"local\"` → Save to local files only (~/.hermes/cron/output/)") + + # Platform home channels + for platform, home in context.home_channels.items(): + lines.append(f"- `\"{platform.value}\"` → Home channel ({home.name})") + + # Note about explicit targeting + lines.append("") + lines.append("*For explicit targeting, use `\"platform:chat_id\"` format if the user provides a specific chat ID.*") + + return "\n".join(lines) + + +@dataclass +class SessionEntry: + """ + Entry in the session store. + + Maps a session key to its current session ID and metadata. + """ + session_key: str + session_id: str + created_at: datetime + updated_at: datetime + + # Origin metadata for delivery routing + origin: Optional[SessionSource] = None + + # Display metadata + display_name: Optional[str] = None + platform: Optional[Platform] = None + chat_type: str = "dm" + + # Token tracking + input_tokens: int = 0 + output_tokens: int = 0 + cache_read_tokens: int = 0 + cache_write_tokens: int = 0 + total_tokens: int = 0 + estimated_cost_usd: float = 0.0 + cost_status: str = "unknown" + + # Last API-reported prompt tokens (for accurate compression pre-check) + last_prompt_tokens: int = 0 + + # Set when a session was created because the previous one expired; + # consumed once by the message handler to inject a notice into context + was_auto_reset: bool = False + auto_reset_reason: Optional[str] = None # "idle" or "daily" + reset_had_activity: bool = False # whether the expired session had any messages + + # Set by the background expiry watcher after it successfully flushes + # memories for this session. Persisted to sessions.json so the flag + # survives gateway restarts (the old in-memory _pre_flushed_sessions + # set was lost on restart, causing redundant re-flushes). + memory_flushed: bool = False + + # When True the next call to get_or_create_session() will auto-reset + # this session (create a new session_id) so the user starts fresh. + # Set by /stop to break stuck-resume loops (#7536). + suspended: bool = False + + def to_dict(self) -> Dict[str, Any]: + result = { + "session_key": self.session_key, + "session_id": self.session_id, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + "display_name": self.display_name, + "platform": self.platform.value if self.platform else None, + "chat_type": self.chat_type, + "input_tokens": self.input_tokens, + "output_tokens": self.output_tokens, + "cache_read_tokens": self.cache_read_tokens, + "cache_write_tokens": self.cache_write_tokens, + "total_tokens": self.total_tokens, + "last_prompt_tokens": self.last_prompt_tokens, + "estimated_cost_usd": self.estimated_cost_usd, + "cost_status": self.cost_status, + "memory_flushed": self.memory_flushed, + "suspended": self.suspended, + } + if self.origin: + result["origin"] = self.origin.to_dict() + return result + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "SessionEntry": + origin = None + if "origin" in data and data["origin"]: + origin = SessionSource.from_dict(data["origin"]) + + platform = None + if data.get("platform"): + try: + platform = Platform(data["platform"]) + except ValueError as e: + logger.debug("Unknown platform value %r: %s", data["platform"], e) + + return cls( + session_key=data["session_key"], + session_id=data["session_id"], + created_at=datetime.fromisoformat(data["created_at"]), + updated_at=datetime.fromisoformat(data["updated_at"]), + origin=origin, + display_name=data.get("display_name"), + platform=platform, + chat_type=data.get("chat_type", "dm"), + input_tokens=data.get("input_tokens", 0), + output_tokens=data.get("output_tokens", 0), + cache_read_tokens=data.get("cache_read_tokens", 0), + cache_write_tokens=data.get("cache_write_tokens", 0), + total_tokens=data.get("total_tokens", 0), + last_prompt_tokens=data.get("last_prompt_tokens", 0), + estimated_cost_usd=data.get("estimated_cost_usd", 0.0), + cost_status=data.get("cost_status", "unknown"), + memory_flushed=data.get("memory_flushed", False), + suspended=data.get("suspended", False), + ) + + +def build_session_key( + source: SessionSource, + group_sessions_per_user: bool = True, + thread_sessions_per_user: bool = False, +) -> str: + """Build a deterministic session key from a message source. + + This is the single source of truth for session key construction. + + DM rules: + - DMs include chat_id when present, so each private conversation is isolated. + - thread_id further differentiates threaded DMs within the same DM chat. + - Without chat_id, thread_id is used as a best-effort fallback. + - Without thread_id or chat_id, DMs share a single session. + + Group/channel rules: + - chat_id identifies the parent group/channel. + - user_id/user_id_alt isolates participants within that parent chat when available when + ``group_sessions_per_user`` is enabled. + - thread_id differentiates threads within that parent chat. When + ``thread_sessions_per_user`` is False (default), threads are *shared* across all + participants — user_id is NOT appended, so every user in the thread + shares a single session. This is the expected UX for threaded + conversations (Telegram forum topics, Discord threads, Slack threads). + - Without participant identifiers, or when isolation is disabled, messages fall back to one + shared session per chat. + - Without identifiers, messages fall back to one session per platform/chat_type. + """ + platform = source.platform.value + if source.chat_type == "dm": + if source.chat_id: + if source.thread_id: + return f"agent:main:{platform}:dm:{source.chat_id}:{source.thread_id}" + return f"agent:main:{platform}:dm:{source.chat_id}" + if source.thread_id: + return f"agent:main:{platform}:dm:{source.thread_id}" + return f"agent:main:{platform}:dm" + + participant_id = source.user_id_alt or source.user_id + key_parts = ["agent:main", platform, source.chat_type] + + if source.chat_id: + key_parts.append(source.chat_id) + if source.thread_id: + key_parts.append(source.thread_id) + + # In threads, default to shared sessions (all participants see the same + # conversation). Per-user isolation only applies when explicitly enabled + # via thread_sessions_per_user, or when there is no thread (regular group). + isolate_user = group_sessions_per_user + if source.thread_id and not thread_sessions_per_user: + isolate_user = False + + if isolate_user and participant_id: + key_parts.append(str(participant_id)) + + return ":".join(key_parts) + + +class SessionStore: + """ + Manages session storage and retrieval. + + Uses SQLite (via SessionDB) for session metadata and message transcripts. + Falls back to legacy JSONL files if SQLite is unavailable. + """ + + def __init__(self, sessions_dir: Path, config: GatewayConfig, + has_active_processes_fn=None): + self.sessions_dir = sessions_dir + self.config = config + self._entries: Dict[str, SessionEntry] = {} + self._loaded = False + self._lock = threading.Lock() + self._has_active_processes_fn = has_active_processes_fn + + # Initialize SQLite session database + self._db = None + try: + from hermes_state import SessionDB + self._db = SessionDB() + except Exception as e: + print(f"[gateway] Warning: SQLite session store unavailable, falling back to JSONL: {e}") + + def _ensure_loaded(self) -> None: + """Load sessions index from disk if not already loaded.""" + with self._lock: + self._ensure_loaded_locked() + + def _ensure_loaded_locked(self) -> None: + """Load sessions index from disk. Must be called with self._lock held.""" + if self._loaded: + return + + self.sessions_dir.mkdir(parents=True, exist_ok=True) + sessions_file = self.sessions_dir / "sessions.json" + + if sessions_file.exists(): + try: + with open(sessions_file, "r", encoding="utf-8") as f: + data = json.load(f) + for key, entry_data in data.items(): + try: + self._entries[key] = SessionEntry.from_dict(entry_data) + except (ValueError, KeyError): + # Skip entries with unknown/removed platform values + continue + except Exception as e: + print(f"[gateway] Warning: Failed to load sessions: {e}") + + self._loaded = True + + def _save(self) -> None: + """Save sessions index to disk (kept for session key -> ID mapping).""" + import tempfile + self.sessions_dir.mkdir(parents=True, exist_ok=True) + sessions_file = self.sessions_dir / "sessions.json" + + data = {key: entry.to_dict() for key, entry in self._entries.items()} + fd, tmp_path = tempfile.mkstemp( + dir=str(self.sessions_dir), suffix=".tmp", prefix=".sessions_" + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, sessions_file) + except BaseException: + try: + os.unlink(tmp_path) + except OSError as e: + logger.debug("Could not remove temp file %s: %s", tmp_path, e) + raise + + def _generate_session_key(self, source: SessionSource) -> str: + """Generate a session key from a source.""" + return build_session_key( + source, + group_sessions_per_user=getattr(self.config, "group_sessions_per_user", True), + thread_sessions_per_user=getattr(self.config, "thread_sessions_per_user", False), + ) + + def _is_session_expired(self, entry: SessionEntry) -> bool: + """Check if a session has expired based on its reset policy. + + Works from the entry alone — no SessionSource needed. + Used by the background expiry watcher to proactively flush memories. + Sessions with active background processes are never considered expired. + """ + if self._has_active_processes_fn: + if self._has_active_processes_fn(entry.session_key): + return False + + policy = self.config.get_reset_policy( + platform=entry.platform, + session_type=entry.chat_type, + ) + + if policy.mode == "none": + return False + + now = _now() + + if policy.mode in ("idle", "both"): + idle_deadline = entry.updated_at + timedelta(minutes=policy.idle_minutes) + if now > idle_deadline: + return True + + if policy.mode in ("daily", "both"): + today_reset = now.replace( + hour=policy.at_hour, + minute=0, second=0, microsecond=0, + ) + if now.hour < policy.at_hour: + today_reset -= timedelta(days=1) + if entry.updated_at < today_reset: + return True + + return False + + def _should_reset(self, entry: SessionEntry, source: SessionSource) -> Optional[str]: + """ + Check if a session should be reset based on policy. + + Returns the reset reason ("idle" or "daily") if a reset is needed, + or None if the session is still valid. + + Sessions with active background processes are never reset. + """ + if self._has_active_processes_fn: + session_key = self._generate_session_key(source) + if self._has_active_processes_fn(session_key): + return None + + policy = self.config.get_reset_policy( + platform=source.platform, + session_type=source.chat_type + ) + + if policy.mode == "none": + return None + + now = _now() + + if policy.mode in ("idle", "both"): + idle_deadline = entry.updated_at + timedelta(minutes=policy.idle_minutes) + if now > idle_deadline: + return "idle" + + if policy.mode in ("daily", "both"): + today_reset = now.replace( + hour=policy.at_hour, + minute=0, + second=0, + microsecond=0 + ) + if now.hour < policy.at_hour: + today_reset -= timedelta(days=1) + + if entry.updated_at < today_reset: + return "daily" + + return None + + def has_any_sessions(self) -> bool: + """Check if any sessions have ever been created (across all platforms). + + Uses the SQLite database as the source of truth because it preserves + historical session records (ended sessions still count). The in-memory + ``_entries`` dict replaces entries on reset, so ``len(_entries)`` would + stay at 1 for single-platform users — which is the bug this fixes. + + The current session is already in the DB by the time this is called + (get_or_create_session runs first), so we check ``> 1``. + """ + if self._db: + try: + return self._db.session_count() > 1 + except Exception: + pass # fall through to heuristic + # Fallback: check if sessions.json was loaded with existing data. + # This covers the rare case where the DB is unavailable. + with self._lock: + self._ensure_loaded_locked() + return len(self._entries) > 1 + + def get_or_create_session( + self, + source: SessionSource, + force_new: bool = False + ) -> SessionEntry: + """ + Get an existing session or create a new one. + + Evaluates reset policy to determine if the existing session is stale. + Creates a session record in SQLite when a new session starts. + """ + session_key = self._generate_session_key(source) + now = _now() + + # SQLite calls are made outside the lock to avoid holding it during I/O. + # All _entries / _loaded mutations are protected by self._lock. + db_end_session_id = None + db_create_kwargs = None + + with self._lock: + self._ensure_loaded_locked() + + if session_key in self._entries and not force_new: + entry = self._entries[session_key] + + # Auto-reset sessions marked as suspended (e.g. after /stop + # broke a stuck loop — #7536). + if entry.suspended: + reset_reason = "suspended" + else: + reset_reason = self._should_reset(entry, source) + if not reset_reason: + entry.updated_at = now + self._save() + return entry + else: + # Session is being auto-reset. + was_auto_reset = True + auto_reset_reason = reset_reason + # Track whether the expired session had any real conversation + reset_had_activity = entry.total_tokens > 0 + db_end_session_id = entry.session_id + else: + was_auto_reset = False + auto_reset_reason = None + reset_had_activity = False + + # Create new session + session_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}" + + entry = SessionEntry( + session_key=session_key, + session_id=session_id, + created_at=now, + updated_at=now, + origin=source, + display_name=source.chat_name, + platform=source.platform, + chat_type=source.chat_type, + was_auto_reset=was_auto_reset, + auto_reset_reason=auto_reset_reason, + reset_had_activity=reset_had_activity, + ) + + self._entries[session_key] = entry + self._save() + db_create_kwargs = { + "session_id": session_id, + "source": source.platform.value, + "user_id": source.user_id, + } + + # SQLite operations outside the lock + if self._db and db_end_session_id: + try: + self._db.end_session(db_end_session_id, "session_reset") + except Exception as e: + logger.debug("Session DB operation failed: %s", e) + + if self._db and db_create_kwargs: + try: + self._db.create_session(**db_create_kwargs) + except Exception as e: + print(f"[gateway] Warning: Failed to create SQLite session: {e}") + + return entry + + def update_session( + self, + session_key: str, + last_prompt_tokens: int = None, + ) -> None: + """Update lightweight session metadata after an interaction.""" + with self._lock: + self._ensure_loaded_locked() + + if session_key in self._entries: + entry = self._entries[session_key] + entry.updated_at = _now() + if last_prompt_tokens is not None: + entry.last_prompt_tokens = last_prompt_tokens + self._save() + + def suspend_session(self, session_key: str) -> bool: + """Mark a session as suspended so it auto-resets on next access. + + Used by ``/stop`` to prevent stuck sessions from being resumed + after a gateway restart (#7536). Returns True if the session + existed and was marked. + """ + with self._lock: + self._ensure_loaded_locked() + if session_key in self._entries: + self._entries[session_key].suspended = True + self._save() + return True + return False + + def suspend_recently_active(self, max_age_seconds: int = 120) -> int: + """Mark recently-active sessions as suspended. + + Called on gateway startup to prevent sessions that were likely + in-flight when the gateway last exited from being blindly resumed + (#7536). Only suspends sessions updated within *max_age_seconds* + to avoid resetting long-idle sessions that are harmless to resume. + Returns the number of sessions that were suspended. + """ + from datetime import timedelta + + cutoff = _now() - timedelta(seconds=max_age_seconds) + count = 0 + with self._lock: + self._ensure_loaded_locked() + for entry in self._entries.values(): + if not entry.suspended and entry.updated_at >= cutoff: + entry.suspended = True + count += 1 + if count: + self._save() + return count + + def reset_session(self, session_key: str) -> Optional[SessionEntry]: + """Force reset a session, creating a new session ID.""" + db_end_session_id = None + db_create_kwargs = None + new_entry = None + + with self._lock: + self._ensure_loaded_locked() + + if session_key not in self._entries: + return None + + old_entry = self._entries[session_key] + db_end_session_id = old_entry.session_id + + now = _now() + session_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}" + + new_entry = SessionEntry( + session_key=session_key, + session_id=session_id, + created_at=now, + updated_at=now, + origin=old_entry.origin, + display_name=old_entry.display_name, + platform=old_entry.platform, + chat_type=old_entry.chat_type, + ) + + self._entries[session_key] = new_entry + self._save() + db_create_kwargs = { + "session_id": session_id, + "source": old_entry.platform.value if old_entry.platform else "unknown", + "user_id": old_entry.origin.user_id if old_entry.origin else None, + } + + if self._db and db_end_session_id: + try: + self._db.end_session(db_end_session_id, "session_reset") + except Exception as e: + logger.debug("Session DB operation failed: %s", e) + + if self._db and db_create_kwargs: + try: + self._db.create_session(**db_create_kwargs) + except Exception as e: + logger.debug("Session DB operation failed: %s", e) + + return new_entry + + def switch_session(self, session_key: str, target_session_id: str) -> Optional[SessionEntry]: + """Switch a session key to point at an existing session ID. + + Used by ``/resume`` to restore a previously-named session. + Ends the current session in SQLite (like reset), but instead of + generating a fresh session ID, re-uses ``target_session_id`` so the + old transcript is loaded on the next message. If the target session was + previously ended, re-open it so gateway resume semantics match the CLI. + """ + db_end_session_id = None + new_entry = None + + with self._lock: + self._ensure_loaded_locked() + + if session_key not in self._entries: + return None + + old_entry = self._entries[session_key] + + # Don't switch if already on that session + if old_entry.session_id == target_session_id: + return old_entry + + db_end_session_id = old_entry.session_id + + now = _now() + new_entry = SessionEntry( + session_key=session_key, + session_id=target_session_id, + created_at=now, + updated_at=now, + origin=old_entry.origin, + display_name=old_entry.display_name, + platform=old_entry.platform, + chat_type=old_entry.chat_type, + ) + + self._entries[session_key] = new_entry + self._save() + + if self._db and db_end_session_id: + try: + self._db.end_session(db_end_session_id, "session_switch") + except Exception as e: + logger.debug("Session DB end_session failed: %s", e) + + if self._db: + try: + self._db.reopen_session(target_session_id) + except Exception as e: + logger.debug("Session DB reopen_session failed: %s", e) + + return new_entry + + def list_sessions(self, active_minutes: Optional[int] = None) -> List[SessionEntry]: + """List all sessions, optionally filtered by activity.""" + with self._lock: + self._ensure_loaded_locked() + entries = list(self._entries.values()) + + if active_minutes is not None: + cutoff = _now() - timedelta(minutes=active_minutes) + entries = [e for e in entries if e.updated_at >= cutoff] + + entries.sort(key=lambda e: e.updated_at, reverse=True) + + return entries + + def get_transcript_path(self, session_id: str) -> Path: + """Get the path to a session's legacy transcript file.""" + return self.sessions_dir / f"{session_id}.jsonl" + + def append_to_transcript(self, session_id: str, message: Dict[str, Any], skip_db: bool = False) -> None: + """Append a message to a session's transcript (SQLite + legacy JSONL). + + Args: + skip_db: When True, only write to JSONL and skip the SQLite write. + Used when the agent already persisted messages to SQLite + via its own _flush_messages_to_session_db(), preventing + the duplicate-write bug (#860). + """ + # Write to SQLite (unless the agent already handled it) + if self._db and not skip_db: + try: + self._db.append_message( + session_id=session_id, + role=message.get("role", "unknown"), + content=message.get("content"), + tool_name=message.get("tool_name"), + tool_calls=message.get("tool_calls"), + tool_call_id=message.get("tool_call_id"), + ) + except Exception as e: + logger.debug("Session DB operation failed: %s", e) + + # Also write legacy JSONL (keeps existing tooling working during transition) + transcript_path = self.get_transcript_path(session_id) + with open(transcript_path, "a", encoding="utf-8") as f: + f.write(json.dumps(message, ensure_ascii=False) + "\n") + + def rewrite_transcript(self, session_id: str, messages: List[Dict[str, Any]]) -> None: + """Replace the entire transcript for a session with new messages. + + Used by /retry, /undo, and /compress to persist modified conversation history. + Rewrites both SQLite and legacy JSONL storage. + """ + # SQLite: clear old messages and re-insert + if self._db: + try: + self._db.clear_messages(session_id) + for msg in messages: + role = msg.get("role", "unknown") + self._db.append_message( + session_id=session_id, + role=role, + content=msg.get("content"), + tool_name=msg.get("tool_name"), + tool_calls=msg.get("tool_calls"), + tool_call_id=msg.get("tool_call_id"), + reasoning=msg.get("reasoning") if role == "assistant" else None, + reasoning_details=msg.get("reasoning_details") if role == "assistant" else None, + codex_reasoning_items=msg.get("codex_reasoning_items") if role == "assistant" else None, + ) + except Exception as e: + logger.debug("Failed to rewrite transcript in DB: %s", e) + + # JSONL: overwrite the file + transcript_path = self.get_transcript_path(session_id) + with open(transcript_path, "w", encoding="utf-8") as f: + for msg in messages: + f.write(json.dumps(msg, ensure_ascii=False) + "\n") + + def load_transcript(self, session_id: str) -> List[Dict[str, Any]]: + """Load all messages from a session's transcript.""" + db_messages = [] + # Try SQLite first + if self._db: + try: + db_messages = self._db.get_messages_as_conversation(session_id) + except Exception as e: + logger.debug("Could not load messages from DB: %s", e) + + # Load legacy JSONL transcript (may contain more history than SQLite + # for sessions created before the DB layer was introduced). + transcript_path = self.get_transcript_path(session_id) + jsonl_messages = [] + if transcript_path.exists(): + with open(transcript_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + try: + jsonl_messages.append(json.loads(line)) + except json.JSONDecodeError: + logger.warning( + "Skipping corrupt line in transcript %s: %s", + session_id, line[:120], + ) + + # Prefer whichever source has more messages. + # + # Background: when a session pre-dates SQLite storage (or when the DB + # layer was added while a long-lived session was already active), the + # first post-migration turn writes only the *new* messages to SQLite + # (because _flush_messages_to_session_db skips messages already in + # conversation_history, assuming they're persisted). On the *next* + # turn load_transcript returns those few SQLite rows and ignores the + # full JSONL history — the model sees a context of 1-4 messages instead + # of hundreds. Using the longer source prevents this silent truncation. + if len(jsonl_messages) > len(db_messages): + if db_messages: + logger.debug( + "Session %s: JSONL has %d messages vs SQLite %d — " + "using JSONL (legacy session not yet fully migrated)", + session_id, len(jsonl_messages), len(db_messages), + ) + return jsonl_messages + + return db_messages + + +def build_session_context( + source: SessionSource, + config: GatewayConfig, + session_entry: Optional[SessionEntry] = None +) -> SessionContext: + """ + Build a full session context from a source and config. + + This is used to inject context into the agent's system prompt. + """ + connected = config.get_connected_platforms() + + home_channels = {} + for platform in connected: + home = config.get_home_channel(platform) + if home: + home_channels[platform] = home + + context = SessionContext( + source=source, + connected_platforms=connected, + home_channels=home_channels, + ) + + if session_entry: + context.session_key = session_entry.session_key + context.session_id = session_entry.session_id + context.created_at = session_entry.created_at + context.updated_at = session_entry.updated_at + + return context diff --git a/mindcli/_vendor/gateway/session_context.py b/mindcli/_vendor/gateway/session_context.py new file mode 100644 index 0000000..84ed07a --- /dev/null +++ b/mindcli/_vendor/gateway/session_context.py @@ -0,0 +1,146 @@ +""" +Session-scoped context variables for the Hermes gateway. + +Replaces the previous ``os.environ``-based session state +(``HERMES_SESSION_PLATFORM``, ``HERMES_SESSION_CHAT_ID``, etc.) with +Python's ``contextvars.ContextVar``. + +**Why this matters** + +The gateway processes messages concurrently via ``asyncio``. When two +messages arrive at the same time the old code did: + + os.environ["HERMES_SESSION_THREAD_ID"] = str(context.source.thread_id) + +Because ``os.environ`` is *process-global*, Message A's value was +silently overwritten by Message B before Message A's agent finished +running. Background-task notifications and tool calls therefore routed +to the wrong thread. + +``contextvars.ContextVar`` values are *task-local*: each ``asyncio`` +task (and any ``run_in_executor`` thread it spawns) gets its own copy, +so concurrent messages never interfere. + +**Backward compatibility** + +The public helper ``get_session_env(name, default="")`` mirrors the old +``os.getenv("HERMES_SESSION_*", ...)`` calls. Existing tool code only +needs to replace the import + call site: + + # before + import os + platform = os.getenv("HERMES_SESSION_PLATFORM", "") + + # after + from gateway.session_context import get_session_env + platform = get_session_env("HERMES_SESSION_PLATFORM", "") +""" + +from contextvars import ContextVar + +# --------------------------------------------------------------------------- +# Per-task session variables +# --------------------------------------------------------------------------- + +_SESSION_PLATFORM: ContextVar[str] = ContextVar("HERMES_SESSION_PLATFORM", default="") +_SESSION_CHAT_ID: ContextVar[str] = ContextVar("HERMES_SESSION_CHAT_ID", default="") +_SESSION_CHAT_NAME: ContextVar[str] = ContextVar("HERMES_SESSION_CHAT_NAME", default="") +_SESSION_THREAD_ID: ContextVar[str] = ContextVar("HERMES_SESSION_THREAD_ID", default="") +_SESSION_USER_ID: ContextVar[str] = ContextVar("HERMES_SESSION_USER_ID", default="") +_SESSION_USER_NAME: ContextVar[str] = ContextVar("HERMES_SESSION_USER_NAME", default="") +_SESSION_KEY: ContextVar[str] = ContextVar("HERMES_SESSION_KEY", default="") +_SESSION_SKILLS_DIRS: ContextVar[str] = ContextVar("HERMES_SESSION_SKILLS_DIRS", default="") + +_VAR_MAP = { + "HERMES_SESSION_PLATFORM": _SESSION_PLATFORM, + "HERMES_SESSION_CHAT_ID": _SESSION_CHAT_ID, + "HERMES_SESSION_CHAT_NAME": _SESSION_CHAT_NAME, + "HERMES_SESSION_THREAD_ID": _SESSION_THREAD_ID, + "HERMES_SESSION_USER_ID": _SESSION_USER_ID, + "HERMES_SESSION_USER_NAME": _SESSION_USER_NAME, + "HERMES_SESSION_KEY": _SESSION_KEY, + "HERMES_SESSION_SKILLS_DIRS": _SESSION_SKILLS_DIRS, +} + + +def set_session_env(name: str, value: str): + """Set a session context variable by its ``HERMES_SESSION_*`` name. + + Returns the reset token (pass to ``var.reset(token)`` to restore). + If the variable name is unknown, sets ``os.environ`` as fallback. + """ + import os + + var = _VAR_MAP.get(name) + if var is not None: + return var.set(value) + # Fallback: 未知变量名写入 os.environ(CLI 兼容) + os.environ[name] = value + return None + + +def set_session_vars( + platform: str = "", + chat_id: str = "", + chat_name: str = "", + thread_id: str = "", + user_id: str = "", + user_name: str = "", + session_key: str = "", +) -> list: + """Set all session context variables and return reset tokens. + + Call ``clear_session_vars(tokens)`` in a ``finally`` block to restore + the previous values when the handler exits. + + Returns a list of ``Token`` objects (one per variable) that can be + passed to ``clear_session_vars``. + """ + tokens = [ + _SESSION_PLATFORM.set(platform), + _SESSION_CHAT_ID.set(chat_id), + _SESSION_CHAT_NAME.set(chat_name), + _SESSION_THREAD_ID.set(thread_id), + _SESSION_USER_ID.set(user_id), + _SESSION_USER_NAME.set(user_name), + _SESSION_KEY.set(session_key), + ] + return tokens + + +def clear_session_vars(tokens: list) -> None: + """Restore session context variables to their pre-handler values.""" + if not tokens: + return + vars_in_order = [ + _SESSION_PLATFORM, + _SESSION_CHAT_ID, + _SESSION_CHAT_NAME, + _SESSION_THREAD_ID, + _SESSION_USER_ID, + _SESSION_USER_NAME, + _SESSION_KEY, + ] + for var, token in zip(vars_in_order, tokens): + var.reset(token) + + +def get_session_env(name: str, default: str = "") -> str: + """Read a session context variable by its legacy ``HERMES_SESSION_*`` name. + + Drop-in replacement for ``os.getenv("HERMES_SESSION_*", default)``. + + Resolution order: + 1. Context variable (set by the gateway for concurrency-safe access) + 2. ``os.environ`` (used by CLI, cron scheduler, and tests) + 3. *default* + """ + import os + + var = _VAR_MAP.get(name) + if var is not None: + value = var.get() + if value: + return value + # Fall back to os.environ for CLI, cron, and test compatibility + return os.getenv(name, default) diff --git a/mindcli/_vendor/gateway/status.py b/mindcli/_vendor/gateway/status.py new file mode 100644 index 0000000..a801cfe --- /dev/null +++ b/mindcli/_vendor/gateway/status.py @@ -0,0 +1,439 @@ +""" +Gateway runtime status helpers. + +Provides PID-file based detection of whether the gateway daemon is running, +used by send_message's check_fn to gate availability in the CLI. + +The PID file lives at ``{HERMES_HOME}/gateway.pid``. HERMES_HOME defaults to +``~/.hermes`` but can be overridden via the environment variable. This means +separate HERMES_HOME directories naturally get separate PID files — a property +that will be useful when we add named profiles (multiple agents running +concurrently under distinct configurations). +""" + +import hashlib +import json +import os +import signal +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from hermes_constants import get_hermes_home +from typing import Any, Optional + +_GATEWAY_KIND = "hermes-gateway" +_RUNTIME_STATUS_FILE = "gateway_state.json" +_LOCKS_DIRNAME = "gateway-locks" +_IS_WINDOWS = sys.platform == "win32" +_UNSET = object() + + +def _get_pid_path() -> Path: + """Return the path to the gateway PID file, respecting HERMES_HOME.""" + home = get_hermes_home() + return home / "gateway.pid" + + +def _get_runtime_status_path() -> Path: + """Return the persisted runtime health/status file path.""" + return _get_pid_path().with_name(_RUNTIME_STATUS_FILE) + + +def _get_lock_dir() -> Path: + """Return the machine-local directory for token-scoped gateway locks.""" + override = os.getenv("HERMES_GATEWAY_LOCK_DIR") + if override: + return Path(override) + state_home = Path(os.getenv("XDG_STATE_HOME", Path.home() / ".local" / "state")) + return state_home / "hermes" / _LOCKS_DIRNAME + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def terminate_pid(pid: int, *, force: bool = False) -> None: + """Terminate a PID with platform-appropriate force semantics. + + POSIX uses SIGTERM/SIGKILL. Windows uses taskkill /T /F for true force-kill + because os.kill(..., SIGTERM) is not equivalent to a tree-killing hard stop. + """ + if force and _IS_WINDOWS: + try: + result = subprocess.run( + ["taskkill", "/PID", str(pid), "/T", "/F"], + capture_output=True, + text=True, + timeout=10, + ) + except FileNotFoundError: + os.kill(pid, signal.SIGTERM) + return + + if result.returncode != 0: + details = (result.stderr or result.stdout or "").strip() + raise OSError(details or f"taskkill failed for PID {pid}") + return + + sig = signal.SIGTERM if not force else getattr(signal, "SIGKILL", signal.SIGTERM) + os.kill(pid, sig) + + +def _scope_hash(identity: str) -> str: + return hashlib.sha256(identity.encode("utf-8")).hexdigest()[:16] + + +def _get_scope_lock_path(scope: str, identity: str) -> Path: + return _get_lock_dir() / f"{scope}-{_scope_hash(identity)}.lock" + + +def _get_process_start_time(pid: int) -> Optional[int]: + """Return the kernel start time for a process when available.""" + stat_path = Path(f"/proc/{pid}/stat") + try: + # Field 22 in /proc/<pid>/stat is process start time (clock ticks). + return int(stat_path.read_text().split()[21]) + except (FileNotFoundError, IndexError, PermissionError, ValueError, OSError): + return None + + +def _read_process_cmdline(pid: int) -> Optional[str]: + """Return the process command line as a space-separated string.""" + cmdline_path = Path(f"/proc/{pid}/cmdline") + try: + raw = cmdline_path.read_bytes() + except (FileNotFoundError, PermissionError, OSError): + return None + + if not raw: + return None + return raw.replace(b"\x00", b" ").decode("utf-8", errors="ignore").strip() + + +def _looks_like_gateway_process(pid: int) -> bool: + """Return True when the live PID still looks like the Hermes gateway.""" + cmdline = _read_process_cmdline(pid) + if not cmdline: + return False + + patterns = ( + "hermes_cli.main gateway", + "hermes_cli/main.py gateway", + "hermes gateway", + "gateway/run.py", + ) + return any(pattern in cmdline for pattern in patterns) + + +def _record_looks_like_gateway(record: dict[str, Any]) -> bool: + """Validate gateway identity from PID-file metadata when cmdline is unavailable.""" + if record.get("kind") != _GATEWAY_KIND: + return False + + argv = record.get("argv") + if not isinstance(argv, list) or not argv: + return False + + cmdline = " ".join(str(part) for part in argv) + patterns = ( + "hermes_cli.main gateway", + "hermes_cli/main.py gateway", + "hermes gateway", + "gateway/run.py", + ) + return any(pattern in cmdline for pattern in patterns) + + +def _build_pid_record() -> dict: + return { + "pid": os.getpid(), + "kind": _GATEWAY_KIND, + "argv": list(sys.argv), + "start_time": _get_process_start_time(os.getpid()), + } + + +def _build_runtime_status_record() -> dict[str, Any]: + payload = _build_pid_record() + payload.update({ + "gateway_state": "starting", + "exit_reason": None, + "restart_requested": False, + "active_agents": 0, + "platforms": {}, + "updated_at": _utc_now_iso(), + }) + return payload + + +def _read_json_file(path: Path) -> Optional[dict[str, Any]]: + if not path.exists(): + return None + try: + raw = path.read_text().strip() + except OSError: + return None + if not raw: + return None + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return None + return payload if isinstance(payload, dict) else None + + +def _write_json_file(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload)) + + +def _read_pid_record() -> Optional[dict]: + pid_path = _get_pid_path() + if not pid_path.exists(): + return None + + raw = pid_path.read_text().strip() + if not raw: + return None + + try: + payload = json.loads(raw) + except json.JSONDecodeError: + try: + return {"pid": int(raw)} + except ValueError: + return None + + if isinstance(payload, int): + return {"pid": payload} + if isinstance(payload, dict): + return payload + return None + + +def write_pid_file() -> None: + """Write the current process PID and metadata to the gateway PID file.""" + _write_json_file(_get_pid_path(), _build_pid_record()) + + +def write_runtime_status( + *, + gateway_state: Any = _UNSET, + exit_reason: Any = _UNSET, + restart_requested: Any = _UNSET, + active_agents: Any = _UNSET, + platform: Any = _UNSET, + platform_state: Any = _UNSET, + error_code: Any = _UNSET, + error_message: Any = _UNSET, +) -> None: + """Persist gateway runtime health information for diagnostics/status.""" + path = _get_runtime_status_path() + payload = _read_json_file(path) or _build_runtime_status_record() + payload.setdefault("platforms", {}) + payload.setdefault("kind", _GATEWAY_KIND) + payload["pid"] = os.getpid() + payload["start_time"] = _get_process_start_time(os.getpid()) + payload["updated_at"] = _utc_now_iso() + + if gateway_state is not _UNSET: + payload["gateway_state"] = gateway_state + if exit_reason is not _UNSET: + payload["exit_reason"] = exit_reason + if restart_requested is not _UNSET: + payload["restart_requested"] = bool(restart_requested) + if active_agents is not _UNSET: + payload["active_agents"] = max(0, int(active_agents)) + + if platform is not _UNSET: + platform_payload = payload["platforms"].get(platform, {}) + if platform_state is not _UNSET: + platform_payload["state"] = platform_state + if error_code is not _UNSET: + platform_payload["error_code"] = error_code + if error_message is not _UNSET: + platform_payload["error_message"] = error_message + platform_payload["updated_at"] = _utc_now_iso() + payload["platforms"][platform] = platform_payload + + _write_json_file(path, payload) + + +def read_runtime_status() -> Optional[dict[str, Any]]: + """Read the persisted gateway runtime health/status information.""" + return _read_json_file(_get_runtime_status_path()) + + +def remove_pid_file() -> None: + """Remove the gateway PID file if it exists.""" + try: + _get_pid_path().unlink(missing_ok=True) + except Exception: + pass + + +def acquire_scoped_lock(scope: str, identity: str, metadata: Optional[dict[str, Any]] = None) -> tuple[bool, Optional[dict[str, Any]]]: + """Acquire a machine-local lock keyed by scope + identity. + + Used to prevent multiple local gateways from using the same external identity + at once (e.g. the same Telegram bot token across different HERMES_HOME dirs). + """ + lock_path = _get_scope_lock_path(scope, identity) + lock_path.parent.mkdir(parents=True, exist_ok=True) + record = { + **_build_pid_record(), + "scope": scope, + "identity_hash": _scope_hash(identity), + "metadata": metadata or {}, + "updated_at": _utc_now_iso(), + } + + existing = _read_json_file(lock_path) + if existing is None and lock_path.exists(): + # Lock file exists but is empty or contains invalid JSON — treat as + # stale. This happens when a previous process was killed between + # O_CREAT|O_EXCL and the subsequent json.dump() (e.g. DNS failure + # during rapid Slack reconnect retries). + try: + lock_path.unlink(missing_ok=True) + except OSError: + pass + if existing: + try: + existing_pid = int(existing["pid"]) + except (KeyError, TypeError, ValueError): + existing_pid = None + + if existing_pid == os.getpid() and existing.get("start_time") == record.get("start_time"): + _write_json_file(lock_path, record) + return True, existing + + stale = existing_pid is None + if not stale: + try: + os.kill(existing_pid, 0) + except (ProcessLookupError, PermissionError): + stale = True + else: + current_start = _get_process_start_time(existing_pid) + if ( + existing.get("start_time") is not None + and current_start is not None + and current_start != existing.get("start_time") + ): + stale = True + # Check if process is stopped (Ctrl+Z / SIGTSTP) — stopped + # processes still respond to os.kill(pid, 0) but are not + # actually running. Treat them as stale so --replace works. + if not stale: + try: + _proc_status = Path(f"/proc/{existing_pid}/status") + if _proc_status.exists(): + for _line in _proc_status.read_text().splitlines(): + if _line.startswith("State:"): + _state = _line.split()[1] + if _state in ("T", "t"): # stopped or tracing stop + stale = True + break + except (OSError, PermissionError): + pass + if stale: + try: + lock_path.unlink(missing_ok=True) + except OSError: + pass + else: + return False, existing + + try: + fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError: + return False, _read_json_file(lock_path) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(record, handle) + except Exception: + try: + lock_path.unlink(missing_ok=True) + except OSError: + pass + raise + return True, None + + +def release_scoped_lock(scope: str, identity: str) -> None: + """Release a previously-acquired scope lock when owned by this process.""" + lock_path = _get_scope_lock_path(scope, identity) + existing = _read_json_file(lock_path) + if not existing: + return + if existing.get("pid") != os.getpid(): + return + if existing.get("start_time") != _get_process_start_time(os.getpid()): + return + try: + lock_path.unlink(missing_ok=True) + except OSError: + pass + + +def release_all_scoped_locks() -> int: + """Remove all scoped lock files in the lock directory. + + Called during --replace to clean up stale locks left by stopped/killed + gateway processes that did not release their locks gracefully. + Returns the number of lock files removed. + """ + lock_dir = _get_lock_dir() + removed = 0 + if lock_dir.exists(): + for lock_file in lock_dir.glob("*.lock"): + try: + lock_file.unlink(missing_ok=True) + removed += 1 + except OSError: + pass + return removed + + +def get_running_pid() -> Optional[int]: + """Return the PID of a running gateway instance, or ``None``. + + Checks the PID file and verifies the process is actually alive. + Cleans up stale PID files automatically. + """ + record = _read_pid_record() + if not record: + remove_pid_file() + return None + + try: + pid = int(record["pid"]) + except (KeyError, TypeError, ValueError): + remove_pid_file() + return None + + try: + os.kill(pid, 0) # signal 0 = existence check, no actual signal sent + except (ProcessLookupError, PermissionError): + remove_pid_file() + return None + + recorded_start = record.get("start_time") + current_start = _get_process_start_time(pid) + if recorded_start is not None and current_start is not None and current_start != recorded_start: + remove_pid_file() + return None + + if not _looks_like_gateway_process(pid): + if not _record_looks_like_gateway(record): + remove_pid_file() + return None + + return pid + + +def is_gateway_running() -> bool: + """Check if the gateway daemon is currently running.""" + return get_running_pid() is not None diff --git a/mindcli/_vendor/gateway/sticker_cache.py b/mindcli/_vendor/gateway/sticker_cache.py new file mode 100644 index 0000000..f3b8740 --- /dev/null +++ b/mindcli/_vendor/gateway/sticker_cache.py @@ -0,0 +1,111 @@ +""" +Sticker description cache for Telegram. + +When users send stickers, we describe them via the vision tool and cache +the descriptions keyed by file_unique_id so we don't re-analyze the same +sticker image on every send. Descriptions are concise (1-2 sentences). + +Cache location: ~/.hermes/sticker_cache.json +""" + +import json +import time +from typing import Optional + +from hermes_cli.config import get_hermes_home + + +CACHE_PATH = get_hermes_home() / "sticker_cache.json" + +# Vision prompt for describing stickers -- kept concise to save tokens +STICKER_VISION_PROMPT = ( + "Describe this sticker in 1-2 sentences. Focus on what it depicts -- " + "character, action, emotion. Be concise and objective." +) + + +def _load_cache() -> dict: + """Load the sticker cache from disk.""" + if CACHE_PATH.exists(): + try: + return json.loads(CACHE_PATH.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {} + return {} + + +def _save_cache(cache: dict) -> None: + """Save the sticker cache to disk.""" + CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + CACHE_PATH.write_text( + json.dumps(cache, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + +def get_cached_description(file_unique_id: str) -> Optional[dict]: + """ + Look up a cached sticker description. + + Returns: + dict with keys {description, emoji, set_name, cached_at} or None. + """ + cache = _load_cache() + return cache.get(file_unique_id) + + +def cache_sticker_description( + file_unique_id: str, + description: str, + emoji: str = "", + set_name: str = "", +) -> None: + """ + Store a sticker description in the cache. + + Args: + file_unique_id: Telegram's stable sticker identifier. + description: Vision-generated description text. + emoji: Associated emoji (e.g. "😀"). + set_name: Sticker set name if available. + """ + cache = _load_cache() + cache[file_unique_id] = { + "description": description, + "emoji": emoji, + "set_name": set_name, + "cached_at": time.time(), + } + _save_cache(cache) + + +def build_sticker_injection( + description: str, + emoji: str = "", + set_name: str = "", +) -> str: + """ + Build the warm-style injection text for a sticker description. + + Returns a string like: + [The user sent a sticker 😀 from "MyPack"~ It shows: "A cat waving" (=^.w.^=)] + """ + context = "" + if set_name and emoji: + context = f" {emoji} from \"{set_name}\"" + elif emoji: + context = f" {emoji}" + + return f"[The user sent a sticker{context}~ It shows: \"{description}\" (=^.w.^=)]" + + +def build_animated_sticker_injection(emoji: str = "") -> str: + """ + Build injection text for animated/video stickers we can't analyze. + """ + if emoji: + return ( + f"[The user sent an animated sticker {emoji}~ " + f"I can't see animated ones yet, but the emoji suggests: {emoji}]" + ) + return "[The user sent an animated sticker~ I can't see animated ones yet]" diff --git a/mindcli/_vendor/gateway/stream_consumer.py b/mindcli/_vendor/gateway/stream_consumer.py new file mode 100644 index 0000000..e6d96c8 --- /dev/null +++ b/mindcli/_vendor/gateway/stream_consumer.py @@ -0,0 +1,744 @@ +"""Gateway streaming consumer — bridges sync agent callbacks to async platform delivery. + +The agent fires stream_delta_callback(text) synchronously from its worker thread. +GatewayStreamConsumer: + 1. Receives deltas via on_delta() (thread-safe, sync) + 2. Queues them to an asyncio task via queue.Queue + 3. The async run() task buffers, rate-limits, and progressively edits + a single message on the target platform + +Design: Uses the edit transport (send initial message, then editMessageText). +This is universally supported across Telegram, Discord, and Slack. + +Credit: jobless0x (#774, #1312), OutThisLife (#798), clicksingh (#697). +""" + +from __future__ import annotations + +import asyncio +import logging +import queue +import re +import time +from dataclasses import dataclass +from typing import Any, Optional + +logger = logging.getLogger("gateway.stream_consumer") + +# Sentinel to signal the stream is complete +_DONE = object() + +# Sentinel to signal a tool boundary — finalize current message and start a +# new one so that subsequent text appears below tool progress messages. +_NEW_SEGMENT = object() + +# Queue marker for a completed assistant commentary message emitted between +# API/tool iterations (for example: "I'll inspect the repo first."). +_COMMENTARY = object() + + +@dataclass +class StreamConsumerConfig: + """Runtime config for a single stream consumer instance.""" + edit_interval: float = 1.0 + buffer_threshold: int = 40 + cursor: str = " ▉" + + +class GatewayStreamConsumer: + """Async consumer that progressively edits a platform message with streamed tokens. + + Usage:: + + consumer = GatewayStreamConsumer(adapter, chat_id, config, metadata=metadata) + # Pass consumer.on_delta as stream_delta_callback to AIAgent + agent = AIAgent(..., stream_delta_callback=consumer.on_delta) + # Start the consumer as an asyncio task + task = asyncio.create_task(consumer.run()) + # ... run agent in thread pool ... + consumer.finish() # signal completion + await task # wait for final edit + """ + + # After this many consecutive flood-control failures, permanently disable + # progressive edits for the remainder of the stream. + _MAX_FLOOD_STRIKES = 3 + + # Reasoning/thinking tags that models emit inline in content. + # Must stay in sync with cli.py _OPEN_TAGS/_CLOSE_TAGS and + # run_agent.py _strip_think_blocks() tag variants. + _OPEN_THINK_TAGS = ( + "<REASONING_SCRATCHPAD>", "<think>", "<reasoning>", + "<THINKING>", "<thinking>", "<thought>", + ) + _CLOSE_THINK_TAGS = ( + "</REASONING_SCRATCHPAD>", "</think>", "</reasoning>", + "</THINKING>", "</thinking>", "</thought>", + ) + + def __init__( + self, + adapter: Any, + chat_id: str, + config: Optional[StreamConsumerConfig] = None, + metadata: Optional[dict] = None, + ): + self.adapter = adapter + self.chat_id = chat_id + self.cfg = config or StreamConsumerConfig() + self.metadata = metadata + self._queue: queue.Queue = queue.Queue() + self._accumulated = "" + self._message_id: Optional[str] = None + self._already_sent = False + self._edit_supported = True # Disabled when progressive edits are no longer usable + self._last_edit_time = 0.0 + self._last_sent_text = "" # Track last-sent text to skip redundant edits + self._fallback_final_send = False + self._fallback_prefix = "" + self._flood_strikes = 0 # Consecutive flood-control edit failures + self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff + self._final_response_sent = False + + # Think-block filter state (mirrors CLI's _stream_delta tag suppression) + self._in_think_block = False + self._think_buffer = "" + + @property + def already_sent(self) -> bool: + """True if at least one message was sent or edited during the run.""" + return self._already_sent + + @property + def final_response_sent(self) -> bool: + """True when the stream consumer delivered the final assistant reply.""" + return self._final_response_sent + + def on_segment_break(self) -> None: + """Finalize the current stream segment and start a fresh message.""" + self._queue.put(_NEW_SEGMENT) + + def on_commentary(self, text: str) -> None: + """Queue a completed interim assistant commentary message.""" + if text: + self._queue.put((_COMMENTARY, text)) + + def _reset_segment_state(self, *, preserve_no_edit: bool = False) -> None: + if preserve_no_edit and self._message_id == "__no_edit__": + return + self._message_id = None + self._accumulated = "" + self._last_sent_text = "" + self._fallback_final_send = False + self._fallback_prefix = "" + + def on_delta(self, text: str) -> None: + """Thread-safe callback — called from the agent's worker thread. + + When *text* is ``None``, signals a tool boundary: the current message + is finalized and subsequent text will be sent as a new message so it + appears below any tool-progress messages the gateway sent in between. + """ + if text: + self._queue.put(text) + elif text is None: + self.on_segment_break() + + def finish(self) -> None: + """Signal that the stream is complete.""" + self._queue.put(_DONE) + + # ── Think-block filtering ──────────────────────────────────────── + # Models like MiniMax emit inline <think>...</think> blocks in their + # content. The CLI's _stream_delta suppresses these via a state + # machine; we do the same here so gateway users never see raw + # reasoning tags. The agent also strips them from the final + # response (run_agent.py _strip_think_blocks), but the stream + # consumer sends intermediate edits before that stripping happens. + + def _filter_and_accumulate(self, text: str) -> None: + """Add a text delta to the accumulated buffer, suppressing think blocks. + + Uses a state machine that tracks whether we are inside a + reasoning/thinking block. Text inside such blocks is silently + discarded. Partial tags at buffer boundaries are held back in + ``_think_buffer`` until enough characters arrive to decide. + """ + buf = self._think_buffer + text + self._think_buffer = "" + + while buf: + if self._in_think_block: + # Look for the earliest closing tag + best_idx = -1 + best_len = 0 + for tag in self._CLOSE_THINK_TAGS: + idx = buf.find(tag) + if idx != -1 and (best_idx == -1 or idx < best_idx): + best_idx = idx + best_len = len(tag) + + if best_len: + # Found closing tag — discard block, process remainder + self._in_think_block = False + buf = buf[best_idx + best_len:] + else: + # No closing tag yet — hold tail that could be a + # partial closing tag prefix, discard the rest. + max_tag = max(len(t) for t in self._CLOSE_THINK_TAGS) + self._think_buffer = buf[-max_tag:] if len(buf) > max_tag else buf + return + else: + # Look for earliest opening tag at a block boundary + # (start of text / preceded by newline + optional whitespace). + # This prevents false positives when models *mention* tags + # in prose (e.g. "the <think> tag is used for…"). + best_idx = -1 + best_len = 0 + for tag in self._OPEN_THINK_TAGS: + search_start = 0 + while True: + idx = buf.find(tag, search_start) + if idx == -1: + break + # Block-boundary check (mirrors cli.py logic) + if idx == 0: + is_boundary = ( + not self._accumulated + or self._accumulated.endswith("\n") + ) + else: + preceding = buf[:idx] + last_nl = preceding.rfind("\n") + if last_nl == -1: + is_boundary = ( + (not self._accumulated + or self._accumulated.endswith("\n")) + and preceding.strip() == "" + ) + else: + is_boundary = preceding[last_nl + 1:].strip() == "" + + if is_boundary and (best_idx == -1 or idx < best_idx): + best_idx = idx + best_len = len(tag) + break # first boundary hit for this tag is enough + search_start = idx + 1 + + if best_len: + # Emit text before the tag, enter think block + self._accumulated += buf[:best_idx] + self._in_think_block = True + buf = buf[best_idx + best_len:] + else: + # No opening tag — check for a partial tag at the tail + held_back = 0 + for tag in self._OPEN_THINK_TAGS: + for i in range(1, len(tag)): + if buf.endswith(tag[:i]) and i > held_back: + held_back = i + if held_back: + self._accumulated += buf[:-held_back] + self._think_buffer = buf[-held_back:] + else: + self._accumulated += buf + return + + def _flush_think_buffer(self) -> None: + """Flush any held-back partial-tag buffer into accumulated text. + + Called when the stream ends (got_done) so that partial text that + was held back waiting for a possible opening tag is not lost. + """ + if self._think_buffer and not self._in_think_block: + self._accumulated += self._think_buffer + self._think_buffer = "" + + async def run(self) -> None: + """Async task that drains the queue and edits the platform message.""" + # Platform message length limit — leave room for cursor + formatting + _raw_limit = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096) + _safe_limit = max(500, _raw_limit - len(self.cfg.cursor) - 100) + + try: + while True: + # Drain all available items from the queue + got_done = False + got_segment_break = False + commentary_text = None + while True: + try: + item = self._queue.get_nowait() + if item is _DONE: + got_done = True + break + if item is _NEW_SEGMENT: + got_segment_break = True + break + if isinstance(item, tuple) and len(item) == 2 and item[0] is _COMMENTARY: + commentary_text = item[1] + break + self._filter_and_accumulate(item) + except queue.Empty: + break + + # Flush any held-back partial-tag buffer on stream end + # so trailing text that was waiting for a potential open + # tag is not lost. + if got_done: + self._flush_think_buffer() + + # Decide whether to flush an edit + now = time.monotonic() + elapsed = now - self._last_edit_time + should_edit = ( + got_done + or got_segment_break + or commentary_text is not None + or (elapsed >= self._current_edit_interval + and self._accumulated) + or len(self._accumulated) >= self.cfg.buffer_threshold + ) + + current_update_visible = False + if should_edit and self._accumulated: + # Split overflow: if accumulated text exceeds the platform + # limit, split into properly sized chunks. + if ( + len(self._accumulated) > _safe_limit + and self._message_id is None + ): + # No existing message to edit (first message or after a + # segment break). Use truncate_message — the same + # helper the non-streaming path uses — to split with + # proper word/code-fence boundaries and chunk + # indicators like "(1/2)". + chunks = self.adapter.truncate_message( + self._accumulated, _safe_limit + ) + for chunk in chunks: + await self._send_new_chunk(chunk, self._message_id) + self._accumulated = "" + self._last_sent_text = "" + self._last_edit_time = time.monotonic() + if got_done: + self._final_response_sent = self._already_sent + return + if got_segment_break: + self._message_id = None + self._fallback_final_send = False + self._fallback_prefix = "" + continue + + # Existing message: edit it with the first chunk, then + # start a new message for the overflow remainder. + while ( + len(self._accumulated) > _safe_limit + and self._message_id is not None + and self._edit_supported + ): + split_at = self._accumulated.rfind("\n", 0, _safe_limit) + if split_at < _safe_limit // 2: + split_at = _safe_limit + chunk = self._accumulated[:split_at] + ok = await self._send_or_edit(chunk) + if self._fallback_final_send or not ok: + # Edit failed (or backed off due to flood control) + # while attempting to split an oversized message. + # Keep the full accumulated text intact so the + # fallback final-send path can deliver the remaining + # continuation without dropping content. + break + self._accumulated = self._accumulated[split_at:].lstrip("\n") + self._message_id = None + self._last_sent_text = "" + + display_text = self._accumulated + if not got_done and not got_segment_break and commentary_text is None: + display_text += self.cfg.cursor + + current_update_visible = await self._send_or_edit(display_text) + self._last_edit_time = time.monotonic() + + if got_done: + # Final edit without cursor. If progressive editing failed + # mid-stream, send a single continuation/fallback message + # here instead of letting the base gateway path send the + # full response again. + if self._accumulated: + if self._fallback_final_send: + await self._send_fallback_final(self._accumulated) + elif current_update_visible: + self._final_response_sent = True + elif self._message_id: + self._final_response_sent = await self._send_or_edit(self._accumulated) + elif not self._already_sent: + self._final_response_sent = await self._send_or_edit(self._accumulated) + return + + if commentary_text is not None: + self._reset_segment_state() + await self._send_commentary(commentary_text) + self._last_edit_time = time.monotonic() + self._reset_segment_state() + + # Tool boundary: reset message state so the next text chunk + # creates a fresh message below any tool-progress messages. + # + # Exception: when _message_id is "__no_edit__" the platform + # never returned a real message ID (e.g. Signal, webhook with + # github_comment delivery). Resetting to None would re-enter + # the "first send" path on every tool boundary and post one + # platform message per tool call — that is what caused 155 + # comments under a single PR. Instead, preserve the sentinel + # so the full continuation is delivered once via + # _send_fallback_final. + # (When editing fails mid-stream due to flood control the id is + # a real string like "msg_1", not "__no_edit__", so that case + # still resets and creates a fresh segment as intended.) + if got_segment_break: + self._reset_segment_state(preserve_no_edit=True) + + await asyncio.sleep(0.05) # Small yield to not busy-loop + + except asyncio.CancelledError: + # Best-effort final edit on cancellation + if self._accumulated and self._message_id: + try: + await self._send_or_edit(self._accumulated) + except Exception: + pass + # If we delivered any content before being cancelled, mark the + # final response as sent so the gateway's already_sent check + # doesn't trigger a duplicate message. The 5-second + # stream_task timeout (gateway/run.py) can cancel us while + # waiting on a slow Telegram API call — without this flag the + # gateway falls through to the normal send path. + if self._already_sent: + self._final_response_sent = True + except Exception as e: + logger.error("Stream consumer error: %s", e) + + # Pattern to strip MEDIA:<path> tags (including optional surrounding quotes). + # Matches the simple cleanup regex used by the non-streaming path in + # gateway/platforms/base.py for post-processing. + _MEDIA_RE = re.compile(r'''[`"']?MEDIA:\s*\S+[`"']?''') + + @staticmethod + def _clean_for_display(text: str) -> str: + """Strip MEDIA: directives and internal markers from text before display. + + The streaming path delivers raw text chunks that may include + ``MEDIA:<path>`` tags and ``[[audio_as_voice]]`` directives meant for + the platform adapter's post-processing. The actual media files are + delivered separately via ``_deliver_media_from_response()`` after the + stream finishes — we just need to hide the raw directives from the + user. + """ + if "MEDIA:" not in text and "[[audio_as_voice]]" not in text: + return text + cleaned = text.replace("[[audio_as_voice]]", "") + cleaned = GatewayStreamConsumer._MEDIA_RE.sub("", cleaned) + # Collapse excessive blank lines left behind by removed tags + cleaned = re.sub(r'\n{3,}', '\n\n', cleaned) + # Strip trailing whitespace/newlines but preserve leading content + return cleaned.rstrip() + + async def _send_new_chunk(self, text: str, reply_to_id: Optional[str]) -> Optional[str]: + """Send a new message chunk, optionally threaded to a previous message. + + Returns the message_id so callers can thread subsequent chunks. + """ + text = self._clean_for_display(text) + if not text.strip(): + return reply_to_id + try: + meta = dict(self.metadata) if self.metadata else {} + result = await self.adapter.send( + chat_id=self.chat_id, + content=text, + reply_to=reply_to_id, + metadata=meta, + ) + if result.success and result.message_id: + self._message_id = str(result.message_id) + self._already_sent = True + self._last_sent_text = text + return str(result.message_id) + else: + self._edit_supported = False + return reply_to_id + except Exception as e: + logger.error("Stream send chunk error: %s", e) + return reply_to_id + + def _visible_prefix(self) -> str: + """Return the visible text already shown in the streamed message.""" + prefix = self._last_sent_text or "" + if self.cfg.cursor and prefix.endswith(self.cfg.cursor): + prefix = prefix[:-len(self.cfg.cursor)] + return self._clean_for_display(prefix) + + def _continuation_text(self, final_text: str) -> str: + """Return only the part of final_text the user has not already seen.""" + prefix = self._fallback_prefix or self._visible_prefix() + if prefix and final_text.startswith(prefix): + return final_text[len(prefix):].lstrip() + return final_text + + @staticmethod + def _split_text_chunks(text: str, limit: int) -> list[str]: + """Split text into reasonably sized chunks for fallback sends.""" + if len(text) <= limit: + return [text] + chunks: list[str] = [] + remaining = text + while len(remaining) > limit: + split_at = remaining.rfind("\n", 0, limit) + if split_at < limit // 2: + split_at = limit + chunks.append(remaining[:split_at]) + remaining = remaining[split_at:].lstrip("\n") + if remaining: + chunks.append(remaining) + return chunks + + async def _send_fallback_final(self, text: str) -> None: + """Send the final continuation after streaming edits stop working. + + Retries each chunk once on flood-control failures with a short delay. + """ + final_text = self._clean_for_display(text) + continuation = self._continuation_text(final_text) + self._fallback_final_send = False + if not continuation.strip(): + # Nothing new to send — the visible partial already matches final text. + self._already_sent = True + self._final_response_sent = True + return + + raw_limit = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096) + safe_limit = max(500, raw_limit - 100) + chunks = self._split_text_chunks(continuation, safe_limit) + + last_message_id: Optional[str] = None + last_successful_chunk = "" + sent_any_chunk = False + for chunk in chunks: + # Try sending with one retry on flood-control errors. + result = None + for attempt in range(2): + result = await self.adapter.send( + chat_id=self.chat_id, + content=chunk, + metadata=self.metadata, + ) + if result.success: + break + if attempt == 0 and self._is_flood_error(result): + logger.debug( + "Flood control on fallback send, retrying in 3s" + ) + await asyncio.sleep(3.0) + else: + break # non-flood error or second attempt failed + + if not result or not result.success: + if sent_any_chunk: + # Some continuation text already reached the user. Suppress + # the base gateway final-send path so we don't resend the + # full response and create another duplicate. + self._already_sent = True + self._final_response_sent = True + self._message_id = last_message_id + self._last_sent_text = last_successful_chunk + self._fallback_prefix = "" + return + # No fallback chunk reached the user — allow the normal gateway + # final-send path to try one more time. + self._already_sent = False + self._message_id = None + self._last_sent_text = "" + self._fallback_prefix = "" + return + sent_any_chunk = True + last_successful_chunk = chunk + last_message_id = result.message_id or last_message_id + + self._message_id = last_message_id + self._already_sent = True + self._final_response_sent = True + self._last_sent_text = chunks[-1] + self._fallback_prefix = "" + + def _is_flood_error(self, result) -> bool: + """Check if a SendResult failure is due to flood control / rate limiting.""" + err = getattr(result, "error", "") or "" + err_lower = err.lower() + return "flood" in err_lower or "retry after" in err_lower or "rate" in err_lower + + async def _try_strip_cursor(self) -> None: + """Best-effort edit to remove the cursor from the last visible message. + + Called when entering fallback mode so the user doesn't see a stuck + cursor (▉) in the partial message. + """ + if not self._message_id or self._message_id == "__no_edit__": + return + prefix = self._visible_prefix() + if not prefix or not prefix.strip(): + return + try: + await self.adapter.edit_message( + chat_id=self.chat_id, + message_id=self._message_id, + content=prefix, + ) + self._last_sent_text = prefix + except Exception: + pass # best-effort — don't let this block the fallback path + + async def _send_commentary(self, text: str) -> bool: + """Send a completed interim assistant commentary message.""" + text = self._clean_for_display(text) + if not text.strip(): + return False + try: + result = await self.adapter.send( + chat_id=self.chat_id, + content=text, + metadata=self.metadata, + ) + if result.success: + self._already_sent = True + return True + except Exception as e: + logger.error("Commentary send error: %s", e) + return False + + async def _send_or_edit(self, text: str) -> bool: + """Send or edit the streaming message. + + Returns True if the text was successfully delivered (sent or edited), + False otherwise. Callers like the overflow split loop use this to + decide whether to advance past the delivered chunk. + """ + # Strip MEDIA: directives so they don't appear as visible text. + # Media files are delivered as native attachments after the stream + # finishes (via _deliver_media_from_response in gateway/run.py). + text = self._clean_for_display(text) + # A bare streaming cursor is not meaningful user-visible content and + # can render as a stray tofu/white-box message on some clients. + visible_without_cursor = text + if self.cfg.cursor: + visible_without_cursor = visible_without_cursor.replace(self.cfg.cursor, "") + _visible_stripped = visible_without_cursor.strip() + if not _visible_stripped: + return True # cursor-only / whitespace-only update + if not text.strip(): + return True # nothing to send is "success" + # Guard: do not create a brand-new standalone message when the only + # visible content is a handful of characters alongside the streaming + # cursor. During rapid tool-calling the model often emits 1-2 tokens + # before switching to tool calls; the resulting "X ▉" message risks + # leaving the cursor permanently visible if the follow-up edit (to + # strip the cursor on segment break) is rate-limited by the platform. + # This was reported on Telegram, Matrix, and other clients where the + # ▉ block character renders as a visible white box ("tofu"). + # Existing messages (edits) are unaffected — only first sends gated. + _MIN_NEW_MSG_CHARS = 4 + if (self._message_id is None + and self.cfg.cursor + and self.cfg.cursor in text + and len(_visible_stripped) < _MIN_NEW_MSG_CHARS): + return True # too short for a standalone message — accumulate more + try: + if self._message_id is not None: + if self._edit_supported: + # Skip if text is identical to what we last sent + if text == self._last_sent_text: + return True + # Edit existing message + result = await self.adapter.edit_message( + chat_id=self.chat_id, + message_id=self._message_id, + content=text, + ) + if result.success: + self._already_sent = True + self._last_sent_text = text + # Successful edit — reset flood strike counter + self._flood_strikes = 0 + return True + else: + # Edit failed. If this looks like flood control / rate + # limiting, use adaptive backoff: double the edit interval + # and retry on the next cycle. Only permanently disable + # edits after _MAX_FLOOD_STRIKES consecutive failures. + if self._is_flood_error(result): + self._flood_strikes += 1 + self._current_edit_interval = min( + self._current_edit_interval * 2, 10.0, + ) + logger.debug( + "Flood control on edit (strike %d/%d), " + "backoff interval → %.1fs", + self._flood_strikes, + self._MAX_FLOOD_STRIKES, + self._current_edit_interval, + ) + if self._flood_strikes < self._MAX_FLOOD_STRIKES: + # Don't disable edits yet — just slow down. + # Update _last_edit_time so the next edit + # respects the new interval. + self._last_edit_time = time.monotonic() + return False + + # Non-flood error OR flood strikes exhausted: enter + # fallback mode — send only the missing tail once the + # final response is available. + logger.debug( + "Edit failed (strikes=%d), entering fallback mode", + self._flood_strikes, + ) + self._fallback_prefix = self._visible_prefix() + self._fallback_final_send = True + self._edit_supported = False + self._already_sent = True + # Best-effort: strip the cursor from the last visible + # message so the user doesn't see a stuck ▉. + await self._try_strip_cursor() + return False + else: + # Editing not supported — skip intermediate updates. + # The final response will be sent by the fallback path. + return False + else: + # First message — send new + result = await self.adapter.send( + chat_id=self.chat_id, + content=text, + metadata=self.metadata, + ) + if result.success: + if result.message_id: + self._message_id = result.message_id + else: + self._edit_supported = False + self._already_sent = True + self._last_sent_text = text + if not result.message_id: + self._fallback_prefix = self._visible_prefix() + self._fallback_final_send = True + # Sentinel prevents re-entering the first-send path on + # every delta/tool boundary when platforms accept a + # message but do not return an editable message id. + self._message_id = "__no_edit__" + return True + else: + # Initial send failed — disable streaming for this session + self._edit_supported = False + return False + except Exception as e: + logger.error("Stream send/edit error: %s", e) + return False diff --git a/mindcli/_vendor/hermes_state.py b/mindcli/_vendor/hermes_state.py index 413a5de..823c91d 100644 --- a/mindcli/_vendor/hermes_state.py +++ b/mindcli/_vendor/hermes_state.py @@ -31,7 +31,7 @@ T = TypeVar("T") DEFAULT_DB_PATH = get_hermes_home() / "state.db" -SCHEMA_VERSION = 8 +SCHEMA_VERSION = 9 SCHEMA_SQL = """ CREATE TABLE IF NOT EXISTS schema_version ( @@ -81,7 +81,8 @@ CREATE TABLE IF NOT EXISTS messages ( finish_reason TEXT, reasoning TEXT, reasoning_details TEXT, - codex_reasoning_items TEXT + codex_reasoning_items TEXT, + archived INTEGER DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source); @@ -366,6 +367,13 @@ class SessionDB: except Exception: pass cursor.execute("UPDATE schema_version SET version = 8") + if current_version < 9: + # v9: messages 表增加 archived 列(Context Compaction 消息归档) + try: + cursor.execute("ALTER TABLE messages ADD COLUMN archived INTEGER DEFAULT 0") + except sqlite3.OperationalError: + pass # Column already exists + cursor.execute("UPDATE schema_version SET version = 9") # Unique title index — always ensure it exists (safe to run after migrations # since the title column is guaranteed to exist at this point) @@ -920,19 +928,125 @@ class SessionDB: result.append(msg) return result - def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]: + def archive_messages(self, session_id: str) -> int: + """将指定 session 的所有活跃消息标记为 archived。 + + 用于 Context Compaction:原始消息保留在 DB 中供前端历史查看, + 但不再被 get_messages_as_conversation() 返回给 LLM。 + + Returns: + 归档的消息数量。 """ - Load messages in the OpenAI conversation format (role + content dicts). - Used by the gateway to restore conversation history. - """ - with self._lock: - cursor = self._conn.execute( - "SELECT role, content, tool_call_id, tool_calls, tool_name, " - "reasoning, reasoning_details, codex_reasoning_items " - "FROM messages WHERE session_id = ? ORDER BY timestamp, id", + def _do(conn): + cursor = conn.execute( + "UPDATE messages SET archived = 1 " + "WHERE session_id = ? AND archived = 0", (session_id,), ) - rows = cursor.fetchall() + return cursor.rowcount + return self._execute_write(_do) + + def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]: + """ + Load active (non-archived) messages in the OpenAI conversation format. + Used by the gateway to restore conversation history for LLM context. + Archived messages (from Context Compaction) are excluded. + """ + with self._lock: + rows = self._conn.execute( + "SELECT role, content, tool_call_id, tool_calls, tool_name, " + "reasoning, reasoning_details, codex_reasoning_items " + "FROM messages WHERE session_id = ? AND archived = 0 ORDER BY timestamp, id", + (session_id,), + ).fetchall() + return self._rows_to_conversation(rows) + + # ── Compaction 标记前缀(与 context_compressor.py 保持一致) ── + _COMPACTION_MARKER = "[CONTEXT COMPACTION" + + def get_all_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]: + """Load ALL messages (including archived) for frontend history display. + + 智能合并策略: + 1. 如果没有归档消息 → 直接返回活跃消息(未发生过 compaction)。 + 2. 如果有归档消息 → 以归档消息为"基础历史",然后从活跃消息中 + **去除 compaction summary + tail 重复副本**,只追加真正的新消息。 + + 这确保前端看到完整、无重复的对话历史,同时 LLM 侧的 + get_messages_as_conversation() 仍然只返回活跃(精简)上下文。 + """ + with self._lock: + archived_rows = self._conn.execute( + "SELECT role, content, tool_call_id, tool_calls, tool_name, " + "reasoning, reasoning_details, codex_reasoning_items " + "FROM messages WHERE session_id = ? AND archived = 1 " + "ORDER BY timestamp, id", + (session_id,), + ).fetchall() + + active_rows = self._conn.execute( + "SELECT role, content, tool_call_id, tool_calls, tool_name, " + "reasoning, reasoning_details, codex_reasoning_items " + "FROM messages WHERE session_id = ? AND archived = 0 " + "ORDER BY timestamp, id", + (session_id,), + ).fetchall() + + # 无归档 → 从未压缩过,直接返回全部活跃消息 + if not archived_rows: + return self._rows_to_conversation(active_rows) + + archived_msgs = self._rows_to_conversation(archived_rows) + + # 过滤归档中的 compaction summary(多次压缩可能产生多条) + archived_msgs = [ + m for m in archived_msgs + if not (m.get("content") or "").lstrip().startswith(self._COMPACTION_MARKER) + ] + + # 构建归档消息的签名集(role + content 前 300 字),用于去重 + # tail 消息的 timestamp 在 flush 时被重写,可能出现在 archived + # 的任意位置,因此必须对全量 archived 构建签名 + all_sigs: set = set() + for msg in archived_msgs: + sig = self._msg_dedup_sig(msg) + all_sigs.add(sig) + + # 从活跃消息中筛选真正的新消息 + active_msgs = self._rows_to_conversation(active_rows) + new_msgs = [] + for msg in active_msgs: + content = msg.get("content") or "" + # 跳过 compaction summary(LLM 内部参考,不应展示给用户) + if content.lstrip().startswith(self._COMPACTION_MARKER): + continue + # 跳过 tail 重复副本 + sig = self._msg_dedup_sig(msg) + if sig in all_sigs: + continue + new_msgs.append(msg) + + return archived_msgs + new_msgs + + @staticmethod + def _msg_dedup_sig(msg: dict) -> tuple: + """生成消息去重签名。 + + 签名组成:(role, content_prefix, tool_call_id, tc_fingerprint) + - tool_call_id: tool 角色消息的关联 ID + - tc_fingerprint: assistant 消息携带的 tool_calls 首个 call ID + (防止空 content 但不同 tool_calls 的 assistant 消息误判重复) + """ + content = (msg.get("content") or "")[:300] + tc_fp = "" + tool_calls = msg.get("tool_calls") + if tool_calls and isinstance(tool_calls, list) and len(tool_calls) > 0: + first_tc = tool_calls[0] if isinstance(tool_calls[0], dict) else {} + tc_fp = first_tc.get("id", "") or first_tc.get("call_id", "") + return (msg.get("role", ""), content, msg.get("tool_call_id") or "", tc_fp) + + def _rows_to_conversation(self, rows) -> List[Dict[str, Any]]: + """将 DB rows 转换为 OpenAI conversation 格式(共享解析逻辑)。""" messages = [] for row in rows: msg = {"role": row["role"], "content": row["content"]} @@ -944,11 +1058,8 @@ class SessionDB: try: msg["tool_calls"] = json.loads(row["tool_calls"]) except (json.JSONDecodeError, TypeError): - logger.warning("Failed to deserialize tool_calls in conversation replay, falling back to []") + logger.warning("Failed to deserialize tool_calls, falling back to []") msg["tool_calls"] = [] - # Restore reasoning fields on assistant messages so providers - # that replay reasoning (OpenRouter, OpenAI, Nous) receive - # coherent multi-turn reasoning context. if row["role"] == "assistant": if row["reasoning"]: msg["reasoning"] = row["reasoning"] @@ -956,13 +1067,11 @@ class SessionDB: try: msg["reasoning_details"] = json.loads(row["reasoning_details"]) except (json.JSONDecodeError, TypeError): - logger.warning("Failed to deserialize reasoning_details, falling back to None") msg["reasoning_details"] = None if row["codex_reasoning_items"]: try: msg["codex_reasoning_items"] = json.loads(row["codex_reasoning_items"]) except (json.JSONDecodeError, TypeError): - logger.warning("Failed to deserialize codex_reasoning_items, falling back to None") msg["codex_reasoning_items"] = None messages.append(msg) return messages @@ -1162,13 +1271,24 @@ class SessionDB: cursor = self._conn.execute("SELECT COUNT(*) FROM sessions") return cursor.fetchone()[0] - def message_count(self, session_id: str = None) -> int: - """Count messages, optionally for a specific session.""" + def message_count(self, session_id: str = None, active_only: bool = False) -> int: + """Count messages, optionally for a specific session. + + Args: + session_id: If provided, count only messages for this session. + active_only: If True, exclude archived messages (from Context Compaction). + """ with self._lock: if session_id: - cursor = self._conn.execute( - "SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,) - ) + if active_only: + cursor = self._conn.execute( + "SELECT COUNT(*) FROM messages WHERE session_id = ? AND archived = 0", + (session_id,), + ) + else: + cursor = self._conn.execute( + "SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,) + ) else: cursor = self._conn.execute("SELECT COUNT(*) FROM messages") return cursor.fetchone()[0] @@ -1305,21 +1425,13 @@ class SessionDB: s.started_at ) AS last_active, ( - COALESCE( - (SELECT SUM( - (LENGTH(m3.tool_calls) - LENGTH(REPLACE(m3.tool_calls, '"write_file"', ''))) / LENGTH('"write_file"') - ) FROM messages m3 - WHERE m3.session_id = s.id - AND m3.tool_calls LIKE '%"write_file"%'), - 0) - + - (SELECT COUNT(*) FROM messages m4 - WHERE m4.session_id = s.id - AND m4.tool_calls LIKE '%"patch"%') - + - (SELECT COUNT(*) FROM messages m5 - WHERE m5.session_id = s.id - AND m5.role = 'assistant' AND m5.content LIKE '%"type"%audio"%') + SELECT COUNT(*) FROM messages m3 + WHERE m3.session_id = s.id + AND ( + m3.tool_calls LIKE '%"write_file"%' + OR m3.tool_calls LIKE '%"patch"%' + OR (m3.role = 'assistant' AND m3.content LIKE '%"type"%audio"%') + ) ) AS work_product_count FROM sessions s WHERE s.user_id = ? diff --git a/mindcli/_vendor/hermes_time.py b/mindcli/_vendor/hermes_time.py new file mode 100644 index 0000000..9f172d2 --- /dev/null +++ b/mindcli/_vendor/hermes_time.py @@ -0,0 +1,104 @@ +""" +Timezone-aware clock for Hermes. + +Provides a single ``now()`` helper that returns a timezone-aware datetime +based on the user's configured IANA timezone (e.g. ``Asia/Kolkata``). + +Resolution order: + 1. ``HERMES_TIMEZONE`` environment variable + 2. ``timezone`` key in ``~/.hermes/config.yaml`` + 3. Falls back to the server's local time (``datetime.now().astimezone()``) + +Invalid timezone values log a warning and fall back safely — Hermes never +crashes due to a bad timezone string. +""" + +import logging +import os +from datetime import datetime +from hermes_constants import get_config_path +from typing import Optional + +logger = logging.getLogger(__name__) + +try: + from zoneinfo import ZoneInfo +except ImportError: + # Python 3.8 fallback (shouldn't be needed — Hermes requires 3.9+) + from backports.zoneinfo import ZoneInfo # type: ignore[no-redef] + +# Cached state — resolved once, reused on every call. +# Call reset_cache() to force re-resolution (e.g. after config changes). +_cached_tz: Optional[ZoneInfo] = None +_cached_tz_name: Optional[str] = None +_cache_resolved: bool = False + + +def _resolve_timezone_name() -> str: + """Read the configured IANA timezone string (or empty string). + + This does file I/O when falling through to config.yaml, so callers + should cache the result rather than calling on every ``now()``. + """ + # 1. Environment variable (highest priority — set by Supervisor, etc.) + tz_env = os.getenv("HERMES_TIMEZONE", "").strip() + if tz_env: + return tz_env + + # 2. config.yaml ``timezone`` key + try: + import yaml + config_path = get_config_path() + if config_path.exists(): + with open(config_path) as f: + cfg = yaml.safe_load(f) or {} + tz_cfg = cfg.get("timezone", "") + if isinstance(tz_cfg, str) and tz_cfg.strip(): + return tz_cfg.strip() + except Exception: + pass + + return "" + + +def _get_zoneinfo(name: str) -> Optional[ZoneInfo]: + """Validate and return a ZoneInfo, or None if invalid.""" + if not name: + return None + try: + return ZoneInfo(name) + except (KeyError, Exception) as exc: + logger.warning( + "Invalid timezone '%s': %s. Falling back to server local time.", + name, exc, + ) + return None + + +def get_timezone() -> Optional[ZoneInfo]: + """Return the user's configured ZoneInfo, or None (meaning server-local). + + Resolved once and cached. Call ``reset_cache()`` after config changes. + """ + global _cached_tz, _cached_tz_name, _cache_resolved + if not _cache_resolved: + _cached_tz_name = _resolve_timezone_name() + _cached_tz = _get_zoneinfo(_cached_tz_name) + _cache_resolved = True + return _cached_tz + + +def now() -> datetime: + """ + Return the current time as a timezone-aware datetime. + + If a valid timezone is configured, returns wall-clock time in that zone. + Otherwise returns the server's local time (via ``astimezone()``). + """ + tz = get_timezone() + if tz is not None: + return datetime.now(tz) + # No timezone configured — use server-local (still tz-aware) + return datetime.now().astimezone() + + diff --git a/mindcli/_vendor/model_tools.py b/mindcli/_vendor/model_tools.py new file mode 100644 index 0000000..60c5b38 --- /dev/null +++ b/mindcli/_vendor/model_tools.py @@ -0,0 +1,601 @@ +#!/usr/bin/env python3 +""" +Model Tools Module + +Thin orchestration layer over the tool registry. Each tool file in tools/ +self-registers its schema, handler, and metadata via tools.registry.register(). +This module triggers discovery (by importing all tool modules), then provides +the public API that run_agent.py, cli.py, batch_runner.py, and the RL +environments consume. + +Public API (signatures preserved from the original 2,400-line version): + get_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode) -> list + handle_function_call(function_name, function_args, task_id, user_task) -> str + TOOL_TO_TOOLSET_MAP: dict (for batch_runner.py) + TOOLSET_REQUIREMENTS: dict (for cli.py, doctor.py) + get_all_tool_names() -> list + get_toolset_for_tool(name) -> str + get_available_toolsets() -> dict + check_toolset_requirements() -> dict + check_tool_availability(quiet) -> tuple +""" + +import json +import asyncio +import logging +import threading +from typing import Dict, Any, List, Optional, Tuple + +from tools.registry import registry +from toolsets import resolve_toolset, validate_toolset + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Async Bridging (single source of truth -- used by registry.dispatch too) +# ============================================================================= + +_tool_loop = None # persistent loop for the main (CLI) thread +_tool_loop_lock = threading.Lock() +_worker_thread_local = threading.local() # per-worker-thread persistent loops + + +def _get_tool_loop(): + """Return a long-lived event loop for running async tool handlers. + + Using a persistent loop (instead of asyncio.run() which creates and + *closes* a fresh loop every time) prevents "Event loop is closed" + errors that occur when cached httpx/AsyncOpenAI clients attempt to + close their transport on a dead loop during garbage collection. + """ + global _tool_loop + with _tool_loop_lock: + if _tool_loop is None or _tool_loop.is_closed(): + _tool_loop = asyncio.new_event_loop() + return _tool_loop + + +def _get_worker_loop(): + """Return a persistent event loop for the current worker thread. + + Each worker thread (e.g., delegate_task's ThreadPoolExecutor threads) + gets its own long-lived loop stored in thread-local storage. This + prevents the "Event loop is closed" errors that occurred when + asyncio.run() was used per-call: asyncio.run() creates a loop, runs + the coroutine, then *closes* the loop — but cached httpx/AsyncOpenAI + clients remain bound to that now-dead loop and raise RuntimeError + during garbage collection or subsequent use. + + By keeping the loop alive for the thread's lifetime, cached clients + stay valid and their cleanup runs on a live loop. + """ + loop = getattr(_worker_thread_local, 'loop', None) + if loop is None or loop.is_closed(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + _worker_thread_local.loop = loop + return loop + + +def _run_async(coro): + """Run an async coroutine from a sync context. + + If the current thread already has a running event loop (e.g., inside + the gateway's async stack or Atropos's event loop), we spin up a + disposable thread so asyncio.run() can create its own loop without + conflicting. + + For the common CLI path (no running loop), we use a persistent event + loop so that cached async clients (httpx / AsyncOpenAI) remain bound + to a live loop and don't trigger "Event loop is closed" on GC. + + When called from a worker thread (parallel tool execution), we use a + per-thread persistent loop to avoid both contention with the main + thread's shared loop AND the "Event loop is closed" errors caused by + asyncio.run()'s create-and-destroy lifecycle. + + This is the single source of truth for sync->async bridging in tool + handlers. The RL paths (agent_loop.py, tool_context.py) also provide + outer thread-pool wrapping as defense-in-depth, but each handler is + self-protecting via this function. + """ + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop and loop.is_running(): + # Inside an async context (gateway, RL env) — run in a fresh thread. + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(asyncio.run, coro) + return future.result(timeout=300) + + # If we're on a worker thread (e.g., parallel tool execution in + # delegate_task), use a per-thread persistent loop. This avoids + # contention with the main thread's shared loop while keeping cached + # httpx/AsyncOpenAI clients bound to a live loop for the thread's + # lifetime — preventing "Event loop is closed" on GC cleanup. + if threading.current_thread() is not threading.main_thread(): + worker_loop = _get_worker_loop() + return worker_loop.run_until_complete(coro) + + tool_loop = _get_tool_loop() + return tool_loop.run_until_complete(coro) + + +# ============================================================================= +# Tool Discovery (importing each module triggers its registry.register calls) +# ============================================================================= + +def _discover_tools(): + """Import all tool modules to trigger their registry.register() calls. + + Wrapped in a function so import errors in optional tools (e.g., fal_client + not installed) don't prevent the rest from loading. + """ + _modules = [ + "tools.web_tools", + "tools.terminal_tool", + "tools.file_tools", + "tools.vision_tools", + "tools.mixture_of_agents_tool", + "tools.image_generation_tool", + "tools.skills_tool", + "tools.skill_manager_tool", + "tools.browser_tool", + "tools.cronjob_tools", + "tools.rl_training_tool", + "tools.tts_tool", + "tools.todo_tool", + "tools.memory_tool", + "tools.session_search_tool", + "tools.clarify_tool", + "tools.code_execution_tool", + "tools.delegate_tool", + "tools.process_registry", + "tools.send_message_tool", + # "tools.honcho_tools", # Removed — Honcho is now a memory provider plugin + "tools.homeassistant_tool", + "tools.cli_tunnel_tool", + ] + import importlib + for mod_name in _modules: + try: + importlib.import_module(mod_name) + except Exception as e: + logger.warning("Could not import tool module %s: %s", mod_name, e) + + +_discover_tools() + +# MCP tool discovery (external MCP servers from config) +try: + from tools.mcp_tool import discover_mcp_tools + discover_mcp_tools() +except Exception as e: + logger.debug("MCP tool discovery failed: %s", e) + +# Plugin tool discovery (user/project/pip plugins) +try: + from hermes_cli.plugins import discover_plugins + discover_plugins() +except Exception as e: + logger.debug("Plugin discovery failed: %s", e) + + +# ============================================================================= +# Backward-compat constants (built once after discovery) +# ============================================================================= + +TOOL_TO_TOOLSET_MAP: Dict[str, str] = registry.get_tool_to_toolset_map() + +TOOLSET_REQUIREMENTS: Dict[str, dict] = registry.get_toolset_requirements() + +# Resolved tool names from the last get_tool_definitions() call. +# Used by code_execution_tool to know which tools are available in this session. +_last_resolved_tool_names: List[str] = [] + + +# ============================================================================= +# Legacy toolset name mapping (old _tools-suffixed names -> tool name lists) +# ============================================================================= + +_LEGACY_TOOLSET_MAP = { + "web_tools": ["web_search", "web_extract"], + "terminal_tools": ["terminal"], + "vision_tools": ["vision_analyze"], + "moa_tools": ["mixture_of_agents"], + "image_tools": ["image_generate"], + "skills_tools": ["skills_list", "skill_view", "skill_manage"], + "browser_tools": [ + "browser_navigate", "browser_snapshot", "browser_click", + "browser_type", "browser_scroll", "browser_back", + "browser_press", "browser_get_images", + "browser_vision", "browser_console" + ], + "cronjob_tools": ["cronjob"], + "rl_tools": [ + "rl_list_environments", "rl_select_environment", + "rl_get_current_config", "rl_edit_config", + "rl_start_training", "rl_check_status", + "rl_stop_training", "rl_get_results", + "rl_list_runs", "rl_test_inference" + ], + "file_tools": ["read_file", "write_file", "patch", "search_files"], + "tts_tools": ["text_to_speech"], +} + + +# ============================================================================= +# get_tool_definitions (the main schema provider) +# ============================================================================= + +def get_tool_definitions( + enabled_toolsets: List[str] = None, + disabled_toolsets: List[str] = None, + quiet_mode: bool = False, +) -> List[Dict[str, Any]]: + """ + Get tool definitions for model API calls with toolset-based filtering. + + All tools must be part of a toolset to be accessible. + + Args: + enabled_toolsets: Only include tools from these toolsets. + disabled_toolsets: Exclude tools from these toolsets (if enabled_toolsets is None). + quiet_mode: Suppress status prints. + + Returns: + Filtered list of OpenAI-format tool definitions. + """ + # Determine which tool names the caller wants + tools_to_include: set = set() + + if enabled_toolsets is not None: + for toolset_name in enabled_toolsets: + if validate_toolset(toolset_name): + resolved = resolve_toolset(toolset_name) + tools_to_include.update(resolved) + if not quiet_mode: + print(f"✅ Enabled toolset '{toolset_name}': {', '.join(resolved) if resolved else 'no tools'}") + elif toolset_name in _LEGACY_TOOLSET_MAP: + legacy_tools = _LEGACY_TOOLSET_MAP[toolset_name] + tools_to_include.update(legacy_tools) + if not quiet_mode: + print(f"✅ Enabled legacy toolset '{toolset_name}': {', '.join(legacy_tools)}") + else: + if not quiet_mode: + print(f"⚠️ Unknown toolset: {toolset_name}") + + elif disabled_toolsets: + from toolsets import get_all_toolsets + for ts_name in get_all_toolsets(): + tools_to_include.update(resolve_toolset(ts_name)) + + for toolset_name in disabled_toolsets: + if validate_toolset(toolset_name): + resolved = resolve_toolset(toolset_name) + tools_to_include.difference_update(resolved) + if not quiet_mode: + print(f"🚫 Disabled toolset '{toolset_name}': {', '.join(resolved) if resolved else 'no tools'}") + elif toolset_name in _LEGACY_TOOLSET_MAP: + legacy_tools = _LEGACY_TOOLSET_MAP[toolset_name] + tools_to_include.difference_update(legacy_tools) + if not quiet_mode: + print(f"🚫 Disabled legacy toolset '{toolset_name}': {', '.join(legacy_tools)}") + else: + if not quiet_mode: + print(f"⚠️ Unknown toolset: {toolset_name}") + else: + from toolsets import get_all_toolsets + for ts_name in get_all_toolsets(): + tools_to_include.update(resolve_toolset(ts_name)) + + # Plugin-registered tools are now resolved through the normal toolset + # path — validate_toolset() / resolve_toolset() / get_all_toolsets() + # all check the tool registry for plugin-provided toolsets. No bypass + # needed; plugins respect enabled_toolsets / disabled_toolsets like any + # other toolset. + + # Ask the registry for schemas (only returns tools whose check_fn passes) + filtered_tools = registry.get_definitions(tools_to_include, quiet=quiet_mode) + + # The set of tool names that actually passed check_fn filtering. + # Use this (not tools_to_include) for any downstream schema that references + # other tools by name — otherwise the model sees tools mentioned in + # descriptions that don't actually exist, and hallucinates calls to them. + available_tool_names = {t["function"]["name"] for t in filtered_tools} + + # Rebuild execute_code schema to only list sandbox tools that are actually + # available. Without this, the model sees "web_search is available in + # execute_code" even when the API key isn't configured or the toolset is + # disabled (#560-discord). + if "execute_code" in available_tool_names: + from tools.code_execution_tool import SANDBOX_ALLOWED_TOOLS, build_execute_code_schema + sandbox_enabled = SANDBOX_ALLOWED_TOOLS & available_tool_names + dynamic_schema = build_execute_code_schema(sandbox_enabled) + for i, td in enumerate(filtered_tools): + if td.get("function", {}).get("name") == "execute_code": + filtered_tools[i] = {"type": "function", "function": dynamic_schema} + break + + # Strip web tool cross-references from browser_navigate description when + # web_search / web_extract are not available. The static schema says + # "prefer web_search or web_extract" which causes the model to hallucinate + # those tools when they're missing. + if "browser_navigate" in available_tool_names: + web_tools_available = {"web_search", "web_extract"} & available_tool_names + if not web_tools_available: + for i, td in enumerate(filtered_tools): + if td.get("function", {}).get("name") == "browser_navigate": + desc = td["function"].get("description", "") + desc = desc.replace( + " For simple information retrieval, prefer web_search or web_extract (faster, cheaper).", + "", + ) + filtered_tools[i] = { + "type": "function", + "function": {**td["function"], "description": desc}, + } + break + + if not quiet_mode: + if filtered_tools: + tool_names = [t["function"]["name"] for t in filtered_tools] + print(f"🛠️ Final tool selection ({len(filtered_tools)} tools): {', '.join(tool_names)}") + else: + print("🛠️ No tools selected (all filtered out or unavailable)") + + global _last_resolved_tool_names + _last_resolved_tool_names = [t["function"]["name"] for t in filtered_tools] + + return filtered_tools + + +# ============================================================================= +# handle_function_call (the main dispatcher) +# ============================================================================= + +# Tools whose execution is intercepted by the agent loop (run_agent.py) +# because they need agent-level state (TodoStore, MemoryStore, etc.). +# The registry still holds their schemas; dispatch just returns a stub error +# so if something slips through, the LLM sees a sensible message. +_AGENT_LOOP_TOOLS = {"todo", "memory", "session_search", "delegate_task"} +_READ_SEARCH_TOOLS = {"read_file", "search_files"} + + +# ========================================================================= +# Tool argument type coercion +# ========================================================================= + +def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]: + """Coerce tool call arguments to match their JSON Schema types. + + LLMs frequently return numbers as strings (``"42"`` instead of ``42``) + and booleans as strings (``"true"`` instead of ``true``). This compares + each argument value against the tool's registered JSON Schema and attempts + safe coercion when the value is a string but the schema expects a different + type. Original values are preserved when coercion fails. + + Handles ``"type": "integer"``, ``"type": "number"``, ``"type": "boolean"``, + and union types (``"type": ["integer", "string"]``). + """ + if not args or not isinstance(args, dict): + return args + + schema = registry.get_schema(tool_name) + if not schema: + return args + + properties = (schema.get("parameters") or {}).get("properties") + if not properties: + return args + + for key, value in args.items(): + if not isinstance(value, str): + continue + prop_schema = properties.get(key) + if not prop_schema: + continue + expected = prop_schema.get("type") + if not expected: + continue + coerced = _coerce_value(value, expected) + if coerced is not value: + args[key] = coerced + + return args + + +def _coerce_value(value: str, expected_type): + """Attempt to coerce a string *value* to *expected_type*. + + Returns the original string when coercion is not applicable or fails. + """ + if isinstance(expected_type, list): + # Union type — try each in order, return first successful coercion + for t in expected_type: + result = _coerce_value(value, t) + if result is not value: + return result + return value + + if expected_type in ("integer", "number"): + return _coerce_number(value, integer_only=(expected_type == "integer")) + if expected_type == "boolean": + return _coerce_boolean(value) + return value + + +def _coerce_number(value: str, integer_only: bool = False): + """Try to parse *value* as a number. Returns original string on failure.""" + try: + f = float(value) + except (ValueError, OverflowError): + return value + # Guard against inf/nan before int() conversion + if f != f or f == float("inf") or f == float("-inf"): + return f + # If it looks like an integer (no fractional part), return int + if f == int(f): + return int(f) + if integer_only: + # Schema wants an integer but value has decimals — keep as string + return value + return f + + +def _coerce_boolean(value: str): + """Try to parse *value* as a boolean. Returns original string on failure.""" + low = value.strip().lower() + if low == "true": + return True + if low == "false": + return False + return value + + +def handle_function_call( + function_name: str, + function_args: Dict[str, Any], + task_id: Optional[str] = None, + tool_call_id: Optional[str] = None, + session_id: Optional[str] = None, + user_task: Optional[str] = None, + enabled_tools: Optional[List[str]] = None, + skip_pre_tool_call_hook: bool = False, +) -> str: + """ + Main function call dispatcher that routes calls to the tool registry. + + Args: + function_name: Name of the function to call. + function_args: Arguments for the function. + task_id: Unique identifier for terminal/browser session isolation. + user_task: The user's original task (for browser_snapshot context). + enabled_tools: Tool names enabled for this session. When provided, + execute_code uses this list to determine which sandbox + tools to generate. Falls back to the process-global + ``_last_resolved_tool_names`` for backward compat. + + Returns: + Function result as a JSON string. + """ + # Coerce string arguments to their schema-declared types (e.g. "42"→42) + function_args = coerce_tool_args(function_name, function_args) + + try: + if function_name in _AGENT_LOOP_TOOLS: + return json.dumps({"error": f"{function_name} must be handled by the agent loop"}) + + # Check plugin hooks for a block directive (unless caller already + # checked — e.g. run_agent._invoke_tool passes skip=True to + # avoid double-firing the hook). + if not skip_pre_tool_call_hook: + block_message: Optional[str] = None + try: + from hermes_cli.plugins import get_pre_tool_call_block_message + block_message = get_pre_tool_call_block_message( + function_name, + function_args, + task_id=task_id or "", + session_id=session_id or "", + tool_call_id=tool_call_id or "", + ) + except Exception: + pass + + if block_message is not None: + return json.dumps({"error": block_message}, ensure_ascii=False) + else: + # Still fire the hook for observers — just don't check for blocking + # (the caller already did that). + try: + from hermes_cli.plugins import invoke_hook + invoke_hook( + "pre_tool_call", + tool_name=function_name, + args=function_args, + task_id=task_id or "", + session_id=session_id or "", + tool_call_id=tool_call_id or "", + ) + except Exception: + pass + + # Notify the read-loop tracker when a non-read/search tool runs, + # so the *consecutive* counter resets (reads after other work are fine). + if function_name not in _READ_SEARCH_TOOLS: + try: + from tools.file_tools import notify_other_tool_call + notify_other_tool_call(task_id or "default") + except Exception: + pass # file_tools may not be loaded yet + + if function_name == "execute_code": + # Prefer the caller-provided list so subagents can't overwrite + # the parent's tool set via the process-global. + sandbox_enabled = enabled_tools if enabled_tools is not None else _last_resolved_tool_names + result = registry.dispatch( + function_name, function_args, + task_id=task_id, + enabled_tools=sandbox_enabled, + ) + else: + result = registry.dispatch( + function_name, function_args, + task_id=task_id, + user_task=user_task, + ) + + try: + from hermes_cli.plugins import invoke_hook + invoke_hook( + "post_tool_call", + tool_name=function_name, + args=function_args, + result=result, + task_id=task_id or "", + session_id=session_id or "", + tool_call_id=tool_call_id or "", + ) + except Exception: + pass + + return result + + except Exception as e: + error_msg = f"Error executing {function_name}: {str(e)}" + logger.error(error_msg) + return json.dumps({"error": error_msg}, ensure_ascii=False) + + +# ============================================================================= +# Backward-compat wrapper functions +# ============================================================================= + +def get_all_tool_names() -> List[str]: + """Return all registered tool names.""" + return registry.get_all_tool_names() + + +def get_toolset_for_tool(tool_name: str) -> Optional[str]: + """Return the toolset a tool belongs to.""" + return registry.get_toolset_for_tool(tool_name) + + +def get_available_toolsets() -> Dict[str, dict]: + """Return toolset availability info for UI display.""" + return registry.get_available_toolsets() + + +def check_toolset_requirements() -> Dict[str, bool]: + """Return {toolset: available_bool} for every registered toolset.""" + return registry.check_toolset_requirements() + + +def check_tool_availability(quiet: bool = False) -> Tuple[List[str], List[dict]]: + """Return (available_toolsets, unavailable_info).""" + return registry.check_tool_availability(quiet=quiet) diff --git a/mindcli/_vendor/run_agent.py b/mindcli/_vendor/run_agent.py index d8ba43d..5064e33 100644 --- a/mindcli/_vendor/run_agent.py +++ b/mindcli/_vendor/run_agent.py @@ -6804,17 +6804,17 @@ class AIAgent: # 这样前端的 chatId 始终有效,history API 能找到压缩后的消息。 old_title = self._session_db.get_session_title(self.session_id) - # 清除该 session 的所有旧消息(压缩摘要已在内存 messages[] 中) + # 归档旧消息(保留完整历史,但 LLM 不再看到它们) try: - self._session_db._conn.execute( - "DELETE FROM messages WHERE session_id = ?", - (self.session_id,), + archived_count = self._session_db.archive_messages(self.session_id) + logger.info( + "[compress] Archived %d messages for session %s", + archived_count, self.session_id, ) - self._session_db._conn.commit() - except Exception: - pass # non-fatal: worst case 有重复旧消息 + except Exception as e: + logger.warning("Message archival failed (non-fatal): %s", e) - # 重置 flush cursor — 压缩后的消息从头写入 + # 重置 flush cursor — 压缩后的消息从头写入(archived 消息不会被重复读取) self._last_flushed_db_idx = 0 # 更新日志文件路径(仅 JSON 日志,不影响 DB) diff --git a/mindcli/_vendor/tools/cli_tunnel_tool.py b/mindcli/_vendor/tools/cli_tunnel_tool.py new file mode 100644 index 0000000..8dd37a7 --- /dev/null +++ b/mindcli/_vendor/tools/cli_tunnel_tool.py @@ -0,0 +1,324 @@ +""" +CLI Tunnel 工具 — 动态注册 Mind CLI 本地工具到 Hermes 编排器。 + +设计遵循 SPEC 铁律 B(能力报告义务): + CLI 连接时上报能力 → Cloud 审批 → 动态注册到 ToolRegistry + CLI 断开 → 从 ToolRegistry 注销 + +注册模式参照 MCP 动态发现(mcp_tool._register_server_tools), +用户隔离参照飞书连接器(feishu_tool.handler(args.user_id))。 + +与内置工具通过 `cli_` 前缀区分: + cli_terminal = 在用户本地电脑执行命令 + terminal = 在云端服务器执行命令 +""" + +import asyncio +import json +import logging +from typing import Any + +from tools.registry import registry, tool_error, tool_result + +logger = logging.getLogger("tools.cli_tunnel") + +# ── 模块级 bridge 引用(由 mindos_sse.py 注入) ──────────── +_bridge = None + + +def set_bridge(bridge) -> None: + """注入 MindCLIBridge 实例(进程生命周期内调用一次)。""" + global _bridge + _bridge = bridge + + +# ── 工具 Schema 模板 ───────────────────────────────────── +# 每个 CLI 内置工具对应一个 schema 模板,用于注册到 LLM function calling。 +# 只有 capability_report 中出现的工具才会被注册。 + +_TOOL_SCHEMAS: dict[str, dict] = { + "terminal": { + "name": "cli_terminal", + "description": ( + "在用户的本地电脑上执行终端命令。" + "用于需要访问用户本地文件系统、开发环境或系统工具的场景。" + "注意:这是在用户个人电脑上执行,不是在服务器上。" + ), + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "要执行的 shell 命令", + }, + "cwd": { + "type": "string", + "description": "工作目录(可选,默认用户 home)", + }, + "timeout": { + "type": "integer", + "description": "超时秒数(可选,默认 30)", + }, + }, + "required": ["command"], + }, + }, + "file_read": { + "name": "cli_file_read", + "description": ( + "读取用户本地电脑上的文件内容。" + "用于查看用户电脑上的配置文件、代码、文档等。" + ), + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "文件绝对路径", + }, + }, + "required": ["path"], + }, + }, + "file_write": { + "name": "cli_file_write", + "description": ( + "写入文件到用户本地电脑。" + "用于创建或更新用户电脑上的文件。" + ), + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "文件绝对路径", + }, + "content": { + "type": "string", + "description": "文件内容", + }, + }, + "required": ["path", "content"], + }, + }, + "file_ops": { + "name": "cli_file_ops", + "description": ( + "在用户本地电脑上执行文件操作(复制/移动/删除/列表)。" + ), + "parameters": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "description": "操作类型", + "enum": ["copy", "move", "delete", "list"], + }, + "source": { + "type": "string", + "description": "源路径", + }, + "destination": { + "type": "string", + "description": "目标路径(copy/move 时必填)", + }, + }, + "required": ["operation", "source"], + }, + }, + "grep": { + "name": "cli_grep", + "description": ( + "在用户本地电脑上搜索文件内容。" + "用于在用户项目中查找代码、配置等。" + ), + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "搜索模式", + }, + "path": { + "type": "string", + "description": "搜索路径(可选,默认当前目录)", + }, + }, + "required": ["pattern"], + }, + }, + "code_execution": { + "name": "cli_code_execution", + "description": ( + "在用户本地电脑上执行代码片段(Python)。" + "用于需要在用户本地环境中运行脚本的场景。" + ), + "parameters": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "要执行的代码", + }, + "language": { + "type": "string", + "description": "编程语言(当前仅支持 python)", + "enum": ["python"], + }, + }, + "required": ["code"], + }, + }, +} + + +# ── 动态注册 / 注销 ────────────────────────────────────── + +def register_cli_tools( + user_id: str, + capabilities: dict, +) -> list[str]: + """ + CLI 连接时调用——根据能力报告动态注册工具到 Hermes ToolRegistry。 + + Args: + user_id: 用户 ID + capabilities: CLI 上报的 capability_report + + Returns: + 已注册的工具名列表(带 cli_ 前缀) + """ + from toolsets import create_custom_toolset, TOOLSETS + + if not _bridge: + logger.warning("[CliTunnel] bridge 未初始化,跳过工具注册") + return [] + + reported_tools = {t["name"] for t in capabilities.get("tools", [])} + registered: list[str] = [] + + for local_name, schema in _TOOL_SCHEMAS.items(): + if local_name not in reported_tools: + continue + + prefixed_name = schema["name"] # e.g. "cli_terminal" + + # 避免与非 CLI 内置工具冲突 + existing_toolset = registry.get_toolset_for_tool(prefixed_name) + if existing_toolset and existing_toolset != "connectors": + logger.warning( + "[CliTunnel] 工具 '%s' 与 toolset '%s' 冲突,跳过", + prefixed_name, existing_toolset, + ) + continue + + registry.register( + name=prefixed_name, + toolset="connectors", + schema=schema, + handler=_make_cli_handler(local_name, user_id), + check_fn=_make_check_fn(user_id), + is_async=False, + description=schema["description"], + emoji="💻", + ) + registered.append(prefixed_name) + + # 注入到 hermes-* umbrella toolsets 以确保通过 enabled_toolsets 过滤 + if registered: + for ts_name, ts in TOOLSETS.items(): + if ts_name.startswith("hermes-"): + for tool_name in registered: + if tool_name not in ts["tools"]: + ts["tools"].append(tool_name) + + logger.info( + "[CliTunnel] 为用户 %s 注册 %d 个本地工具: %s", + user_id, len(registered), ", ".join(registered), + ) + return registered + + +def deregister_cli_tools(tools: list[str]) -> None: + """ + CLI 断开时调用——从 ToolRegistry 注销工具。 + + Args: + tools: 之前 register_cli_tools 返回的工具名列表 + """ + from toolsets import TOOLSETS + + for name in tools: + registry.deregister(name) + # 从 hermes-* umbrella toolsets 中移除 + for ts_name, ts in TOOLSETS.items(): + if ts_name.startswith("hermes-"): + try: + ts["tools"].remove(name) + except ValueError: + pass + + if tools: + logger.info("[CliTunnel] 注销 %d 个本地工具: %s", len(tools), ", ".join(tools)) + + +# ── Handler 工厂 ───────────────────────────────────────── + +def _make_cli_handler(local_tool_name: str, user_id: str): + """ + 返回闭包 handler,走 Tunnel 派发工具调用。 + + 签名:handler(args, **kwargs) -> str(符合 registry.dispatch 要求) + """ + def _handler(args: dict, **kwargs) -> str: + if not _bridge: + return tool_error("CLI Bridge 未初始化") + + if not _bridge.is_connected(user_id): + return tool_error( + f"本地 CLI 未连接。请确保 Mind CLI 正在运行并已连接隧道。" + ) + + # 同步调用异步 dispatch(复用 model_tools._run_async 模式) + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + coro = _bridge.dispatch_tool_call(user_id, local_tool_name, args) + + try: + if loop and loop.is_running(): + # 在 async 上下文中(SSE gateway)→ 开线程 + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(asyncio.run, coro) + result = future.result(timeout=130) + else: + result = asyncio.run(coro) + except Exception as e: + logger.error("[CliTunnel] 工具 %s 调用失败: %s", local_tool_name, e) + return tool_error(f"本地工具调用失败: {e}") + + # result 是 dict(从 CLI 返回的 JSON-RPC result) + if isinstance(result, dict) and "error" in result: + return tool_error(result["error"]) + + return json.dumps( + {"ok": True, "tool": local_tool_name, "result": result}, + ensure_ascii=False, + ) + + return _handler + + +def _make_check_fn(user_id: str): + """ + 返回 check_fn 闭包——registry 每次 get_definitions 时调用。 + + 只有当 bridge 存在且该用户 CLI 在线时才返回 True, + 离线时工具自动从 LLM 视野消失。 + """ + def _check() -> bool: + return bool(_bridge and _bridge.is_connected(user_id)) + return _check diff --git a/mindcli/_vendor/toolset_distributions.py b/mindcli/_vendor/toolset_distributions.py new file mode 100644 index 0000000..b2a5657 --- /dev/null +++ b/mindcli/_vendor/toolset_distributions.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +""" +Toolset Distributions Module + +This module defines distributions of toolsets for data generation runs. +Each distribution specifies which toolsets should be used and their probability +of being selected for any given prompt during the batch processing. + +A distribution is a dictionary mapping toolset names to their selection probability (%). +Probabilities should sum to 100, but the system will normalize if they don't. + +Usage: + from toolset_distributions import get_distribution, list_distributions + + # Get a specific distribution + dist = get_distribution("image_gen") + + # List all available distributions + all_dists = list_distributions() +""" + +from typing import Dict, List, Optional +import random +from toolsets import validate_toolset + + +# Distribution definitions +# Each key is a distribution name, and the value is a dict of toolset_name: probability_percentage +DISTRIBUTIONS = { + # Default: All tools available 100% of the time + "default": { + "description": "All available tools, all the time", + "toolsets": { + "web": 100, + "vision": 100, + "image_gen": 100, + "terminal": 100, + "file": 100, + "moa": 100, + "browser": 100 + } + }, + + # Image generation focused distribution + "image_gen": { + "description": "Heavy focus on image generation with vision and web support", + "toolsets": { + "image_gen": 90, # 80% chance of image generation tools + "vision": 90, # 60% chance of vision tools + "web": 55, # 40% chance of web tools + "terminal": 45, + "moa": 10 # 20% chance of reasoning tools + } + }, + + # Research-focused distribution + "research": { + "description": "Web research with vision analysis and reasoning", + "toolsets": { + "web": 90, # 90% chance of web tools + "browser": 70, # 70% chance of browser tools for deep research + "vision": 50, # 50% chance of vision tools + "moa": 40, # 40% chance of reasoning tools + "terminal": 10 # 10% chance of terminal tools + } + }, + + # Scientific problem solving focused distribution + "science": { + "description": "Scientific research with web, terminal, file, and browser capabilities", + "toolsets": { + "web": 94, # 94% chance of web tools + "terminal": 94, # 94% chance of terminal tools + "file": 94, # 94% chance of file tools + "vision": 65, # 65% chance of vision tools + "browser": 50, # 50% chance of browser for accessing papers/databases + "image_gen": 15, # 15% chance of image generation tools + "moa": 10 # 10% chance of reasoning tools + } + }, + + # Development-focused distribution + "development": { + "description": "Terminal, file tools, and reasoning with occasional web lookup", + "toolsets": { + "terminal": 80, # 80% chance of terminal tools + "file": 80, # 80% chance of file tools (read, write, patch, search) + "moa": 60, # 60% chance of reasoning tools + "web": 30, # 30% chance of web tools + "vision": 10 # 10% chance of vision tools + } + }, + + # Safe mode (no terminal) + "safe": { + "description": "All tools except terminal for safety", + "toolsets": { + "web": 80, + "browser": 70, # Browser is safe (no local filesystem access) + "vision": 60, + "image_gen": 60, + "moa": 50 + } + }, + + # Balanced distribution + "balanced": { + "description": "Equal probability of all toolsets", + "toolsets": { + "web": 50, + "vision": 50, + "image_gen": 50, + "terminal": 50, + "file": 50, + "moa": 50, + "browser": 50 + } + }, + + # Minimal (web only) + "minimal": { + "description": "Only web tools for basic research", + "toolsets": { + "web": 100 + } + }, + + # Terminal only + "terminal_only": { + "description": "Terminal and file tools for code execution tasks", + "toolsets": { + "terminal": 100, + "file": 100 + } + }, + + # Terminal + web (common for coding tasks that need docs) + "terminal_web": { + "description": "Terminal and file tools with web search for documentation lookup", + "toolsets": { + "terminal": 100, + "file": 100, + "web": 100 + } + }, + + # Creative (vision + image generation) + "creative": { + "description": "Image generation and vision analysis focus", + "toolsets": { + "image_gen": 90, + "vision": 90, + "web": 30 + } + }, + + # Reasoning heavy + "reasoning": { + "description": "Heavy mixture of agents usage with minimal other tools", + "toolsets": { + "moa": 90, + "web": 30, + "terminal": 20 + } + }, + + # Browser-based web interaction + "browser_use": { + "description": "Full browser-based web interaction with search, vision, and page control", + "toolsets": { + "browser": 100, # All browser tools always available + "web": 80, # Web search for finding URLs and quick lookups + "vision": 70 # Vision analysis for images found on pages + } + }, + + # Browser only (no other tools) + "browser_only": { + "description": "Only browser automation tools for pure web interaction tasks", + "toolsets": { + "browser": 100 + } + }, + + # Browser-focused tasks distribution (for browser-use-tasks.jsonl) + "browser_tasks": { + "description": "Browser-focused distribution (browser toolset includes web_search for finding URLs since Google blocks direct browser searches)", + "toolsets": { + "browser": 97, # 97% - browser tools (includes web_search) almost always available + "vision": 12, # 12% - vision analysis occasionally + "terminal": 15 # 15% - terminal occasionally for local operations + } + }, + + # Terminal-focused tasks distribution (for nous-terminal-tasks.jsonl) + "terminal_tasks": { + "description": "Terminal-focused distribution with high terminal/file availability, occasional other tools", + "toolsets": { + "terminal": 97, # 97% - terminal almost always available + "file": 97, # 97% - file tools almost always available + "web": 97, # 15% - web search/scrape for documentation + "browser": 75, # 10% - browser occasionally for web interaction + "vision": 50, # 8% - vision analysis rarely + "image_gen": 10 # 3% - image generation very rarely + } + }, + + # Mixed browser+terminal tasks distribution (for mixed-browser-terminal-tasks.jsonl) + "mixed_tasks": { + "description": "Mixed distribution with high browser, terminal, and file availability for complex tasks", + "toolsets": { + "browser": 92, # 92% - browser tools highly available + "terminal": 92, # 92% - terminal highly available + "file": 92, # 92% - file tools highly available + "web": 35, # 35% - web search/scrape fairly common + "vision": 15, # 15% - vision analysis occasionally + "image_gen": 15 # 15% - image generation occasionally + } + } +} + + +def get_distribution(name: str) -> Optional[Dict[str, any]]: + """ + Get a toolset distribution by name. + + Args: + name (str): Name of the distribution + + Returns: + Dict: Distribution definition with description and toolsets + None: If distribution not found + """ + return DISTRIBUTIONS.get(name) + + +def list_distributions() -> Dict[str, Dict]: + """ + List all available distributions. + + Returns: + Dict: All distribution definitions + """ + return DISTRIBUTIONS.copy() + + +def sample_toolsets_from_distribution(distribution_name: str) -> List[str]: + """ + Sample toolsets based on a distribution's probabilities. + + Each toolset in the distribution has a % chance of being included. + This allows multiple toolsets to be active simultaneously. + + Args: + distribution_name (str): Name of the distribution to sample from + + Returns: + List[str]: List of sampled toolset names + + Raises: + ValueError: If distribution name is not found + """ + dist = get_distribution(distribution_name) + if not dist: + raise ValueError(f"Unknown distribution: {distribution_name}") + + # Sample each toolset independently based on its probability + selected_toolsets = [] + + for toolset_name, probability in dist["toolsets"].items(): + # Validate toolset exists + if not validate_toolset(toolset_name): + print(f"⚠️ Warning: Toolset '{toolset_name}' in distribution '{distribution_name}' is not valid") + continue + + # Roll the dice - if random value is less than probability, include this toolset + if random.random() * 100 < probability: + selected_toolsets.append(toolset_name) + + # If no toolsets were selected (can happen with low probabilities), + # ensure at least one toolset is selected by picking the highest probability one + if not selected_toolsets and dist["toolsets"]: + # Find toolset with highest probability + highest_prob_toolset = max(dist["toolsets"].items(), key=lambda x: x[1])[0] + if validate_toolset(highest_prob_toolset): + selected_toolsets.append(highest_prob_toolset) + + return selected_toolsets + + +def validate_distribution(distribution_name: str) -> bool: + """ + Check if a distribution name is valid. + + Args: + distribution_name (str): Distribution name to validate + + Returns: + bool: True if valid, False otherwise + """ + return distribution_name in DISTRIBUTIONS + + +def print_distribution_info(distribution_name: str) -> None: + """ + Print detailed information about a distribution. + + Args: + distribution_name (str): Distribution name + """ + dist = get_distribution(distribution_name) + if not dist: + print(f"❌ Unknown distribution: {distribution_name}") + return + + print(f"\n📊 Distribution: {distribution_name}") + print(f" Description: {dist['description']}") + print(" Toolsets:") + for toolset, prob in sorted(dist["toolsets"].items(), key=lambda x: x[1], reverse=True): + print(f" • {toolset:15} : {prob:3}% chance") + + +if __name__ == "__main__": + """ + Demo and testing of the distributions system + """ + print("📊 Toolset Distributions Demo") + print("=" * 60) + + # List all distributions + print("\n📋 Available Distributions:") + print("-" * 40) + for name, dist in list_distributions().items(): + print(f"\n {name}:") + print(f" {dist['description']}") + toolset_list = ", ".join([f"{ts}({p}%)" for ts, p in dist["toolsets"].items()]) + print(f" Toolsets: {toolset_list}") + + # Demo sampling + print("\n\n🎲 Sampling Examples:") + print("-" * 40) + + test_distributions = ["image_gen", "research", "balanced", "default"] + + for dist_name in test_distributions: + print(f"\n{dist_name}:") + # Sample 5 times to show variability + samples = [] + for _ in range(5): + sampled = sample_toolsets_from_distribution(dist_name) + samples.append(sorted(sampled)) + + print(f" Sample 1: {samples[0]}") + print(f" Sample 2: {samples[1]}") + print(f" Sample 3: {samples[2]}") + print(f" Sample 4: {samples[3]}") + print(f" Sample 5: {samples[4]}") + + # Show detailed info + print("\n\n📊 Detailed Distribution Info:") + print("-" * 40) + print_distribution_info("image_gen") + print_distribution_info("research") + diff --git a/mindcli/_vendor/toolsets.py b/mindcli/_vendor/toolsets.py new file mode 100644 index 0000000..2e7a0a9 --- /dev/null +++ b/mindcli/_vendor/toolsets.py @@ -0,0 +1,661 @@ +#!/usr/bin/env python3 +""" +Toolsets Module + +This module provides a flexible system for defining and managing tool aliases/toolsets. +Toolsets allow you to group tools together for specific scenarios and can be composed +from individual tools or other toolsets. + +Features: +- Define custom toolsets with specific tools +- Compose toolsets from other toolsets +- Built-in common toolsets for typical use cases +- Easy extension for new toolsets +- Support for dynamic toolset resolution + +Usage: + from toolsets import get_toolset, resolve_toolset, get_all_toolsets + + # Get tools for a specific toolset + tools = get_toolset("research") + + # Resolve a toolset to get all tool names (including from composed toolsets) + all_tools = resolve_toolset("full_stack") +""" + +from typing import List, Dict, Any, Set, Optional + + +# Shared tool list for CLI and all messaging platform toolsets. +# Edit this once to update all platforms simultaneously. +_HERMES_CORE_TOOLS = [ + # Web + "web_search", "web_extract", + # Terminal + process management + "terminal", "process", + # File manipulation + "read_file", "write_file", "patch", "search_files", + # Vision + image generation + "vision_analyze", "image_generate", + # Skills + "skills_list", "skill_view", "skill_manage", + # Browser automation + "browser_navigate", "browser_snapshot", "browser_click", + "browser_type", "browser_scroll", "browser_back", + "browser_press", "browser_get_images", + "browser_vision", "browser_console", + # Text-to-speech + "text_to_speech", + # Planning & memory + "todo", "memory", + # Session history search + "session_search", + # Clarifying questions + "clarify", + # Code execution + delegation + "execute_code", "delegate_task", + # Cronjob management + "cronjob", + # Cross-platform messaging (gated on gateway running via check_fn) + "send_message", + # Home Assistant smart home control (gated on HASS_TOKEN via check_fn) + "ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service", +] + + +# Core toolset definitions +# These can include individual tools or reference other toolsets +TOOLSETS = { + # Basic toolsets - individual tool categories + "web": { + "description": "Web research and content extraction tools", + "tools": ["web_search", "web_extract"], + "includes": [] # No other toolsets included + }, + + "search": { + "description": "Web search only (no content extraction/scraping)", + "tools": ["web_search"], + "includes": [] + }, + + "vision": { + "description": "Image analysis and vision tools", + "tools": ["vision_analyze"], + "includes": [] + }, + + "image_gen": { + "description": "Creative generation tools (images)", + "tools": ["image_generate"], + "includes": [] + }, + + "terminal": { + "description": "Terminal/command execution and process management tools", + "tools": ["terminal", "process"], + "includes": [] + }, + + "moa": { + "description": "Advanced reasoning and problem-solving tools", + "tools": ["mixture_of_agents"], + "includes": [] + }, + + "skills": { + "description": "Access, create, edit, and manage skill documents with specialized instructions and knowledge", + "tools": ["skills_list", "skill_view", "skill_manage"], + "includes": [] + }, + + "browser": { + "description": "Browser automation for web interaction (navigate, click, type, scroll, iframes, hold-click) with web search for finding URLs", + "tools": [ + "browser_navigate", "browser_snapshot", "browser_click", + "browser_type", "browser_scroll", "browser_back", + "browser_press", "browser_get_images", + "browser_vision", "browser_console", "web_search" + ], + "includes": [] + }, + + "cronjob": { + "description": "Cronjob management tool - create, list, update, pause, resume, remove, and trigger scheduled tasks", + "tools": ["cronjob"], + "includes": [] + }, + + "messaging": { + "description": "Cross-platform messaging: send messages to Telegram, Discord, Slack, SMS, etc.", + "tools": ["send_message"], + "includes": [] + }, + + "rl": { + "description": "RL training tools for running reinforcement learning on Tinker-Atropos", + "tools": [ + "rl_list_environments", "rl_select_environment", + "rl_get_current_config", "rl_edit_config", + "rl_start_training", "rl_check_status", + "rl_stop_training", "rl_get_results", + "rl_list_runs", "rl_test_inference" + ], + "includes": [] + }, + + "file": { + "description": "File manipulation tools: read, write, patch (with fuzzy matching), and search (content + files)", + "tools": ["read_file", "write_file", "patch", "search_files"], + "includes": [] + }, + + "tts": { + "description": "Text-to-speech: convert text to audio with Edge TTS (free), ElevenLabs, or OpenAI", + "tools": ["text_to_speech"], + "includes": [] + }, + + "todo": { + "description": "Task planning and tracking for multi-step work", + "tools": ["todo"], + "includes": [] + }, + + "memory": { + "description": "Persistent memory across sessions (personal notes + user profile)", + "tools": ["memory"], + "includes": [] + }, + + "session_search": { + "description": "Search and recall past conversations with summarization", + "tools": ["session_search"], + "includes": [] + }, + + "clarify": { + "description": "Ask the user clarifying questions (multiple-choice or open-ended)", + "tools": ["clarify"], + "includes": [] + }, + + "code_execution": { + "description": "Run Python scripts that call tools programmatically (reduces LLM round trips)", + "tools": ["execute_code"], + "includes": [] + }, + + "delegation": { + "description": "Spawn subagents with isolated context for complex subtasks", + "tools": ["delegate_task"], + "includes": [] + }, + + # "honcho" toolset removed — Honcho is now a memory provider plugin. + # Tools are injected via MemoryManager, not the toolset system. + + "homeassistant": { + "description": "Home Assistant smart home control and monitoring", + "tools": ["ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service"], + "includes": [] + }, + + + # Scenario-specific toolsets + + "debugging": { + "description": "Debugging and troubleshooting toolkit", + "tools": ["terminal", "process"], + "includes": ["web", "file"] # For searching error messages and solutions, and file operations + }, + + "safe": { + "description": "Safe toolkit without terminal access", + "tools": [], + "includes": ["web", "vision", "image_gen"] + }, + + # ========================================================================== + # Full Hermes toolsets (CLI + messaging platforms) + # + # All platforms share the same core tools (including send_message, + # which is gated on gateway running via its check_fn). + # ========================================================================== + + "hermes-acp": { + "description": "Editor integration (VS Code, Zed, JetBrains) — coding-focused tools without messaging, audio, or clarify UI", + "tools": [ + "web_search", "web_extract", + "terminal", "process", + "read_file", "write_file", "patch", "search_files", + "vision_analyze", + "skills_list", "skill_view", "skill_manage", + "browser_navigate", "browser_snapshot", "browser_click", + "browser_type", "browser_scroll", "browser_back", + "browser_press", "browser_get_images", + "browser_vision", "browser_console", + "todo", "memory", + "session_search", + "execute_code", "delegate_task", + ], + "includes": [] + }, + + "hermes-api-server": { + "description": "OpenAI-compatible API server — full agent tools accessible via HTTP (no interactive UI tools like clarify or send_message)", + "tools": [ + # Web + "web_search", "web_extract", + # Terminal + process management + "terminal", "process", + # File manipulation + "read_file", "write_file", "patch", "search_files", + # Vision + image generation + "vision_analyze", "image_generate", + # Skills + "skills_list", "skill_view", "skill_manage", + # Browser automation + "browser_navigate", "browser_snapshot", "browser_click", + "browser_type", "browser_scroll", "browser_back", + "browser_press", "browser_get_images", + "browser_vision", "browser_console", + # Planning & memory + "todo", "memory", + # Session history search + "session_search", + # Code execution + delegation + "execute_code", "delegate_task", + # Cronjob management + "cronjob", + # Home Assistant smart home control (gated on HASS_TOKEN via check_fn) + "ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service", + + ], + "includes": [] + }, + + "hermes-cli": { + "description": "Full interactive CLI toolset - all default tools plus cronjob management", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-telegram": { + "description": "Telegram bot toolset - full access for personal use (terminal has safety checks)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-discord": { + "description": "Discord bot toolset - full access (terminal has safety checks via dangerous command approval)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-whatsapp": { + "description": "WhatsApp bot toolset - similar to Telegram (personal messaging, more trusted)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-slack": { + "description": "Slack bot toolset - full access for workspace use (terminal has safety checks)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-signal": { + "description": "Signal bot toolset - encrypted messaging platform (full access)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-bluebubbles": { + "description": "BlueBubbles iMessage bot toolset - Apple iMessage via local BlueBubbles server", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-homeassistant": { + "description": "Home Assistant bot toolset - smart home event monitoring and control", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-email": { + "description": "Email bot toolset - interact with Hermes via email (IMAP/SMTP)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-mattermost": { + "description": "Mattermost bot toolset - self-hosted team messaging (full access)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-matrix": { + "description": "Matrix bot toolset - decentralized encrypted messaging (full access)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-dingtalk": { + "description": "DingTalk bot toolset - enterprise messaging platform (full access)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-feishu": { + "description": "Feishu/Lark bot toolset - enterprise messaging via Feishu/Lark (full access)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-weixin": { + "description": "Weixin bot toolset - personal WeChat messaging via iLink (full access)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-qqbot": { + "description": "QQBot toolset - QQ messaging via Official Bot API v2 (full access)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-wecom": { + "description": "WeCom bot toolset - enterprise WeChat messaging (full access)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-wecom-callback": { + "description": "WeCom callback toolset - enterprise self-built app messaging (full access)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-sms": { + "description": "SMS bot toolset - interact with Hermes via SMS (Twilio)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-webhook": { + "description": "Webhook toolset - receive and process external webhook events", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-gateway": { + "description": "Gateway toolset - union of all messaging platform tools", + "tools": [], + "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-bluebubbles", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom", "hermes-wecom-callback", "hermes-weixin", "hermes-qqbot", "hermes-webhook"] + } +} + + + +def get_toolset(name: str) -> Optional[Dict[str, Any]]: + """ + Get a toolset definition by name. + + Args: + name (str): Name of the toolset + + Returns: + Dict: Toolset definition with description, tools, and includes + None: If toolset not found + """ + # Return toolset definition + return TOOLSETS.get(name) + + +def resolve_toolset(name: str, visited: Set[str] = None) -> List[str]: + """ + Recursively resolve a toolset to get all tool names. + + This function handles toolset composition by recursively resolving + included toolsets and combining all tools. + + Args: + name (str): Name of the toolset to resolve + visited (Set[str]): Set of already visited toolsets (for cycle detection) + + Returns: + List[str]: List of all tool names in the toolset + """ + if visited is None: + visited = set() + + # Special aliases that represent all tools across every toolset + # This ensures future toolsets are automatically included without changes. + if name in {"all", "*"}: + all_tools: Set[str] = set() + for toolset_name in get_toolset_names(): + # Use a fresh visited set per branch to avoid cross-branch contamination + resolved = resolve_toolset(toolset_name, visited.copy()) + all_tools.update(resolved) + return list(all_tools) + + # Check for cycles / already-resolved (diamond deps). + # Silently return [] — either this is a diamond (not a bug, tools already + # collected via another path) or a genuine cycle (safe to skip). + if name in visited: + return [] + + visited.add(name) + + # Get toolset definition + toolset = TOOLSETS.get(name) + if not toolset: + # Fall back to tool registry for plugin-provided toolsets + if name in _get_plugin_toolset_names(): + try: + from tools.registry import registry + return registry.get_tool_names_for_toolset(name) + except Exception: + pass + return [] + + # Collect direct tools + tools = set(toolset.get("tools", [])) + + # Recursively resolve included toolsets, sharing the visited set across + # sibling includes so diamond dependencies are only resolved once and + # cycle warnings don't fire multiple times for the same cycle. + for included_name in toolset.get("includes", []): + included_tools = resolve_toolset(included_name, visited) + tools.update(included_tools) + + return list(tools) + + +def resolve_multiple_toolsets(toolset_names: List[str]) -> List[str]: + """ + Resolve multiple toolsets and combine their tools. + + Args: + toolset_names (List[str]): List of toolset names to resolve + + Returns: + List[str]: Combined list of all tool names (deduplicated) + """ + all_tools = set() + + for name in toolset_names: + tools = resolve_toolset(name) + all_tools.update(tools) + + return list(all_tools) + + +def _get_plugin_toolset_names() -> Set[str]: + """Return toolset names registered by plugins (from the tool registry). + + These are toolsets that exist in the registry but not in the static + ``TOOLSETS`` dict — i.e. they were added by plugins at load time. + """ + try: + from tools.registry import registry + return { + toolset_name + for toolset_name in registry.get_registered_toolset_names() + if toolset_name not in TOOLSETS + } + except Exception: + return set() + + +def get_all_toolsets() -> Dict[str, Dict[str, Any]]: + """ + Get all available toolsets with their definitions. + + Includes both statically-defined toolsets and plugin-registered ones. + + Returns: + Dict: All toolset definitions + """ + result = TOOLSETS.copy() + # Add plugin-provided toolsets (synthetic entries) + for ts_name in _get_plugin_toolset_names(): + if ts_name not in result: + try: + from tools.registry import registry + tools = registry.get_tool_names_for_toolset(ts_name) + result[ts_name] = { + "description": f"Plugin toolset: {ts_name}", + "tools": tools, + } + except Exception: + pass + return result + + +def get_toolset_names() -> List[str]: + """ + Get names of all available toolsets (excluding aliases). + + Includes plugin-registered toolset names. + + Returns: + List[str]: List of toolset names + """ + names = set(TOOLSETS.keys()) + names |= _get_plugin_toolset_names() + return sorted(names) + + + + +def validate_toolset(name: str) -> bool: + """ + Check if a toolset name is valid. + + Args: + name (str): Toolset name to validate + + Returns: + bool: True if valid, False otherwise + """ + # Accept special alias names for convenience + if name in {"all", "*"}: + return True + if name in TOOLSETS: + return True + # Check tool registry for plugin-provided toolsets + return name in _get_plugin_toolset_names() + + +def create_custom_toolset( + name: str, + description: str, + tools: List[str] = None, + includes: List[str] = None +) -> None: + """ + Create a custom toolset at runtime. + + Args: + name (str): Name for the new toolset + description (str): Description of the toolset + tools (List[str]): Direct tools to include + includes (List[str]): Other toolsets to include + """ + TOOLSETS[name] = { + "description": description, + "tools": tools or [], + "includes": includes or [] + } + + + + +def get_toolset_info(name: str) -> Dict[str, Any]: + """ + Get detailed information about a toolset including resolved tools. + + Args: + name (str): Toolset name + + Returns: + Dict: Detailed toolset information + """ + toolset = get_toolset(name) + if not toolset: + return None + + resolved_tools = resolve_toolset(name) + + return { + "name": name, + "description": toolset["description"], + "direct_tools": toolset["tools"], + "includes": toolset["includes"], + "resolved_tools": resolved_tools, + "tool_count": len(resolved_tools), + "is_composite": bool(toolset["includes"]) + } + + + + +if __name__ == "__main__": + print("Toolsets System Demo") + print("=" * 60) + + print("\nAvailable Toolsets:") + print("-" * 40) + for name, toolset in get_all_toolsets().items(): + info = get_toolset_info(name) + composite = "[composite]" if info["is_composite"] else "[leaf]" + print(f" {composite} {name:20} - {toolset['description']}") + print(f" Tools: {len(info['resolved_tools'])} total") + + print("\nToolset Resolution Examples:") + print("-" * 40) + for name in ["web", "terminal", "safe", "debugging"]: + tools = resolve_toolset(name) + print(f"\n {name}:") + print(f" Resolved to {len(tools)} tools: {', '.join(sorted(tools))}") + + print("\nMultiple Toolset Resolution:") + print("-" * 40) + combined = resolve_multiple_toolsets(["web", "vision", "terminal"]) + print(" Combining ['web', 'vision', 'terminal']:") + print(f" Result: {', '.join(sorted(combined))}") + + print("\nCustom Toolset Creation:") + print("-" * 40) + create_custom_toolset( + name="my_custom", + description="My custom toolset for specific tasks", + tools=["web_search"], + includes=["terminal", "vision"] + ) + custom_info = get_toolset_info("my_custom") + print(" Created 'my_custom' toolset:") + print(f" Description: {custom_info['description']}") + print(f" Resolved tools: {', '.join(custom_info['resolved_tools'])}") diff --git a/mindcli/_vendor/utils.py b/mindcli/_vendor/utils.py new file mode 100644 index 0000000..f967c08 --- /dev/null +++ b/mindcli/_vendor/utils.py @@ -0,0 +1,164 @@ +"""Shared utility functions for hermes-agent.""" + +import json +import logging +import os +import tempfile +from pathlib import Path +from typing import Any, Union + +import yaml + +logger = logging.getLogger(__name__) + + +TRUTHY_STRINGS = frozenset({"1", "true", "yes", "on"}) + + +def is_truthy_value(value: Any, default: bool = False) -> bool: + """Coerce bool-ish values using the project's shared truthy string set.""" + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in TRUTHY_STRINGS + return bool(value) + + +def env_var_enabled(name: str, default: str = "") -> bool: + """Return True when an environment variable is set to a truthy value.""" + return is_truthy_value(os.getenv(name, default), default=False) + + +def atomic_json_write( + path: Union[str, Path], + data: Any, + *, + indent: int = 2, + **dump_kwargs: Any, +) -> None: + """Write JSON data to a file atomically. + + Uses temp file + fsync + os.replace to ensure the target file is never + left in a partially-written state. If the process crashes mid-write, + the previous version of the file remains intact. + + Args: + path: Target file path (will be created or overwritten). + data: JSON-serializable data to write. + indent: JSON indentation (default 2). + **dump_kwargs: Additional keyword args forwarded to json.dump(), such + as default=str for non-native types. + """ + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + + fd, tmp_path = tempfile.mkstemp( + dir=str(path.parent), + prefix=f".{path.stem}_", + suffix=".tmp", + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump( + data, + f, + indent=indent, + ensure_ascii=False, + **dump_kwargs, + ) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, path) + except BaseException: + # Intentionally catch BaseException so temp-file cleanup still runs for + # KeyboardInterrupt/SystemExit before re-raising the original signal. + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + +def atomic_yaml_write( + path: Union[str, Path], + data: Any, + *, + default_flow_style: bool = False, + sort_keys: bool = False, + extra_content: str | None = None, +) -> None: + """Write YAML data to a file atomically. + + Uses temp file + fsync + os.replace to ensure the target file is never + left in a partially-written state. If the process crashes mid-write, + the previous version of the file remains intact. + + Args: + path: Target file path (will be created or overwritten). + data: YAML-serializable data to write. + default_flow_style: YAML flow style (default False). + sort_keys: Whether to sort dict keys (default False). + extra_content: Optional string to append after the YAML dump + (e.g. commented-out sections for user reference). + """ + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + + fd, tmp_path = tempfile.mkstemp( + dir=str(path.parent), + prefix=f".{path.stem}_", + suffix=".tmp", + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=default_flow_style, sort_keys=sort_keys) + if extra_content: + f.write(extra_content) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, path) + except BaseException: + # Match atomic_json_write: cleanup must also happen for process-level + # interruptions before we re-raise them. + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + +# ─── JSON Helpers ───────────────────────────────────────────────────────────── + + +def safe_json_loads(text: str, default: Any = None) -> Any: + """Parse JSON, returning *default* on any parse error. + + Replaces the ``try: json.loads(x) except (JSONDecodeError, TypeError)`` + pattern duplicated across display.py, anthropic_adapter.py, + auxiliary_client.py, and others. + """ + try: + return json.loads(text) + except (json.JSONDecodeError, TypeError, ValueError): + return default + + +# ─── Environment Variable Helpers ───────────────────────────────────────────── + + +def env_int(key: str, default: int = 0) -> int: + """Read an environment variable as an integer, with fallback.""" + raw = os.getenv(key, "").strip() + if not raw: + return default + try: + return int(raw) + except (ValueError, TypeError): + return default + + +def env_bool(key: str, default: bool = False) -> bool: + """Read an environment variable as a boolean.""" + return is_truthy_value(os.getenv(key, ""), default=default) diff --git a/pyproject.toml b/pyproject.toml index 7360fde..d380532 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,8 @@ dependencies = [ # Mind CLI 专属 "websockets>=12.0", "click>=8.0", + # Hermes _vendor 隐性依赖 + "python-dotenv>=1.0", ] [project.optional-dependencies] diff --git a/scripts/vendor_hermes.sh b/scripts/vendor_hermes.sh index 04875a8..4a4d487 100755 --- a/scripts/vendor_hermes.sh +++ b/scripts/vendor_hermes.sh @@ -40,24 +40,39 @@ echo "📦 Vendoring hermes@$COMMIT → $VENDOR_DIR" rm -rf "$VENDOR_DIR" mkdir -p "$VENDOR_DIR" -# 核心模块 -cp "$HERMES_SRC/cli.py" "$VENDOR_DIR/" -cp "$HERMES_SRC/run_agent.py" "$VENDOR_DIR/" -cp "$HERMES_SRC/mcp_serve.py" "$VENDOR_DIR/" -cp "$HERMES_SRC/hermes_state.py" "$VENDOR_DIR/" -cp "$HERMES_SRC/hermes_constants.py" "$VENDOR_DIR/" -cp "$HERMES_SRC/batch_runner.py" "$VENDOR_DIR/" 2>/dev/null || true +# 核心模块(单文件) +for pyfile in cli.py run_agent.py mcp_serve.py hermes_state.py hermes_constants.py \ + batch_runner.py model_tools.py utils.py toolsets.py toolset_distributions.py \ + hermes_time.py; do + if [ -f "$HERMES_SRC/$pyfile" ]; then + cp "$HERMES_SRC/$pyfile" "$VENDOR_DIR/" + echo " ✓ $pyfile" + else + echo " ⚠ $pyfile 不存在,跳过" + fi +done -# 子模块 -cp -r "$HERMES_SRC/hermes_cli" "$VENDOR_DIR/" -cp -r "$HERMES_SRC/tools" "$VENDOR_DIR/" -cp -r "$HERMES_SRC/agent" "$VENDOR_DIR/" +# 子模块(目录) +for subdir in hermes_cli tools agent model_tools utils toolsets toolset_distributions gateway cron; do + if [ -d "$HERMES_SRC/$subdir" ]; then + cp -r "$HERMES_SRC/$subdir" "$VENDOR_DIR/" + echo " ✓ $subdir/" + else + echo " ⚠ $subdir/ 不存在,跳过" + fi +done # __init__.py touch "$VENDOR_DIR/__init__.py" # 版本锁定标记 -echo "$COMMIT" > "$VENDOR_DIR/HERMES_COMMIT" +cat > "$VENDOR_DIR/VENDOR_COMMIT" << MARKER +# MindOS CLI Vendor Snapshot +source: hermes +commit: $COMMIT +snapshot_date: $(date +%Y-%m-%d) +snapshot_by: vendor_hermes.sh +MARKER # 统计 FILE_COUNT=$(find "$VENDOR_DIR" -name "*.py" | wc -l | tr -d ' ')