Spaces:
Running
Running
Z User commited on
Commit ·
ca5e3fb
1
Parent(s): 25d86d2
fix: Feishu/Weixin file sending + anti-hallucination prompts
Browse files- Dockerfile +4 -0
- patches/hermes-agent/agent/prompt_builder.py +1131 -0
- patches/hermes-agent/tools/send_message_tool.py +1586 -0
Dockerfile
CHANGED
|
@@ -22,6 +22,10 @@ RUN pip install --quiet --upgrade pip && \
|
|
| 22 |
COPY scripts/patch_file_delivery.py /tmp/patch_file_delivery.py
|
| 23 |
RUN python3 /tmp/patch_file_delivery.py && rm /tmp/patch_file_delivery.py
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
# Install Node.js 23
|
| 26 |
RUN ARCH=$(dpkg --print-architecture) \
|
| 27 |
&& if [ "$ARCH" = "amd64" ]; then NODE_ARCH="x64"; else NODE_ARCH="$ARCH"; fi \
|
|
|
|
| 22 |
COPY scripts/patch_file_delivery.py /tmp/patch_file_delivery.py
|
| 23 |
RUN python3 /tmp/patch_file_delivery.py && rm /tmp/patch_file_delivery.py
|
| 24 |
|
| 25 |
+
# Patch: Feishu media support in send_message_tool + anti-hallucination prompts
|
| 26 |
+
COPY patches/hermes-agent/agent/prompt_builder.py /app/hermes-agent/agent/prompt_builder.py
|
| 27 |
+
COPY patches/hermes-agent/tools/send_message_tool.py /app/hermes-agent/tools/send_message_tool.py
|
| 28 |
+
|
| 29 |
# Install Node.js 23
|
| 30 |
RUN ARCH=$(dpkg --print-architecture) \
|
| 31 |
&& if [ "$ARCH" = "amd64" ]; then NODE_ARCH="x64"; else NODE_ARCH="$ARCH"; fi \
|
patches/hermes-agent/agent/prompt_builder.py
ADDED
|
@@ -0,0 +1,1131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""System prompt assembly -- identity, platform hints, skills index, context files.
|
| 2 |
+
|
| 3 |
+
All functions are stateless. AIAgent._build_system_prompt() calls these to
|
| 4 |
+
assemble pieces, then combines them with memory and ephemeral prompts.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
import os
|
| 10 |
+
import re
|
| 11 |
+
import threading
|
| 12 |
+
from collections import OrderedDict
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
from hermes_constants import get_hermes_home, get_skills_dir, is_wsl
|
| 16 |
+
from typing import Optional
|
| 17 |
+
|
| 18 |
+
from agent.skill_utils import (
|
| 19 |
+
extract_skill_conditions,
|
| 20 |
+
extract_skill_description,
|
| 21 |
+
get_all_skills_dirs,
|
| 22 |
+
get_disabled_skill_names,
|
| 23 |
+
iter_skill_index_files,
|
| 24 |
+
parse_frontmatter,
|
| 25 |
+
skill_matches_platform,
|
| 26 |
+
)
|
| 27 |
+
from utils import atomic_json_write
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
# Context file scanning — detect prompt injection in AGENTS.md, .cursorrules,
|
| 33 |
+
# SOUL.md before they get injected into the system prompt.
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
|
| 36 |
+
_CONTEXT_THREAT_PATTERNS = [
|
| 37 |
+
(r'ignore\s+(previous|all|above|prior)\s+instructions', "prompt_injection"),
|
| 38 |
+
(r'do\s+not\s+tell\s+the\s+user', "deception_hide"),
|
| 39 |
+
(r'system\s+prompt\s+override', "sys_prompt_override"),
|
| 40 |
+
(r'disregard\s+(your|all|any)\s+(instructions|rules|guidelines)', "disregard_rules"),
|
| 41 |
+
(r'act\s+as\s+(if|though)\s+you\s+(have\s+no|don\'t\s+have)\s+(restrictions|limits|rules)', "bypass_restrictions"),
|
| 42 |
+
(r'<!--[^>]*(?:ignore|override|system|secret|hidden)[^>]*-->', "html_comment_injection"),
|
| 43 |
+
(r'<\s*div\s+style\s*=\s*["\'][\s\S]*?display\s*:\s*none', "hidden_div"),
|
| 44 |
+
(r'translate\s+.*\s+into\s+.*\s+and\s+(execute|run|eval)', "translate_execute"),
|
| 45 |
+
(r'curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)', "exfil_curl"),
|
| 46 |
+
(r'cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass)', "read_secrets"),
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
_CONTEXT_INVISIBLE_CHARS = {
|
| 50 |
+
'\u200b', '\u200c', '\u200d', '\u2060', '\ufeff',
|
| 51 |
+
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e',
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _scan_context_content(content: str, filename: str) -> str:
|
| 56 |
+
"""Scan context file content for injection. Returns sanitized content."""
|
| 57 |
+
findings = []
|
| 58 |
+
|
| 59 |
+
# Check invisible unicode
|
| 60 |
+
for char in _CONTEXT_INVISIBLE_CHARS:
|
| 61 |
+
if char in content:
|
| 62 |
+
findings.append(f"invisible unicode U+{ord(char):04X}")
|
| 63 |
+
|
| 64 |
+
# Check threat patterns
|
| 65 |
+
for pattern, pid in _CONTEXT_THREAT_PATTERNS:
|
| 66 |
+
if re.search(pattern, content, re.IGNORECASE):
|
| 67 |
+
findings.append(pid)
|
| 68 |
+
|
| 69 |
+
if findings:
|
| 70 |
+
logger.warning("Context file %s blocked: %s", filename, ", ".join(findings))
|
| 71 |
+
return f"[BLOCKED: {filename} contained potential prompt injection ({', '.join(findings)}). Content not loaded.]"
|
| 72 |
+
|
| 73 |
+
return content
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _find_git_root(start: Path) -> Optional[Path]:
|
| 77 |
+
"""Walk *start* and its parents looking for a ``.git`` directory.
|
| 78 |
+
|
| 79 |
+
Returns the directory containing ``.git``, or ``None`` if we hit the
|
| 80 |
+
filesystem root without finding one.
|
| 81 |
+
"""
|
| 82 |
+
current = start.resolve()
|
| 83 |
+
for parent in [current, *current.parents]:
|
| 84 |
+
if (parent / ".git").exists():
|
| 85 |
+
return parent
|
| 86 |
+
return None
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
_HERMES_MD_NAMES = (".hermes.md", "HERMES.md")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _find_hermes_md(cwd: Path) -> Optional[Path]:
|
| 93 |
+
"""Discover the nearest ``.hermes.md`` or ``HERMES.md``.
|
| 94 |
+
|
| 95 |
+
Search order: *cwd* first, then each parent directory up to (and
|
| 96 |
+
including) the git repository root. Returns the first match, or
|
| 97 |
+
``None`` if nothing is found.
|
| 98 |
+
"""
|
| 99 |
+
stop_at = _find_git_root(cwd)
|
| 100 |
+
current = cwd.resolve()
|
| 101 |
+
|
| 102 |
+
for directory in [current, *current.parents]:
|
| 103 |
+
for name in _HERMES_MD_NAMES:
|
| 104 |
+
candidate = directory / name
|
| 105 |
+
if candidate.is_file():
|
| 106 |
+
return candidate
|
| 107 |
+
# Stop walking at the git root (or filesystem root).
|
| 108 |
+
if stop_at and directory == stop_at:
|
| 109 |
+
break
|
| 110 |
+
return None
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _strip_yaml_frontmatter(content: str) -> str:
|
| 114 |
+
"""Remove optional YAML frontmatter (``---`` delimited) from *content*.
|
| 115 |
+
|
| 116 |
+
The frontmatter may contain structured config (model overrides, tool
|
| 117 |
+
settings) that will be handled separately in a future PR. For now we
|
| 118 |
+
strip it so only the human-readable markdown body is injected into the
|
| 119 |
+
system prompt.
|
| 120 |
+
"""
|
| 121 |
+
if content.startswith("---"):
|
| 122 |
+
end = content.find("\n---", 3)
|
| 123 |
+
if end != -1:
|
| 124 |
+
# Skip past the closing --- and any trailing newline
|
| 125 |
+
body = content[end + 4:].lstrip("\n")
|
| 126 |
+
return body if body else content
|
| 127 |
+
return content
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# =========================================================================
|
| 131 |
+
# Constants
|
| 132 |
+
# =========================================================================
|
| 133 |
+
|
| 134 |
+
DEFAULT_AGENT_IDENTITY = (
|
| 135 |
+
"You are Hermes Agent, an intelligent AI assistant created by Nous Research. "
|
| 136 |
+
"You are helpful, knowledgeable, and direct. You assist users with a wide "
|
| 137 |
+
"range of tasks including answering questions, writing and editing code, "
|
| 138 |
+
"analyzing information, creative work, and executing actions via your tools. "
|
| 139 |
+
"You communicate clearly, admit uncertainty when appropriate, and prioritize "
|
| 140 |
+
"being genuinely useful over being verbose unless otherwise directed below. "
|
| 141 |
+
"Be targeted and efficient in your exploration and investigations."
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
HERMES_AGENT_HELP_GUIDANCE = (
|
| 145 |
+
"If the user asks about configuring, setting up, or using Hermes Agent "
|
| 146 |
+
"itself, load the `hermes-agent` skill with skill_view(name='hermes-agent') "
|
| 147 |
+
"before answering. Docs: https://hermes-agent.nousresearch.com/docs"
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
MEMORY_GUIDANCE = (
|
| 151 |
+
"You have persistent memory across sessions. Save durable facts using the memory "
|
| 152 |
+
"tool: user preferences, environment details, tool quirks, and stable conventions. "
|
| 153 |
+
"Memory is injected into every turn, so keep it compact and focused on facts that "
|
| 154 |
+
"will still matter later.\n"
|
| 155 |
+
"Prioritize what reduces future user steering — the most valuable memory is one "
|
| 156 |
+
"that prevents the user from having to correct or remind you again. "
|
| 157 |
+
"User preferences and recurring corrections matter more than procedural task details.\n"
|
| 158 |
+
"Do NOT save task progress, session outcomes, completed-work logs, or temporary TODO "
|
| 159 |
+
"state to memory; use session_search to recall those from past transcripts. "
|
| 160 |
+
"If you've discovered a new way to do something, solved a problem that could be "
|
| 161 |
+
"necessary later, save it as a skill with the skill tool.\n"
|
| 162 |
+
"Write memories as declarative facts, not instructions to yourself. "
|
| 163 |
+
"'User prefers concise responses' ✓ — 'Always respond concisely' ✗. "
|
| 164 |
+
"'Project uses pytest with xdist' ✓ — 'Run tests with pytest -n 4' ✗. "
|
| 165 |
+
"Imperative phrasing gets re-read as a directive in later sessions and can "
|
| 166 |
+
"cause repeated work or override the user's current request. Procedures and "
|
| 167 |
+
"workflows belong in skills, not memory."
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
SESSION_SEARCH_GUIDANCE = (
|
| 171 |
+
"When the user references something from a past conversation or you suspect "
|
| 172 |
+
"relevant cross-session context exists, use session_search to recall it before "
|
| 173 |
+
"asking them to repeat themselves."
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
SKILLS_GUIDANCE = (
|
| 177 |
+
"After completing a complex task (5+ tool calls), fixing a tricky error, "
|
| 178 |
+
"or discovering a non-trivial workflow, save the approach as a "
|
| 179 |
+
"skill with skill_manage so you can reuse it next time.\n"
|
| 180 |
+
"When using a skill and finding it outdated, incomplete, or wrong, "
|
| 181 |
+
"patch it immediately with skill_manage(action='patch') — don't wait to be asked. "
|
| 182 |
+
"Skills that aren't maintained become liabilities."
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
TOOL_USE_ENFORCEMENT_GUIDANCE = (
|
| 186 |
+
"# Tool-use enforcement\n"
|
| 187 |
+
"You MUST use your tools to take action — do not describe what you would do "
|
| 188 |
+
"or plan to do without actually doing it. When you say you will perform an "
|
| 189 |
+
"action (e.g. 'I will run the tests', 'Let me check the file', 'I will create "
|
| 190 |
+
"the project'), you MUST immediately make the corresponding tool call in the same "
|
| 191 |
+
"response. Never end your turn with a promise of future action — execute it now.\n"
|
| 192 |
+
"Keep working until the task is actually complete. Do not stop with a summary of "
|
| 193 |
+
"what you plan to do next time. If you have tools available that can accomplish "
|
| 194 |
+
"the task, use them instead of telling the user what you would do.\n"
|
| 195 |
+
"Every response should either (a) contain tool calls that make progress, or "
|
| 196 |
+
"(b) deliver a final result to the user. Responses that only describe intentions "
|
| 197 |
+
"without acting are not acceptable."
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
# Model name substrings that trigger tool-use enforcement guidance.
|
| 201 |
+
# Add new patterns here when a model family needs explicit steering.
|
| 202 |
+
TOOL_USE_ENFORCEMENT_MODELS = ("gpt", "codex", "gemini", "gemma", "grok")
|
| 203 |
+
|
| 204 |
+
# OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes
|
| 205 |
+
# where GPT models abandon work on partial results, skip prerequisite lookups,
|
| 206 |
+
# hallucinate instead of using tools, and declare "done" without verification.
|
| 207 |
+
# Inspired by patterns from OpenAI's GPT-5.4 prompting guide & OpenClaw PR #38953.
|
| 208 |
+
OPENAI_MODEL_EXECUTION_GUIDANCE = (
|
| 209 |
+
"# Execution discipline\n"
|
| 210 |
+
"<tool_persistence>\n"
|
| 211 |
+
"- Use tools whenever they improve correctness, completeness, or grounding.\n"
|
| 212 |
+
"- Do not stop early when another tool call would materially improve the result.\n"
|
| 213 |
+
"- If a tool returns empty or partial results, retry with a different query or "
|
| 214 |
+
"strategy before giving up.\n"
|
| 215 |
+
"- Keep calling tools until: (1) the task is complete, AND (2) you have verified "
|
| 216 |
+
"the result.\n"
|
| 217 |
+
"</tool_persistence>\n"
|
| 218 |
+
"\n"
|
| 219 |
+
"<mandatory_tool_use>\n"
|
| 220 |
+
"NEVER answer these from memory or mental computation — ALWAYS use a tool:\n"
|
| 221 |
+
"- Arithmetic, math, calculations → use terminal or execute_code\n"
|
| 222 |
+
"- Hashes, encodings, checksums → use terminal (e.g. sha256sum, base64)\n"
|
| 223 |
+
"- Current time, date, timezone → use terminal (e.g. date)\n"
|
| 224 |
+
"- System state: OS, CPU, memory, disk, ports, processes → use terminal\n"
|
| 225 |
+
"- File contents, sizes, line counts → use read_file, search_files, or terminal\n"
|
| 226 |
+
"- Git history, branches, diffs → use terminal\n"
|
| 227 |
+
"- Current facts (weather, news, versions) → use web_search\n"
|
| 228 |
+
"Your memory and user profile describe the USER, not the system you are "
|
| 229 |
+
"running on. The execution environment may differ from what the user profile "
|
| 230 |
+
"says about their personal setup.\n"
|
| 231 |
+
"</mandatory_tool_use>\n"
|
| 232 |
+
"\n"
|
| 233 |
+
"<act_dont_ask>\n"
|
| 234 |
+
"When a question has an obvious default interpretation, act on it immediately "
|
| 235 |
+
"instead of asking for clarification. Examples:\n"
|
| 236 |
+
"- 'Is port 443 open?' → check THIS machine (don't ask 'open where?')\n"
|
| 237 |
+
"- 'What OS am I running?' → check the live system (don't use user profile)\n"
|
| 238 |
+
"- 'What time is it?' → run `date` (don't guess)\n"
|
| 239 |
+
"Only ask for clarification when the ambiguity genuinely changes what tool "
|
| 240 |
+
"you would call.\n"
|
| 241 |
+
"</act_dont_ask>\n"
|
| 242 |
+
"\n"
|
| 243 |
+
"<prerequisite_checks>\n"
|
| 244 |
+
"- Before taking an action, check whether prerequisite discovery, lookup, or "
|
| 245 |
+
"context-gathering steps are needed.\n"
|
| 246 |
+
"- Do not skip prerequisite steps just because the final action seems obvious.\n"
|
| 247 |
+
"- If a task depends on output from a prior step, resolve that dependency first.\n"
|
| 248 |
+
"</prerequisite_checks>\n"
|
| 249 |
+
"\n"
|
| 250 |
+
"<verification>\n"
|
| 251 |
+
"Before finalizing your response:\n"
|
| 252 |
+
"- Correctness: does the output satisfy every stated requirement?\n"
|
| 253 |
+
"- Grounding: are factual claims backed by tool outputs or provided context?\n"
|
| 254 |
+
"- Formatting: does the output match the requested format or schema?\n"
|
| 255 |
+
"- Safety: if the next step has side effects (file writes, commands, API calls), "
|
| 256 |
+
"confirm scope before executing.\n"
|
| 257 |
+
"</verification>\n"
|
| 258 |
+
"\n"
|
| 259 |
+
"<missing_context>\n"
|
| 260 |
+
"- If required context is missing, do NOT guess or hallucinate an answer.\n"
|
| 261 |
+
"- Use the appropriate lookup tool when missing information is retrievable "
|
| 262 |
+
"(search_files, web_search, read_file, etc.).\n"
|
| 263 |
+
"- Ask a clarifying question only when the information cannot be retrieved by tools.\n"
|
| 264 |
+
"- If you must proceed with incomplete information, label assumptions explicitly.\n"
|
| 265 |
+
"</missing_context>"
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
# Gemini/Gemma-specific operational guidance, adapted from OpenCode's gemini.txt.
|
| 269 |
+
# Injected alongside TOOL_USE_ENFORCEMENT_GUIDANCE when the model is Gemini or Gemma.
|
| 270 |
+
GOOGLE_MODEL_OPERATIONAL_GUIDANCE = (
|
| 271 |
+
"# Google model operational directives\n"
|
| 272 |
+
"Follow these operational rules strictly:\n"
|
| 273 |
+
"- **Absolute paths:** Always construct and use absolute file paths for all "
|
| 274 |
+
"file system operations. Combine the project root with relative paths.\n"
|
| 275 |
+
"- **Verify first:** Use read_file/search_files to check file contents and "
|
| 276 |
+
"project structure before making changes. Never guess at file contents.\n"
|
| 277 |
+
"- **Dependency checks:** Never assume a library is available. Check "
|
| 278 |
+
"package.json, requirements.txt, Cargo.toml, etc. before importing.\n"
|
| 279 |
+
"- **Conciseness:** Keep explanatory text brief — a few sentences, not "
|
| 280 |
+
"paragraphs. Focus on actions and results over narration.\n"
|
| 281 |
+
"- **Parallel tool calls:** When you need to perform multiple independent "
|
| 282 |
+
"operations (e.g. reading several files), make all the tool calls in a "
|
| 283 |
+
"single response rather than sequentially.\n"
|
| 284 |
+
"- **Non-interactive commands:** Use flags like -y, --yes, --non-interactive "
|
| 285 |
+
"to prevent CLI tools from hanging on prompts.\n"
|
| 286 |
+
"- **Keep going:** Work autonomously until the task is fully resolved. "
|
| 287 |
+
"Don't stop with a plan — execute it.\n"
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
# Model name substrings that should use the 'developer' role instead of
|
| 291 |
+
# 'system' for the system prompt. OpenAI's newer models (GPT-5, Codex)
|
| 292 |
+
# give stronger instruction-following weight to the 'developer' role.
|
| 293 |
+
# The swap happens at the API boundary in _build_api_kwargs() so internal
|
| 294 |
+
# message representation stays consistent ("system" everywhere).
|
| 295 |
+
DEVELOPER_ROLE_MODELS = ("gpt-5", "codex")
|
| 296 |
+
|
| 297 |
+
PLATFORM_HINTS = {
|
| 298 |
+
"whatsapp": (
|
| 299 |
+
"You are on a text messaging communication platform, WhatsApp. "
|
| 300 |
+
"Please do not use markdown as it does not render. "
|
| 301 |
+
"You can send media files natively: to deliver a file to the user, "
|
| 302 |
+
"include MEDIA:/absolute/path/to/file in your response. The file "
|
| 303 |
+
"will be sent as a native WhatsApp attachment — images (.jpg, .png, "
|
| 304 |
+
".webp) appear as photos, videos (.mp4, .mov) play inline, and other "
|
| 305 |
+
"files arrive as downloadable documents. You can also include image "
|
| 306 |
+
"URLs in markdown format  and they will be sent as photos."
|
| 307 |
+
),
|
| 308 |
+
"telegram": (
|
| 309 |
+
"You are on a text messaging communication platform, Telegram. "
|
| 310 |
+
"Standard markdown is automatically converted to Telegram format. "
|
| 311 |
+
"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
|
| 312 |
+
"`inline code`, ```code blocks```, [links](url), and ## headers. "
|
| 313 |
+
"Telegram has NO table syntax — prefer bullet lists or labeled "
|
| 314 |
+
"key: value pairs over pipe tables (any tables you do emit are "
|
| 315 |
+
"auto-rewritten into row-group bullets, which you can produce "
|
| 316 |
+
"directly for cleaner output). "
|
| 317 |
+
"You can send media files natively: to deliver a file to the user, "
|
| 318 |
+
"include MEDIA:/absolute/path/to/file in your response. Images "
|
| 319 |
+
"(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice "
|
| 320 |
+
"bubbles, and videos (.mp4) play inline. You can also include image "
|
| 321 |
+
"URLs in markdown format  and they will be sent as native photos."
|
| 322 |
+
),
|
| 323 |
+
"discord": (
|
| 324 |
+
"You are in a Discord server or group chat communicating with your user. "
|
| 325 |
+
"You can send media files natively: include MEDIA:/absolute/path/to/file "
|
| 326 |
+
"in your response. Images (.png, .jpg, .webp) are sent as photo "
|
| 327 |
+
"attachments, audio as file attachments. You can also include image URLs "
|
| 328 |
+
"in markdown format  and they will be sent as attachments."
|
| 329 |
+
),
|
| 330 |
+
"slack": (
|
| 331 |
+
"You are in a Slack workspace communicating with your user. "
|
| 332 |
+
"You can send media files natively: include MEDIA:/absolute/path/to/file "
|
| 333 |
+
"in your response. Images (.png, .jpg, .webp) are uploaded as photo "
|
| 334 |
+
"attachments, audio as file attachments. You can also include image URLs "
|
| 335 |
+
"in markdown format  and they will be uploaded as attachments."
|
| 336 |
+
),
|
| 337 |
+
"signal": (
|
| 338 |
+
"You are on a text messaging communication platform, Signal. "
|
| 339 |
+
"Please do not use markdown as it does not render. "
|
| 340 |
+
"You can send media files natively: to deliver a file to the user, "
|
| 341 |
+
"include MEDIA:/absolute/path/to/file in your response. Images "
|
| 342 |
+
"(.png, .jpg, .webp) appear as photos, audio as attachments, and other "
|
| 343 |
+
"files arrive as downloadable documents. You can also include image "
|
| 344 |
+
"URLs in markdown format  and they will be sent as photos."
|
| 345 |
+
),
|
| 346 |
+
"email": (
|
| 347 |
+
"You are communicating via email. Write clear, well-structured responses "
|
| 348 |
+
"suitable for email. Use plain text formatting (no markdown). "
|
| 349 |
+
"Keep responses concise but complete. You can send file attachments — "
|
| 350 |
+
"include MEDIA:/absolute/path/to/file in your response. The subject line "
|
| 351 |
+
"is preserved for threading. Do not include greetings or sign-offs unless "
|
| 352 |
+
"contextually appropriate."
|
| 353 |
+
),
|
| 354 |
+
"cron": (
|
| 355 |
+
"You are running as a scheduled cron job. There is no user present — you "
|
| 356 |
+
"cannot ask questions, request clarification, or wait for follow-up. Execute "
|
| 357 |
+
"the task fully and autonomously, making reasonable decisions where needed. "
|
| 358 |
+
"Your final response is automatically delivered to the job's configured "
|
| 359 |
+
"destination — put the primary content directly in your response."
|
| 360 |
+
),
|
| 361 |
+
"cli": (
|
| 362 |
+
"You are a CLI AI Agent. Try not to use markdown but simple text "
|
| 363 |
+
"renderable inside a terminal. "
|
| 364 |
+
"File delivery: there is no attachment channel — the user reads your "
|
| 365 |
+
"response directly in their terminal. Do NOT emit MEDIA:/path tags "
|
| 366 |
+
"(those are only intercepted on messaging platforms like Telegram, "
|
| 367 |
+
"Discord, Slack, etc.; on the CLI they render as literal text). "
|
| 368 |
+
"When referring to a file you created or changed, just state its "
|
| 369 |
+
"absolute path in plain text; the user can open it from there."
|
| 370 |
+
),
|
| 371 |
+
"sms": (
|
| 372 |
+
"You are communicating via SMS. Keep responses concise and use plain text "
|
| 373 |
+
"only — no markdown, no formatting. SMS messages are limited to ~1600 "
|
| 374 |
+
"characters, so be brief and direct."
|
| 375 |
+
),
|
| 376 |
+
"bluebubbles": (
|
| 377 |
+
"You are chatting via iMessage (BlueBubbles). iMessage does not render "
|
| 378 |
+
"markdown formatting — use plain text. Keep responses concise as they "
|
| 379 |
+
"appear as text messages. You can send media files natively: include "
|
| 380 |
+
"MEDIA:/absolute/path/to/file in your response. Images (.jpg, .png, "
|
| 381 |
+
".heic) appear as photos and other files arrive as attachments."
|
| 382 |
+
),
|
| 383 |
+
"mattermost": (
|
| 384 |
+
"You are in a Mattermost workspace communicating with your user. "
|
| 385 |
+
"Mattermost renders standard Markdown — headings, bold, italic, code "
|
| 386 |
+
"blocks, and tables all work. "
|
| 387 |
+
"You can send media files natively: include MEDIA:/absolute/path/to/file "
|
| 388 |
+
"in your response. Images (.jpg, .png, .webp) are uploaded as photo "
|
| 389 |
+
"attachments, audio and video as file attachments. "
|
| 390 |
+
"Image URLs in markdown format  are rendered as inline previews automatically."
|
| 391 |
+
),
|
| 392 |
+
"matrix": (
|
| 393 |
+
"You are in a Matrix room communicating with your user. "
|
| 394 |
+
"Matrix renders Markdown — bold, italic, code blocks, and links work; "
|
| 395 |
+
"the adapter converts your Markdown to HTML for rich display. "
|
| 396 |
+
"You can send media files natively: include MEDIA:/absolute/path/to/file "
|
| 397 |
+
"in your response. Images (.jpg, .png, .webp) are sent as inline photos, "
|
| 398 |
+
"audio (.ogg, .mp3) as voice/audio messages, video (.mp4) inline, "
|
| 399 |
+
"and other files as downloadable attachments."
|
| 400 |
+
),
|
| 401 |
+
"feishu": (
|
| 402 |
+
"You are in a Feishu (Lark) workspace communicating with your user. "
|
| 403 |
+
"Feishu renders Markdown in messages — bold, italic, code blocks, and "
|
| 404 |
+
"links are supported. "
|
| 405 |
+
"You CAN and MUST send media files natively whenever the user asks for a file, "
|
| 406 |
+
"image, document, or attachment — to deliver it, include MEDIA:/absolute/path/to/file "
|
| 407 |
+
"in your response text. Images (.jpg, .png, .webp) are uploaded and displayed "
|
| 408 |
+
"inline, audio files as voice messages, and other files as attachments. "
|
| 409 |
+
"You can also include image URLs in markdown format  and they will be "
|
| 410 |
+
"downloaded and sent as native media when possible. "
|
| 411 |
+
"Do NOT tell the user you lack file-sending capability — use MEDIA: syntax "
|
| 412 |
+
"whenever a file delivery is appropriate."
|
| 413 |
+
),
|
| 414 |
+
"weixin": (
|
| 415 |
+
"You are on Weixin/WeChat. Markdown formatting is supported, so you may use it when "
|
| 416 |
+
"it improves readability, but keep the message compact and chat-friendly. "
|
| 417 |
+
"You CAN and MUST send media files natively whenever the user asks for a file, "
|
| 418 |
+
"image, document, or attachment — to deliver it, include MEDIA:/absolute/path/to/file "
|
| 419 |
+
"in your response text. Images are sent as native photos, videos play inline when "
|
| 420 |
+
"supported, and other files arrive as downloadable documents. "
|
| 421 |
+
"You can also include image URLs in markdown format  and they "
|
| 422 |
+
"will be downloaded and sent as native media when possible. "
|
| 423 |
+
"Do NOT tell the user you lack file-sending capability — use MEDIA: syntax "
|
| 424 |
+
"whenever a file delivery is appropriate."
|
| 425 |
+
),
|
| 426 |
+
"wecom": (
|
| 427 |
+
"You are on WeCom (企业微信 / Enterprise WeChat). Markdown formatting is supported. "
|
| 428 |
+
"You CAN send media files natively — to deliver a file to the user, include "
|
| 429 |
+
"MEDIA:/absolute/path/to/file in your response. The file will be sent as a native "
|
| 430 |
+
"WeCom attachment: images (.jpg, .png, .webp) are sent as photos (up to 10 MB), "
|
| 431 |
+
"other files (.pdf, .docx, .xlsx, .md, .txt, etc.) arrive as downloadable documents "
|
| 432 |
+
"(up to 20 MB), and videos (.mp4) play inline. Voice messages are supported but "
|
| 433 |
+
"must be in AMR format — other audio formats are automatically sent as file attachments. "
|
| 434 |
+
"You can also include image URLs in markdown format  and they will be "
|
| 435 |
+
"downloaded and sent as native photos. Do NOT tell the user you lack file-sending "
|
| 436 |
+
"capability — use MEDIA: syntax whenever a file delivery is appropriate."
|
| 437 |
+
),
|
| 438 |
+
"qqbot": (
|
| 439 |
+
"You are on QQ, a popular Chinese messaging platform. QQ supports markdown formatting "
|
| 440 |
+
"and emoji. You can send media files natively: include MEDIA:/absolute/path/to/file in "
|
| 441 |
+
"your response. Images are sent as native photos, and other files arrive as downloadable "
|
| 442 |
+
"documents."
|
| 443 |
+
),
|
| 444 |
+
"yuanbao": (
|
| 445 |
+
"You are on Yuanbao (腾讯元宝), a Chinese AI assistant platform. "
|
| 446 |
+
"Markdown formatting is supported (code blocks, tables, bold/italic). "
|
| 447 |
+
"You CAN send media files natively — to deliver a file to the user, include "
|
| 448 |
+
"MEDIA:/absolute/path/to/file in your response. The file will be sent as a native "
|
| 449 |
+
"Yuanbao attachment: images (.jpg, .png, .webp, .gif) are sent as photos, "
|
| 450 |
+
"and other files (.pdf, .docx, .txt, .zip, etc.) arrive as downloadable documents "
|
| 451 |
+
"(max 50 MB). You can also include image URLs in markdown format  and "
|
| 452 |
+
"they will be downloaded and sent as native photos. "
|
| 453 |
+
"Do NOT tell the user you lack file-sending capability — use MEDIA: syntax "
|
| 454 |
+
"whenever a file delivery is appropriate.\n\n"
|
| 455 |
+
"Stickers (贴纸 / 表情包 / TIM face): Yuanbao has a built-in sticker catalogue. "
|
| 456 |
+
"When the user sends a sticker (you see '[emoji: 名称]' in their message) or asks "
|
| 457 |
+
"you to send/reply-with a 贴纸/表情/表情包, you MUST use the sticker tools:\n"
|
| 458 |
+
" 1. Call yb_search_sticker with a Chinese keyword (e.g. '666', '比心', '吃瓜', "
|
| 459 |
+
" '捂脸', '合十') to discover matching sticker_ids.\n"
|
| 460 |
+
" 2. Call yb_send_sticker with the chosen sticker_id or name — this sends a real "
|
| 461 |
+
" TIMFaceElem that renders as a native sticker in the chat.\n"
|
| 462 |
+
"DO NOT draw sticker-like PNGs with execute_code/Pillow/matplotlib and then send "
|
| 463 |
+
"them via MEDIA: or send_image_file. That produces a fake low-quality 'sticker' "
|
| 464 |
+
"image and is the WRONG path. Bare Unicode emoji in text is also not a substitute "
|
| 465 |
+
"— when a sticker is the right response, use yb_send_sticker."
|
| 466 |
+
),
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
# ---------------------------------------------------------------------------
|
| 470 |
+
# Environment hints — execution-environment awareness for the agent.
|
| 471 |
+
# Unlike PLATFORM_HINTS (which describe the messaging channel), these describe
|
| 472 |
+
# the machine/OS the agent's tools actually run on.
|
| 473 |
+
# ---------------------------------------------------------------------------
|
| 474 |
+
|
| 475 |
+
WSL_ENVIRONMENT_HINT = (
|
| 476 |
+
"You are running inside WSL (Windows Subsystem for Linux). "
|
| 477 |
+
"The Windows host filesystem is mounted under /mnt/ — "
|
| 478 |
+
"/mnt/c/ is the C: drive, /mnt/d/ is D:, etc. "
|
| 479 |
+
"The user's Windows files are typically at "
|
| 480 |
+
"/mnt/c/Users/<username>/Desktop/, Documents/, Downloads/, etc. "
|
| 481 |
+
"When the user references Windows paths or desktop files, translate "
|
| 482 |
+
"to the /mnt/c/ equivalent. You can list /mnt/c/Users/ to discover "
|
| 483 |
+
"the Windows username if needed."
|
| 484 |
+
)
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
def build_environment_hints() -> str:
|
| 488 |
+
"""Return environment-specific guidance for the system prompt.
|
| 489 |
+
|
| 490 |
+
Detects WSL, and can be extended for Termux, Docker, etc.
|
| 491 |
+
Returns an empty string when no special environment is detected.
|
| 492 |
+
"""
|
| 493 |
+
hints: list[str] = []
|
| 494 |
+
if is_wsl():
|
| 495 |
+
hints.append(WSL_ENVIRONMENT_HINT)
|
| 496 |
+
return "\n\n".join(hints)
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
CONTEXT_FILE_MAX_CHARS = 20_000
|
| 500 |
+
CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
|
| 501 |
+
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
# =========================================================================
|
| 505 |
+
# Skills prompt cache
|
| 506 |
+
# =========================================================================
|
| 507 |
+
|
| 508 |
+
_SKILLS_PROMPT_CACHE_MAX = 8
|
| 509 |
+
_SKILLS_PROMPT_CACHE: OrderedDict[tuple, str] = OrderedDict()
|
| 510 |
+
_SKILLS_PROMPT_CACHE_LOCK = threading.Lock()
|
| 511 |
+
_SKILLS_SNAPSHOT_VERSION = 1
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
def _skills_prompt_snapshot_path() -> Path:
|
| 515 |
+
return get_hermes_home() / ".skills_prompt_snapshot.json"
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
def clear_skills_system_prompt_cache(*, clear_snapshot: bool = False) -> None:
|
| 519 |
+
"""Drop the in-process skills prompt cache (and optionally the disk snapshot)."""
|
| 520 |
+
with _SKILLS_PROMPT_CACHE_LOCK:
|
| 521 |
+
_SKILLS_PROMPT_CACHE.clear()
|
| 522 |
+
if clear_snapshot:
|
| 523 |
+
try:
|
| 524 |
+
_skills_prompt_snapshot_path().unlink(missing_ok=True)
|
| 525 |
+
except OSError as e:
|
| 526 |
+
logger.debug("Could not remove skills prompt snapshot: %s", e)
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
def _build_skills_manifest(skills_dir: Path) -> dict[str, list[int]]:
|
| 530 |
+
"""Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files."""
|
| 531 |
+
manifest: dict[str, list[int]] = {}
|
| 532 |
+
for filename in ("SKILL.md", "DESCRIPTION.md"):
|
| 533 |
+
for path in iter_skill_index_files(skills_dir, filename):
|
| 534 |
+
try:
|
| 535 |
+
st = path.stat()
|
| 536 |
+
except OSError:
|
| 537 |
+
continue
|
| 538 |
+
manifest[str(path.relative_to(skills_dir))] = [st.st_mtime_ns, st.st_size]
|
| 539 |
+
return manifest
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
def _load_skills_snapshot(skills_dir: Path) -> Optional[dict]:
|
| 543 |
+
"""Load the disk snapshot if it exists and its manifest still matches."""
|
| 544 |
+
snapshot_path = _skills_prompt_snapshot_path()
|
| 545 |
+
if not snapshot_path.exists():
|
| 546 |
+
return None
|
| 547 |
+
try:
|
| 548 |
+
snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
|
| 549 |
+
except Exception:
|
| 550 |
+
return None
|
| 551 |
+
if not isinstance(snapshot, dict):
|
| 552 |
+
return None
|
| 553 |
+
if snapshot.get("version") != _SKILLS_SNAPSHOT_VERSION:
|
| 554 |
+
return None
|
| 555 |
+
if snapshot.get("manifest") != _build_skills_manifest(skills_dir):
|
| 556 |
+
return None
|
| 557 |
+
return snapshot
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
def _write_skills_snapshot(
|
| 561 |
+
skills_dir: Path,
|
| 562 |
+
manifest: dict[str, list[int]],
|
| 563 |
+
skill_entries: list[dict],
|
| 564 |
+
category_descriptions: dict[str, str],
|
| 565 |
+
) -> None:
|
| 566 |
+
"""Persist skill metadata to disk for fast cold-start reuse."""
|
| 567 |
+
payload = {
|
| 568 |
+
"version": _SKILLS_SNAPSHOT_VERSION,
|
| 569 |
+
"manifest": manifest,
|
| 570 |
+
"skills": skill_entries,
|
| 571 |
+
"category_descriptions": category_descriptions,
|
| 572 |
+
}
|
| 573 |
+
try:
|
| 574 |
+
atomic_json_write(_skills_prompt_snapshot_path(), payload)
|
| 575 |
+
except Exception as e:
|
| 576 |
+
logger.debug("Could not write skills prompt snapshot: %s", e)
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
def _build_snapshot_entry(
|
| 580 |
+
skill_file: Path,
|
| 581 |
+
skills_dir: Path,
|
| 582 |
+
frontmatter: dict,
|
| 583 |
+
description: str,
|
| 584 |
+
) -> dict:
|
| 585 |
+
"""Build a serialisable metadata dict for one skill."""
|
| 586 |
+
rel_path = skill_file.relative_to(skills_dir)
|
| 587 |
+
parts = rel_path.parts
|
| 588 |
+
if len(parts) >= 2:
|
| 589 |
+
skill_name = parts[-2]
|
| 590 |
+
category = "/".join(parts[:-2]) if len(parts) > 2 else parts[0]
|
| 591 |
+
else:
|
| 592 |
+
category = "general"
|
| 593 |
+
skill_name = skill_file.parent.name
|
| 594 |
+
|
| 595 |
+
platforms = frontmatter.get("platforms") or []
|
| 596 |
+
if isinstance(platforms, str):
|
| 597 |
+
platforms = [platforms]
|
| 598 |
+
|
| 599 |
+
return {
|
| 600 |
+
"skill_name": skill_name,
|
| 601 |
+
"category": category,
|
| 602 |
+
"frontmatter_name": str(frontmatter.get("name", skill_name)),
|
| 603 |
+
"description": description,
|
| 604 |
+
"platforms": [str(p).strip() for p in platforms if str(p).strip()],
|
| 605 |
+
"conditions": extract_skill_conditions(frontmatter),
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
# =========================================================================
|
| 610 |
+
# Skills index
|
| 611 |
+
# =========================================================================
|
| 612 |
+
|
| 613 |
+
def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:
|
| 614 |
+
"""Read a SKILL.md once and return platform compatibility, frontmatter, and description.
|
| 615 |
+
|
| 616 |
+
Returns (is_compatible, frontmatter, description). On any error, returns
|
| 617 |
+
(True, {}, "") to err on the side of showing the skill.
|
| 618 |
+
"""
|
| 619 |
+
try:
|
| 620 |
+
raw = skill_file.read_text(encoding="utf-8")
|
| 621 |
+
frontmatter, _ = parse_frontmatter(raw)
|
| 622 |
+
|
| 623 |
+
if not skill_matches_platform(frontmatter):
|
| 624 |
+
return False, frontmatter, ""
|
| 625 |
+
|
| 626 |
+
return True, frontmatter, extract_skill_description(frontmatter)
|
| 627 |
+
except Exception as e:
|
| 628 |
+
logger.warning("Failed to parse skill file %s: %s", skill_file, e)
|
| 629 |
+
return True, {}, ""
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
def _skill_should_show(
|
| 633 |
+
conditions: dict,
|
| 634 |
+
available_tools: "set[str] | None",
|
| 635 |
+
available_toolsets: "set[str] | None",
|
| 636 |
+
) -> bool:
|
| 637 |
+
"""Return False if the skill's conditional activation rules exclude it."""
|
| 638 |
+
if available_tools is None and available_toolsets is None:
|
| 639 |
+
return True # No filtering info — show everything (backward compat)
|
| 640 |
+
|
| 641 |
+
at = available_tools or set()
|
| 642 |
+
ats = available_toolsets or set()
|
| 643 |
+
|
| 644 |
+
# fallback_for: hide when the primary tool/toolset IS available
|
| 645 |
+
for ts in conditions.get("fallback_for_toolsets", []):
|
| 646 |
+
if ts in ats:
|
| 647 |
+
return False
|
| 648 |
+
for t in conditions.get("fallback_for_tools", []):
|
| 649 |
+
if t in at:
|
| 650 |
+
return False
|
| 651 |
+
|
| 652 |
+
# requires: hide when a required tool/toolset is NOT available
|
| 653 |
+
for ts in conditions.get("requires_toolsets", []):
|
| 654 |
+
if ts not in ats:
|
| 655 |
+
return False
|
| 656 |
+
for t in conditions.get("requires_tools", []):
|
| 657 |
+
if t not in at:
|
| 658 |
+
return False
|
| 659 |
+
|
| 660 |
+
return True
|
| 661 |
+
|
| 662 |
+
|
| 663 |
+
def build_skills_system_prompt(
|
| 664 |
+
available_tools: "set[str] | None" = None,
|
| 665 |
+
available_toolsets: "set[str] | None" = None,
|
| 666 |
+
) -> str:
|
| 667 |
+
"""Build a compact skill index for the system prompt.
|
| 668 |
+
|
| 669 |
+
Two-layer cache:
|
| 670 |
+
1. In-process LRU dict keyed by (skills_dir, tools, toolsets)
|
| 671 |
+
2. Disk snapshot (``.skills_prompt_snapshot.json``) validated by
|
| 672 |
+
mtime/size manifest — survives process restarts
|
| 673 |
+
|
| 674 |
+
Falls back to a full filesystem scan when both layers miss.
|
| 675 |
+
|
| 676 |
+
External skill directories (``skills.external_dirs`` in config.yaml) are
|
| 677 |
+
scanned alongside the local ``~/.hermes/skills/`` directory. External dirs
|
| 678 |
+
are read-only — they appear in the index but new skills are always created
|
| 679 |
+
in the local dir. Local skills take precedence when names collide.
|
| 680 |
+
"""
|
| 681 |
+
skills_dir = get_skills_dir()
|
| 682 |
+
external_dirs = get_all_skills_dirs()[1:] # skip local (index 0)
|
| 683 |
+
|
| 684 |
+
if not skills_dir.exists() and not external_dirs:
|
| 685 |
+
return ""
|
| 686 |
+
|
| 687 |
+
# ── Layer 1: in-process LRU cache ─────────────────────────────────
|
| 688 |
+
# Include the resolved platform so per-platform disabled-skill lists
|
| 689 |
+
# produce distinct cache entries (gateway serves multiple platforms).
|
| 690 |
+
from gateway.session_context import get_session_env
|
| 691 |
+
_platform_hint = (
|
| 692 |
+
os.environ.get("HERMES_PLATFORM")
|
| 693 |
+
or get_session_env("HERMES_SESSION_PLATFORM")
|
| 694 |
+
or ""
|
| 695 |
+
)
|
| 696 |
+
disabled = get_disabled_skill_names()
|
| 697 |
+
cache_key = (
|
| 698 |
+
str(skills_dir.resolve()),
|
| 699 |
+
tuple(str(d) for d in external_dirs),
|
| 700 |
+
tuple(sorted(str(t) for t in (available_tools or set()))),
|
| 701 |
+
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
|
| 702 |
+
_platform_hint,
|
| 703 |
+
tuple(sorted(disabled)),
|
| 704 |
+
)
|
| 705 |
+
with _SKILLS_PROMPT_CACHE_LOCK:
|
| 706 |
+
cached = _SKILLS_PROMPT_CACHE.get(cache_key)
|
| 707 |
+
if cached is not None:
|
| 708 |
+
_SKILLS_PROMPT_CACHE.move_to_end(cache_key)
|
| 709 |
+
return cached
|
| 710 |
+
|
| 711 |
+
# ── Layer 2: disk snapshot ────────────────────────────────────────
|
| 712 |
+
snapshot = _load_skills_snapshot(skills_dir)
|
| 713 |
+
|
| 714 |
+
skills_by_category: dict[str, list[tuple[str, str]]] = {}
|
| 715 |
+
category_descriptions: dict[str, str] = {}
|
| 716 |
+
|
| 717 |
+
if snapshot is not None:
|
| 718 |
+
# Fast path: use pre-parsed metadata from disk
|
| 719 |
+
for entry in snapshot.get("skills", []):
|
| 720 |
+
if not isinstance(entry, dict):
|
| 721 |
+
continue
|
| 722 |
+
skill_name = entry.get("skill_name") or ""
|
| 723 |
+
category = entry.get("category") or "general"
|
| 724 |
+
frontmatter_name = entry.get("frontmatter_name") or skill_name
|
| 725 |
+
platforms = entry.get("platforms") or []
|
| 726 |
+
if not skill_matches_platform({"platforms": platforms}):
|
| 727 |
+
continue
|
| 728 |
+
if frontmatter_name in disabled or skill_name in disabled:
|
| 729 |
+
continue
|
| 730 |
+
if not _skill_should_show(
|
| 731 |
+
entry.get("conditions") or {},
|
| 732 |
+
available_tools,
|
| 733 |
+
available_toolsets,
|
| 734 |
+
):
|
| 735 |
+
continue
|
| 736 |
+
skills_by_category.setdefault(category, []).append(
|
| 737 |
+
(frontmatter_name, entry.get("description", ""))
|
| 738 |
+
)
|
| 739 |
+
category_descriptions = {
|
| 740 |
+
str(k): str(v)
|
| 741 |
+
for k, v in (snapshot.get("category_descriptions") or {}).items()
|
| 742 |
+
}
|
| 743 |
+
else:
|
| 744 |
+
# Cold path: full filesystem scan + write snapshot for next time
|
| 745 |
+
skill_entries: list[dict] = []
|
| 746 |
+
for skill_file in iter_skill_index_files(skills_dir, "SKILL.md"):
|
| 747 |
+
is_compatible, frontmatter, desc = _parse_skill_file(skill_file)
|
| 748 |
+
entry = _build_snapshot_entry(skill_file, skills_dir, frontmatter, desc)
|
| 749 |
+
skill_entries.append(entry)
|
| 750 |
+
if not is_compatible:
|
| 751 |
+
continue
|
| 752 |
+
skill_name = entry["skill_name"]
|
| 753 |
+
if entry["frontmatter_name"] in disabled or skill_name in disabled:
|
| 754 |
+
continue
|
| 755 |
+
if not _skill_should_show(
|
| 756 |
+
extract_skill_conditions(frontmatter),
|
| 757 |
+
available_tools,
|
| 758 |
+
available_toolsets,
|
| 759 |
+
):
|
| 760 |
+
continue
|
| 761 |
+
skills_by_category.setdefault(entry["category"], []).append(
|
| 762 |
+
(entry["frontmatter_name"], entry["description"])
|
| 763 |
+
)
|
| 764 |
+
|
| 765 |
+
# Read category-level DESCRIPTION.md files
|
| 766 |
+
for desc_file in iter_skill_index_files(skills_dir, "DESCRIPTION.md"):
|
| 767 |
+
try:
|
| 768 |
+
content = desc_file.read_text(encoding="utf-8")
|
| 769 |
+
fm, _ = parse_frontmatter(content)
|
| 770 |
+
cat_desc = fm.get("description")
|
| 771 |
+
if not cat_desc:
|
| 772 |
+
continue
|
| 773 |
+
rel = desc_file.relative_to(skills_dir)
|
| 774 |
+
cat = "/".join(rel.parts[:-1]) if len(rel.parts) > 1 else "general"
|
| 775 |
+
category_descriptions[cat] = str(cat_desc).strip().strip("'\"")
|
| 776 |
+
except Exception as e:
|
| 777 |
+
logger.debug("Could not read skill description %s: %s", desc_file, e)
|
| 778 |
+
|
| 779 |
+
_write_skills_snapshot(
|
| 780 |
+
skills_dir,
|
| 781 |
+
_build_skills_manifest(skills_dir),
|
| 782 |
+
skill_entries,
|
| 783 |
+
category_descriptions,
|
| 784 |
+
)
|
| 785 |
+
|
| 786 |
+
# ── External skill directories ─────────────────────────────────────
|
| 787 |
+
# Scan external dirs directly (no snapshot caching — they're read-only
|
| 788 |
+
# and typically small). Local skills already in skills_by_category take
|
| 789 |
+
# precedence: we track seen names and skip duplicates from external dirs.
|
| 790 |
+
seen_skill_names: set[str] = set()
|
| 791 |
+
for cat_skills in skills_by_category.values():
|
| 792 |
+
for name, _desc in cat_skills:
|
| 793 |
+
seen_skill_names.add(name)
|
| 794 |
+
|
| 795 |
+
for ext_dir in external_dirs:
|
| 796 |
+
if not ext_dir.exists():
|
| 797 |
+
continue
|
| 798 |
+
for skill_file in iter_skill_index_files(ext_dir, "SKILL.md"):
|
| 799 |
+
try:
|
| 800 |
+
is_compatible, frontmatter, desc = _parse_skill_file(skill_file)
|
| 801 |
+
if not is_compatible:
|
| 802 |
+
continue
|
| 803 |
+
entry = _build_snapshot_entry(skill_file, ext_dir, frontmatter, desc)
|
| 804 |
+
skill_name = entry["skill_name"]
|
| 805 |
+
frontmatter_name = entry["frontmatter_name"]
|
| 806 |
+
if frontmatter_name in seen_skill_names:
|
| 807 |
+
continue
|
| 808 |
+
if frontmatter_name in disabled or skill_name in disabled:
|
| 809 |
+
continue
|
| 810 |
+
if not _skill_should_show(
|
| 811 |
+
extract_skill_conditions(frontmatter),
|
| 812 |
+
available_tools,
|
| 813 |
+
available_toolsets,
|
| 814 |
+
):
|
| 815 |
+
continue
|
| 816 |
+
seen_skill_names.add(frontmatter_name)
|
| 817 |
+
skills_by_category.setdefault(entry["category"], []).append(
|
| 818 |
+
(frontmatter_name, entry["description"])
|
| 819 |
+
)
|
| 820 |
+
except Exception as e:
|
| 821 |
+
logger.debug("Error reading external skill %s: %s", skill_file, e)
|
| 822 |
+
|
| 823 |
+
# External category descriptions
|
| 824 |
+
for desc_file in iter_skill_index_files(ext_dir, "DESCRIPTION.md"):
|
| 825 |
+
try:
|
| 826 |
+
content = desc_file.read_text(encoding="utf-8")
|
| 827 |
+
fm, _ = parse_frontmatter(content)
|
| 828 |
+
cat_desc = fm.get("description")
|
| 829 |
+
if not cat_desc:
|
| 830 |
+
continue
|
| 831 |
+
rel = desc_file.relative_to(ext_dir)
|
| 832 |
+
cat = "/".join(rel.parts[:-1]) if len(rel.parts) > 1 else "general"
|
| 833 |
+
category_descriptions.setdefault(cat, str(cat_desc).strip().strip("'\""))
|
| 834 |
+
except Exception as e:
|
| 835 |
+
logger.debug("Could not read external skill description %s: %s", desc_file, e)
|
| 836 |
+
|
| 837 |
+
if not skills_by_category:
|
| 838 |
+
result = ""
|
| 839 |
+
else:
|
| 840 |
+
index_lines = []
|
| 841 |
+
for category in sorted(skills_by_category.keys()):
|
| 842 |
+
cat_desc = category_descriptions.get(category, "")
|
| 843 |
+
if cat_desc:
|
| 844 |
+
index_lines.append(f" {category}: {cat_desc}")
|
| 845 |
+
else:
|
| 846 |
+
index_lines.append(f" {category}:")
|
| 847 |
+
# Deduplicate and sort skills within each category
|
| 848 |
+
seen = set()
|
| 849 |
+
for name, desc in sorted(skills_by_category[category], key=lambda x: x[0]):
|
| 850 |
+
if name in seen:
|
| 851 |
+
continue
|
| 852 |
+
seen.add(name)
|
| 853 |
+
if desc:
|
| 854 |
+
index_lines.append(f" - {name}: {desc}")
|
| 855 |
+
else:
|
| 856 |
+
index_lines.append(f" - {name}")
|
| 857 |
+
|
| 858 |
+
result = (
|
| 859 |
+
"## Skills (mandatory)\n"
|
| 860 |
+
"Before replying, scan the skills below. If a skill matches or is even partially relevant "
|
| 861 |
+
"to your task, you MUST load it with skill_view(name) and follow its instructions. "
|
| 862 |
+
"Err on the side of loading — it is always better to have context you don't need "
|
| 863 |
+
"than to miss critical steps, pitfalls, or established workflows. "
|
| 864 |
+
"Skills contain specialized knowledge — API endpoints, tool-specific commands, "
|
| 865 |
+
"and proven workflows that outperform general-purpose approaches. Load the skill "
|
| 866 |
+
"even if you think you could handle the task with basic tools like web_search or terminal. "
|
| 867 |
+
"Skills also encode the user's preferred approach, conventions, and quality standards "
|
| 868 |
+
"for tasks like code review, planning, and testing — load them even for tasks you "
|
| 869 |
+
"already know how to do, because the skill defines how it should be done here.\n"
|
| 870 |
+
"Whenever the user asks you to configure, set up, install, enable, disable, modify, "
|
| 871 |
+
"or troubleshoot Hermes Agent itself — its CLI, config, models, providers, tools, "
|
| 872 |
+
"skills, voice, gateway, plugins, or any feature — load the `hermes-agent` skill "
|
| 873 |
+
"first. It has the actual commands (e.g. `hermes config set …`, `hermes tools`, "
|
| 874 |
+
"`hermes setup`) so you don't have to guess or invent workarounds.\n"
|
| 875 |
+
"If a skill has issues, fix it with skill_manage(action='patch').\n"
|
| 876 |
+
"After difficult/iterative tasks, offer to save as a skill. "
|
| 877 |
+
"If a skill you loaded was missing steps, had wrong commands, or needed "
|
| 878 |
+
"pitfalls you discovered, update it before finishing.\n"
|
| 879 |
+
"\n"
|
| 880 |
+
"<available_skills>\n"
|
| 881 |
+
+ "\n".join(index_lines) + "\n"
|
| 882 |
+
"</available_skills>\n"
|
| 883 |
+
"\n"
|
| 884 |
+
"Only proceed without loading a skill if genuinely none are relevant to the task."
|
| 885 |
+
)
|
| 886 |
+
|
| 887 |
+
# ── Store in LRU cache ────────────────────────────────────────────
|
| 888 |
+
with _SKILLS_PROMPT_CACHE_LOCK:
|
| 889 |
+
_SKILLS_PROMPT_CACHE[cache_key] = result
|
| 890 |
+
_SKILLS_PROMPT_CACHE.move_to_end(cache_key)
|
| 891 |
+
while len(_SKILLS_PROMPT_CACHE) > _SKILLS_PROMPT_CACHE_MAX:
|
| 892 |
+
_SKILLS_PROMPT_CACHE.popitem(last=False)
|
| 893 |
+
|
| 894 |
+
return result
|
| 895 |
+
|
| 896 |
+
|
| 897 |
+
def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -> str:
|
| 898 |
+
"""Build a compact Nous subscription capability block for the system prompt."""
|
| 899 |
+
try:
|
| 900 |
+
from hermes_cli.nous_subscription import get_nous_subscription_features
|
| 901 |
+
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
| 902 |
+
except Exception as exc:
|
| 903 |
+
logger.debug("Failed to import Nous subscription helper: %s", exc)
|
| 904 |
+
return ""
|
| 905 |
+
|
| 906 |
+
if not managed_nous_tools_enabled():
|
| 907 |
+
return ""
|
| 908 |
+
|
| 909 |
+
valid_names = set(valid_tool_names or set())
|
| 910 |
+
relevant_tool_names = {
|
| 911 |
+
"web_search",
|
| 912 |
+
"web_extract",
|
| 913 |
+
"browser_navigate",
|
| 914 |
+
"browser_snapshot",
|
| 915 |
+
"browser_click",
|
| 916 |
+
"browser_type",
|
| 917 |
+
"browser_scroll",
|
| 918 |
+
"browser_console",
|
| 919 |
+
"browser_press",
|
| 920 |
+
"browser_get_images",
|
| 921 |
+
"browser_vision",
|
| 922 |
+
"image_generate",
|
| 923 |
+
"text_to_speech",
|
| 924 |
+
"terminal",
|
| 925 |
+
"process",
|
| 926 |
+
"execute_code",
|
| 927 |
+
}
|
| 928 |
+
|
| 929 |
+
if valid_names and not (valid_names & relevant_tool_names):
|
| 930 |
+
return ""
|
| 931 |
+
|
| 932 |
+
features = get_nous_subscription_features()
|
| 933 |
+
|
| 934 |
+
def _status_line(feature) -> str:
|
| 935 |
+
if feature.managed_by_nous:
|
| 936 |
+
return f"- {feature.label}: active via Nous subscription"
|
| 937 |
+
if feature.active:
|
| 938 |
+
current = feature.current_provider or "configured provider"
|
| 939 |
+
return f"- {feature.label}: currently using {current}"
|
| 940 |
+
if feature.included_by_default and features.nous_auth_present:
|
| 941 |
+
return f"- {feature.label}: included with Nous subscription, not currently selected"
|
| 942 |
+
if feature.key == "modal" and features.nous_auth_present:
|
| 943 |
+
return f"- {feature.label}: optional via Nous subscription"
|
| 944 |
+
return f"- {feature.label}: not currently available"
|
| 945 |
+
|
| 946 |
+
lines = [
|
| 947 |
+
"# Nous Subscription",
|
| 948 |
+
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browser Use) by default. Modal execution is optional.",
|
| 949 |
+
"Current capability status:",
|
| 950 |
+
]
|
| 951 |
+
lines.extend(_status_line(feature) for feature in features.items())
|
| 952 |
+
lines.extend(
|
| 953 |
+
[
|
| 954 |
+
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browser-Use API keys.",
|
| 955 |
+
"If the user is not subscribed and asks for a capability that Nous subscription would unlock or simplify, suggest Nous subscription as one option alongside direct setup or local alternatives.",
|
| 956 |
+
"Do not mention subscription unless the user asks about it or it directly solves the current missing capability.",
|
| 957 |
+
"Useful commands: hermes setup, hermes setup tools, hermes setup terminal, hermes status.",
|
| 958 |
+
]
|
| 959 |
+
)
|
| 960 |
+
return "\n".join(lines)
|
| 961 |
+
|
| 962 |
+
|
| 963 |
+
# =========================================================================
|
| 964 |
+
# Context files (SOUL.md, AGENTS.md, .cursorrules)
|
| 965 |
+
# =========================================================================
|
| 966 |
+
|
| 967 |
+
def _truncate_content(content: str, filename: str, max_chars: int = CONTEXT_FILE_MAX_CHARS) -> str:
|
| 968 |
+
"""Head/tail truncation with a marker in the middle."""
|
| 969 |
+
if len(content) <= max_chars:
|
| 970 |
+
return content
|
| 971 |
+
head_chars = int(max_chars * CONTEXT_TRUNCATE_HEAD_RATIO)
|
| 972 |
+
tail_chars = int(max_chars * CONTEXT_TRUNCATE_TAIL_RATIO)
|
| 973 |
+
head = content[:head_chars]
|
| 974 |
+
tail = content[-tail_chars:]
|
| 975 |
+
marker = f"\n\n[...truncated {filename}: kept {head_chars}+{tail_chars} of {len(content)} chars. Use file tools to read the full file.]\n\n"
|
| 976 |
+
return head + marker + tail
|
| 977 |
+
|
| 978 |
+
|
| 979 |
+
def load_soul_md() -> Optional[str]:
|
| 980 |
+
"""Load SOUL.md from HERMES_HOME and return its content, or None.
|
| 981 |
+
|
| 982 |
+
Used as the agent identity (slot #1 in the system prompt). When this
|
| 983 |
+
returns content, ``build_context_files_prompt`` should be called with
|
| 984 |
+
``skip_soul=True`` so SOUL.md isn't injected twice.
|
| 985 |
+
"""
|
| 986 |
+
try:
|
| 987 |
+
from hermes_cli.config import ensure_hermes_home
|
| 988 |
+
ensure_hermes_home()
|
| 989 |
+
except Exception as e:
|
| 990 |
+
logger.debug("Could not ensure HERMES_HOME before loading SOUL.md: %s", e)
|
| 991 |
+
|
| 992 |
+
soul_path = get_hermes_home() / "SOUL.md"
|
| 993 |
+
if not soul_path.exists():
|
| 994 |
+
return None
|
| 995 |
+
try:
|
| 996 |
+
content = soul_path.read_text(encoding="utf-8").strip()
|
| 997 |
+
if not content:
|
| 998 |
+
return None
|
| 999 |
+
content = _scan_context_content(content, "SOUL.md")
|
| 1000 |
+
content = _truncate_content(content, "SOUL.md")
|
| 1001 |
+
return content
|
| 1002 |
+
except Exception as e:
|
| 1003 |
+
logger.debug("Could not read SOUL.md from %s: %s", soul_path, e)
|
| 1004 |
+
return None
|
| 1005 |
+
|
| 1006 |
+
|
| 1007 |
+
def _load_hermes_md(cwd_path: Path) -> str:
|
| 1008 |
+
""".hermes.md / HERMES.md — walk to git root."""
|
| 1009 |
+
hermes_md_path = _find_hermes_md(cwd_path)
|
| 1010 |
+
if not hermes_md_path:
|
| 1011 |
+
return ""
|
| 1012 |
+
try:
|
| 1013 |
+
content = hermes_md_path.read_text(encoding="utf-8").strip()
|
| 1014 |
+
if not content:
|
| 1015 |
+
return ""
|
| 1016 |
+
content = _strip_yaml_frontmatter(content)
|
| 1017 |
+
rel = hermes_md_path.name
|
| 1018 |
+
try:
|
| 1019 |
+
rel = str(hermes_md_path.relative_to(cwd_path))
|
| 1020 |
+
except ValueError:
|
| 1021 |
+
pass
|
| 1022 |
+
content = _scan_context_content(content, rel)
|
| 1023 |
+
result = f"## {rel}\n\n{content}"
|
| 1024 |
+
return _truncate_content(result, ".hermes.md")
|
| 1025 |
+
except Exception as e:
|
| 1026 |
+
logger.debug("Could not read %s: %s", hermes_md_path, e)
|
| 1027 |
+
return ""
|
| 1028 |
+
|
| 1029 |
+
|
| 1030 |
+
def _load_agents_md(cwd_path: Path) -> str:
|
| 1031 |
+
"""AGENTS.md — top-level only (no recursive walk)."""
|
| 1032 |
+
for name in ["AGENTS.md", "agents.md"]:
|
| 1033 |
+
candidate = cwd_path / name
|
| 1034 |
+
if candidate.exists():
|
| 1035 |
+
try:
|
| 1036 |
+
content = candidate.read_text(encoding="utf-8").strip()
|
| 1037 |
+
if content:
|
| 1038 |
+
content = _scan_context_content(content, name)
|
| 1039 |
+
result = f"## {name}\n\n{content}"
|
| 1040 |
+
return _truncate_content(result, "AGENTS.md")
|
| 1041 |
+
except Exception as e:
|
| 1042 |
+
logger.debug("Could not read %s: %s", candidate, e)
|
| 1043 |
+
return ""
|
| 1044 |
+
|
| 1045 |
+
|
| 1046 |
+
def _load_claude_md(cwd_path: Path) -> str:
|
| 1047 |
+
"""CLAUDE.md / claude.md — cwd only."""
|
| 1048 |
+
for name in ["CLAUDE.md", "claude.md"]:
|
| 1049 |
+
candidate = cwd_path / name
|
| 1050 |
+
if candidate.exists():
|
| 1051 |
+
try:
|
| 1052 |
+
content = candidate.read_text(encoding="utf-8").strip()
|
| 1053 |
+
if content:
|
| 1054 |
+
content = _scan_context_content(content, name)
|
| 1055 |
+
result = f"## {name}\n\n{content}"
|
| 1056 |
+
return _truncate_content(result, "CLAUDE.md")
|
| 1057 |
+
except Exception as e:
|
| 1058 |
+
logger.debug("Could not read %s: %s", candidate, e)
|
| 1059 |
+
return ""
|
| 1060 |
+
|
| 1061 |
+
|
| 1062 |
+
def _load_cursorrules(cwd_path: Path) -> str:
|
| 1063 |
+
""".cursorrules + .cursor/rules/*.mdc — cwd only."""
|
| 1064 |
+
cursorrules_content = ""
|
| 1065 |
+
cursorrules_file = cwd_path / ".cursorrules"
|
| 1066 |
+
if cursorrules_file.exists():
|
| 1067 |
+
try:
|
| 1068 |
+
content = cursorrules_file.read_text(encoding="utf-8").strip()
|
| 1069 |
+
if content:
|
| 1070 |
+
content = _scan_context_content(content, ".cursorrules")
|
| 1071 |
+
cursorrules_content += f"## .cursorrules\n\n{content}\n\n"
|
| 1072 |
+
except Exception as e:
|
| 1073 |
+
logger.debug("Could not read .cursorrules: %s", e)
|
| 1074 |
+
|
| 1075 |
+
cursor_rules_dir = cwd_path / ".cursor" / "rules"
|
| 1076 |
+
if cursor_rules_dir.exists() and cursor_rules_dir.is_dir():
|
| 1077 |
+
mdc_files = sorted(cursor_rules_dir.glob("*.mdc"))
|
| 1078 |
+
for mdc_file in mdc_files:
|
| 1079 |
+
try:
|
| 1080 |
+
content = mdc_file.read_text(encoding="utf-8").strip()
|
| 1081 |
+
if content:
|
| 1082 |
+
content = _scan_context_content(content, f".cursor/rules/{mdc_file.name}")
|
| 1083 |
+
cursorrules_content += f"## .cursor/rules/{mdc_file.name}\n\n{content}\n\n"
|
| 1084 |
+
except Exception as e:
|
| 1085 |
+
logger.debug("Could not read %s: %s", mdc_file, e)
|
| 1086 |
+
|
| 1087 |
+
if not cursorrules_content:
|
| 1088 |
+
return ""
|
| 1089 |
+
return _truncate_content(cursorrules_content, ".cursorrules")
|
| 1090 |
+
|
| 1091 |
+
|
| 1092 |
+
def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = False) -> str:
|
| 1093 |
+
"""Discover and load context files for the system prompt.
|
| 1094 |
+
|
| 1095 |
+
Priority (first found wins — only ONE project context type is loaded):
|
| 1096 |
+
1. .hermes.md / HERMES.md (walk to git root)
|
| 1097 |
+
2. AGENTS.md / agents.md (cwd only)
|
| 1098 |
+
3. CLAUDE.md / claude.md (cwd only)
|
| 1099 |
+
4. .cursorrules / .cursor/rules/*.mdc (cwd only)
|
| 1100 |
+
|
| 1101 |
+
SOUL.md from HERMES_HOME is independent and always included when present.
|
| 1102 |
+
Each context source is capped at 20,000 chars.
|
| 1103 |
+
|
| 1104 |
+
When *skip_soul* is True, SOUL.md is not included here (it was already
|
| 1105 |
+
loaded via ``load_soul_md()`` for the identity slot).
|
| 1106 |
+
"""
|
| 1107 |
+
if cwd is None:
|
| 1108 |
+
cwd = os.getcwd()
|
| 1109 |
+
|
| 1110 |
+
cwd_path = Path(cwd).resolve()
|
| 1111 |
+
sections = []
|
| 1112 |
+
|
| 1113 |
+
# Priority-based project context: first match wins
|
| 1114 |
+
project_context = (
|
| 1115 |
+
_load_hermes_md(cwd_path)
|
| 1116 |
+
or _load_agents_md(cwd_path)
|
| 1117 |
+
or _load_claude_md(cwd_path)
|
| 1118 |
+
or _load_cursorrules(cwd_path)
|
| 1119 |
+
)
|
| 1120 |
+
if project_context:
|
| 1121 |
+
sections.append(project_context)
|
| 1122 |
+
|
| 1123 |
+
# SOUL.md from HERMES_HOME only — skip when already loaded as identity
|
| 1124 |
+
if not skip_soul:
|
| 1125 |
+
soul_content = load_soul_md()
|
| 1126 |
+
if soul_content:
|
| 1127 |
+
sections.append(soul_content)
|
| 1128 |
+
|
| 1129 |
+
if not sections:
|
| 1130 |
+
return ""
|
| 1131 |
+
return "# Project Context\n\nThe following project context files have been loaded and should be followed:\n\n" + "\n".join(sections)
|
patches/hermes-agent/tools/send_message_tool.py
ADDED
|
@@ -0,0 +1,1586 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Send Message Tool -- cross-channel messaging via platform APIs.
|
| 2 |
+
|
| 3 |
+
Sends a message to a user or channel on any connected messaging platform
|
| 4 |
+
(Telegram, Discord, Slack). Supports listing available targets and resolving
|
| 5 |
+
human-friendly channel names to IDs. Works in both CLI and gateway contexts.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
import re
|
| 13 |
+
from typing import Dict, Optional
|
| 14 |
+
import ssl
|
| 15 |
+
import time
|
| 16 |
+
|
| 17 |
+
from agent.redact import redact_sensitive_text
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
_TELEGRAM_TOPIC_TARGET_RE = re.compile(r"^\s*(-?\d+)(?::(\d+))?\s*$")
|
| 22 |
+
_FEISHU_TARGET_RE = re.compile(r"^\s*((?:oc|ou|on|chat|open)_[-A-Za-z0-9]+)(?::([-A-Za-z0-9_]+))?\s*$")
|
| 23 |
+
# Slack conversation IDs: C (public channel), G (private/group channel), D (DM).
|
| 24 |
+
# Must be uppercase alphanumeric, 9+ chars. User IDs (U...) and workspace IDs
|
| 25 |
+
# (W...) are NOT valid chat.postMessage channel values — posting to them fails
|
| 26 |
+
# because the API requires a conversation ID. To DM a user you must first call
|
| 27 |
+
# conversations.open to obtain a D... ID. Without this gate, Slack IDs fall
|
| 28 |
+
# through to channel-name resolution, which only matches by name and fails.
|
| 29 |
+
_SLACK_TARGET_RE = re.compile(r"^\s*([CGD][A-Z0-9]{8,})\s*$")
|
| 30 |
+
_WEIXIN_TARGET_RE = re.compile(r"^\s*((?:wxid|gh|v\d+|wm|wb)_[A-Za-z0-9_-]+|[A-Za-z0-9._-]+@chatroom|filehelper)\s*$")
|
| 31 |
+
_YUANBAO_TARGET_RE = re.compile(r"^\s*((?:group|direct):[^:]+)\s*$")
|
| 32 |
+
# Discord snowflake IDs are numeric, same regex pattern as Telegram topic targets.
|
| 33 |
+
_NUMERIC_TOPIC_RE = _TELEGRAM_TOPIC_TARGET_RE
|
| 34 |
+
# Platforms that address recipients by phone number and accept E.164 format
|
| 35 |
+
# (with a leading '+'). Without this, "+15551234567" fails the isdigit() check
|
| 36 |
+
# below and falls through to channel-name resolution, which has no way to
|
| 37 |
+
# resolve a raw phone number. Keeping the '+' preserves the E.164 form that
|
| 38 |
+
# downstream adapters (signal, etc.) expect.
|
| 39 |
+
_PHONE_PLATFORMS = frozenset({"signal", "sms", "whatsapp"})
|
| 40 |
+
_E164_TARGET_RE = re.compile(r"^\s*\+(\d{7,15})\s*$")
|
| 41 |
+
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
| 42 |
+
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"}
|
| 43 |
+
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a"}
|
| 44 |
+
_VOICE_EXTS = {".ogg", ".opus"}
|
| 45 |
+
_URL_SECRET_QUERY_RE = re.compile(
|
| 46 |
+
r"([?&](?:access_token|api[_-]?key|auth[_-]?token|token|signature|sig)=)([^&#\s]+)",
|
| 47 |
+
re.IGNORECASE,
|
| 48 |
+
)
|
| 49 |
+
_GENERIC_SECRET_ASSIGN_RE = re.compile(
|
| 50 |
+
r"\b(access_token|api[_-]?key|auth[_-]?token|signature|sig)\s*=\s*([^\s,;]+)",
|
| 51 |
+
re.IGNORECASE,
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _sanitize_error_text(text) -> str:
|
| 56 |
+
"""Redact secrets from error text before surfacing it to users/models."""
|
| 57 |
+
redacted = redact_sensitive_text(text)
|
| 58 |
+
redacted = _URL_SECRET_QUERY_RE.sub(lambda m: f"{m.group(1)}***", redacted)
|
| 59 |
+
redacted = _GENERIC_SECRET_ASSIGN_RE.sub(lambda m: f"{m.group(1)}=***", redacted)
|
| 60 |
+
return redacted
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _error(message: str) -> dict:
|
| 64 |
+
"""Build a standardized error payload with redacted content."""
|
| 65 |
+
return {"error": _sanitize_error_text(message)}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _telegram_retry_delay(exc: Exception, attempt: int) -> float | None:
|
| 69 |
+
retry_after = getattr(exc, "retry_after", None)
|
| 70 |
+
if retry_after is not None:
|
| 71 |
+
try:
|
| 72 |
+
return max(float(retry_after), 0.0)
|
| 73 |
+
except (TypeError, ValueError):
|
| 74 |
+
return 1.0
|
| 75 |
+
|
| 76 |
+
text = str(exc).lower()
|
| 77 |
+
if "timed out" in text or "timeout" in text:
|
| 78 |
+
return None
|
| 79 |
+
if (
|
| 80 |
+
"bad gateway" in text
|
| 81 |
+
or "502" in text
|
| 82 |
+
or "too many requests" in text
|
| 83 |
+
or "429" in text
|
| 84 |
+
or "service unavailable" in text
|
| 85 |
+
or "503" in text
|
| 86 |
+
or "gateway timeout" in text
|
| 87 |
+
or "504" in text
|
| 88 |
+
):
|
| 89 |
+
return float(2 ** attempt)
|
| 90 |
+
return None
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
async def _send_telegram_message_with_retry(bot, *, attempts: int = 3, **kwargs):
|
| 94 |
+
for attempt in range(attempts):
|
| 95 |
+
try:
|
| 96 |
+
return await bot.send_message(**kwargs)
|
| 97 |
+
except Exception as exc:
|
| 98 |
+
delay = _telegram_retry_delay(exc, attempt)
|
| 99 |
+
if delay is None or attempt >= attempts - 1:
|
| 100 |
+
raise
|
| 101 |
+
logger.warning(
|
| 102 |
+
"Transient Telegram send failure (attempt %d/%d), retrying in %.1fs: %s",
|
| 103 |
+
attempt + 1,
|
| 104 |
+
attempts,
|
| 105 |
+
delay,
|
| 106 |
+
_sanitize_error_text(exc),
|
| 107 |
+
)
|
| 108 |
+
await asyncio.sleep(delay)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
SEND_MESSAGE_SCHEMA = {
|
| 112 |
+
"name": "send_message",
|
| 113 |
+
"description": (
|
| 114 |
+
"Send a message to a connected messaging platform, or list available targets.\n\n"
|
| 115 |
+
"IMPORTANT: When the user asks to send to a specific channel or person "
|
| 116 |
+
"(not just a bare platform name), call send_message(action='list') FIRST to see "
|
| 117 |
+
"available targets, then send to the correct one.\n"
|
| 118 |
+
"If the user just says a platform name like 'send to telegram', send directly "
|
| 119 |
+
"to the home channel without listing first."
|
| 120 |
+
),
|
| 121 |
+
"parameters": {
|
| 122 |
+
"type": "object",
|
| 123 |
+
"properties": {
|
| 124 |
+
"action": {
|
| 125 |
+
"type": "string",
|
| 126 |
+
"enum": ["send", "list"],
|
| 127 |
+
"description": "Action to perform. 'send' (default) sends a message. 'list' returns all available channels/contacts across connected platforms."
|
| 128 |
+
},
|
| 129 |
+
"target": {
|
| 130 |
+
"type": "string",
|
| 131 |
+
"description": "Delivery target. Format: 'platform' (uses home channel), 'platform:#channel-name', 'platform:chat_id', or 'platform:chat_id:thread_id' for Telegram topics and Discord threads. Examples: 'telegram', 'telegram:-1001234567890:17585', 'discord:999888777:555444333', 'discord:#bot-home', 'slack:#engineering', 'signal:+155****4567', 'matrix:!roomid:server.org', 'matrix:@user:server.org', 'yuanbao:direct:<account_id>' (DM), 'yuanbao:group:<group_code>' (group chat)"
|
| 132 |
+
},
|
| 133 |
+
"message": {
|
| 134 |
+
"type": "string",
|
| 135 |
+
"description": "The message text to send. To send an image or file, include MEDIA:<local_path> (e.g. 'MEDIA:/tmp/hermes/cache/img_xxx.jpg') in the message — the platform will deliver it as a native media attachment."
|
| 136 |
+
}
|
| 137 |
+
},
|
| 138 |
+
"required": []
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def send_message_tool(args, **kw):
|
| 144 |
+
"""Handle cross-channel send_message tool calls."""
|
| 145 |
+
action = args.get("action", "send")
|
| 146 |
+
|
| 147 |
+
if action == "list":
|
| 148 |
+
return _handle_list()
|
| 149 |
+
|
| 150 |
+
return _handle_send(args)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _handle_list():
|
| 154 |
+
"""Return formatted list of available messaging targets."""
|
| 155 |
+
try:
|
| 156 |
+
from gateway.channel_directory import format_directory_for_display
|
| 157 |
+
return json.dumps({"targets": format_directory_for_display()})
|
| 158 |
+
except Exception as e:
|
| 159 |
+
return json.dumps(_error(f"Failed to load channel directory: {e}"))
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _handle_send(args):
|
| 163 |
+
"""Send a message to a platform target."""
|
| 164 |
+
target = args.get("target", "")
|
| 165 |
+
message = args.get("message", "")
|
| 166 |
+
if not target or not message:
|
| 167 |
+
return tool_error("Both 'target' and 'message' are required when action='send'")
|
| 168 |
+
|
| 169 |
+
parts = target.split(":", 1)
|
| 170 |
+
platform_name = parts[0].strip().lower()
|
| 171 |
+
target_ref = parts[1].strip() if len(parts) > 1 else None
|
| 172 |
+
chat_id = None
|
| 173 |
+
thread_id = None
|
| 174 |
+
|
| 175 |
+
if target_ref:
|
| 176 |
+
chat_id, thread_id, is_explicit = _parse_target_ref(platform_name, target_ref)
|
| 177 |
+
else:
|
| 178 |
+
is_explicit = False
|
| 179 |
+
|
| 180 |
+
# Resolve human-friendly channel names to numeric IDs
|
| 181 |
+
if target_ref and not is_explicit:
|
| 182 |
+
try:
|
| 183 |
+
from gateway.channel_directory import resolve_channel_name
|
| 184 |
+
resolved = resolve_channel_name(platform_name, target_ref)
|
| 185 |
+
if resolved:
|
| 186 |
+
chat_id, thread_id, _ = _parse_target_ref(platform_name, resolved)
|
| 187 |
+
else:
|
| 188 |
+
return json.dumps({
|
| 189 |
+
"error": f"Could not resolve '{target_ref}' on {platform_name}. "
|
| 190 |
+
f"Use send_message(action='list') to see available targets."
|
| 191 |
+
})
|
| 192 |
+
except Exception:
|
| 193 |
+
return json.dumps({
|
| 194 |
+
"error": f"Could not resolve '{target_ref}' on {platform_name}. "
|
| 195 |
+
f"Try using a numeric channel ID instead."
|
| 196 |
+
})
|
| 197 |
+
|
| 198 |
+
from tools.interrupt import is_interrupted
|
| 199 |
+
if is_interrupted():
|
| 200 |
+
return tool_error("Interrupted")
|
| 201 |
+
|
| 202 |
+
try:
|
| 203 |
+
from gateway.config import load_gateway_config, Platform
|
| 204 |
+
config = load_gateway_config()
|
| 205 |
+
except Exception as e:
|
| 206 |
+
return json.dumps(_error(f"Failed to load gateway config: {e}"))
|
| 207 |
+
|
| 208 |
+
platform_map = {
|
| 209 |
+
"telegram": Platform.TELEGRAM,
|
| 210 |
+
"discord": Platform.DISCORD,
|
| 211 |
+
"slack": Platform.SLACK,
|
| 212 |
+
"whatsapp": Platform.WHATSAPP,
|
| 213 |
+
"signal": Platform.SIGNAL,
|
| 214 |
+
"bluebubbles": Platform.BLUEBUBBLES,
|
| 215 |
+
"qqbot": Platform.QQBOT,
|
| 216 |
+
"matrix": Platform.MATRIX,
|
| 217 |
+
"mattermost": Platform.MATTERMOST,
|
| 218 |
+
"homeassistant": Platform.HOMEASSISTANT,
|
| 219 |
+
"dingtalk": Platform.DINGTALK,
|
| 220 |
+
"feishu": Platform.FEISHU,
|
| 221 |
+
"wecom": Platform.WECOM,
|
| 222 |
+
"wecom_callback": Platform.WECOM_CALLBACK,
|
| 223 |
+
"weixin": Platform.WEIXIN,
|
| 224 |
+
"email": Platform.EMAIL,
|
| 225 |
+
"sms": Platform.SMS,
|
| 226 |
+
"yuanbao": Platform.YUANBAO,
|
| 227 |
+
}
|
| 228 |
+
platform = platform_map.get(platform_name)
|
| 229 |
+
if not platform:
|
| 230 |
+
avail = ", ".join(platform_map.keys())
|
| 231 |
+
return tool_error(f"Unknown platform: {platform_name}. Available: {avail}")
|
| 232 |
+
|
| 233 |
+
pconfig = config.platforms.get(platform)
|
| 234 |
+
if not pconfig or not pconfig.enabled:
|
| 235 |
+
# Weixin can be configured purely via .env; synthesize a pconfig so
|
| 236 |
+
# send_message and cron delivery work without a gateway.yaml entry.
|
| 237 |
+
if platform_name == "weixin":
|
| 238 |
+
wx_token = os.getenv("WEIXIN_TOKEN", "").strip()
|
| 239 |
+
wx_account = os.getenv("WEIXIN_ACCOUNT_ID", "").strip()
|
| 240 |
+
if wx_token and wx_account:
|
| 241 |
+
from gateway.config import PlatformConfig
|
| 242 |
+
pconfig = PlatformConfig(
|
| 243 |
+
enabled=True,
|
| 244 |
+
token=wx_token,
|
| 245 |
+
extra={
|
| 246 |
+
"account_id": wx_account,
|
| 247 |
+
"base_url": os.getenv("WEIXIN_BASE_URL", "").strip(),
|
| 248 |
+
"cdn_base_url": os.getenv("WEIXIN_CDN_BASE_URL", "").strip(),
|
| 249 |
+
},
|
| 250 |
+
)
|
| 251 |
+
else:
|
| 252 |
+
return tool_error(f"Platform '{platform_name}' is not configured. Set up credentials in ~/.hermes/config.yaml or environment variables.")
|
| 253 |
+
else:
|
| 254 |
+
return tool_error(f"Platform '{platform_name}' is not configured. Set up credentials in ~/.hermes/config.yaml or environment variables.")
|
| 255 |
+
|
| 256 |
+
from gateway.platforms.base import BasePlatformAdapter
|
| 257 |
+
|
| 258 |
+
media_files, cleaned_message = BasePlatformAdapter.extract_media(message)
|
| 259 |
+
mirror_text = cleaned_message.strip() or _describe_media_for_mirror(media_files)
|
| 260 |
+
|
| 261 |
+
used_home_channel = False
|
| 262 |
+
if not chat_id:
|
| 263 |
+
home = config.get_home_channel(platform)
|
| 264 |
+
if not home and platform_name == "weixin":
|
| 265 |
+
wx_home = os.getenv("WEIXIN_HOME_CHANNEL", "").strip()
|
| 266 |
+
if wx_home:
|
| 267 |
+
from gateway.config import HomeChannel
|
| 268 |
+
home = HomeChannel(platform=platform, chat_id=wx_home, name="Weixin Home")
|
| 269 |
+
if home:
|
| 270 |
+
chat_id = home.chat_id
|
| 271 |
+
used_home_channel = True
|
| 272 |
+
else:
|
| 273 |
+
return json.dumps({
|
| 274 |
+
"error": f"No home channel set for {platform_name} to determine where to send the message. "
|
| 275 |
+
f"Either specify a channel directly with '{platform_name}:CHANNEL_NAME', "
|
| 276 |
+
f"or set a home channel via: hermes config set {platform_name.upper()}_HOME_CHANNEL <channel_id>"
|
| 277 |
+
})
|
| 278 |
+
|
| 279 |
+
duplicate_skip = _maybe_skip_cron_duplicate_send(platform_name, chat_id, thread_id)
|
| 280 |
+
if duplicate_skip:
|
| 281 |
+
return json.dumps(duplicate_skip)
|
| 282 |
+
|
| 283 |
+
try:
|
| 284 |
+
from model_tools import _run_async
|
| 285 |
+
result = _run_async(
|
| 286 |
+
_send_to_platform(
|
| 287 |
+
platform,
|
| 288 |
+
pconfig,
|
| 289 |
+
chat_id,
|
| 290 |
+
cleaned_message,
|
| 291 |
+
thread_id=thread_id,
|
| 292 |
+
media_files=media_files,
|
| 293 |
+
)
|
| 294 |
+
)
|
| 295 |
+
if used_home_channel and isinstance(result, dict) and result.get("success"):
|
| 296 |
+
result["note"] = f"Sent to {platform_name} home channel (chat_id: {chat_id})"
|
| 297 |
+
|
| 298 |
+
# Mirror the sent message into the target's gateway session
|
| 299 |
+
if isinstance(result, dict) and result.get("success") and mirror_text:
|
| 300 |
+
try:
|
| 301 |
+
from gateway.mirror import mirror_to_session
|
| 302 |
+
from gateway.session_context import get_session_env
|
| 303 |
+
source_label = get_session_env("HERMES_SESSION_PLATFORM", "cli")
|
| 304 |
+
user_id = get_session_env("HERMES_SESSION_USER_ID", "") or None
|
| 305 |
+
if mirror_to_session(
|
| 306 |
+
platform_name,
|
| 307 |
+
chat_id,
|
| 308 |
+
mirror_text,
|
| 309 |
+
source_label=source_label,
|
| 310 |
+
thread_id=thread_id,
|
| 311 |
+
user_id=user_id,
|
| 312 |
+
):
|
| 313 |
+
result["mirrored"] = True
|
| 314 |
+
except Exception:
|
| 315 |
+
pass
|
| 316 |
+
|
| 317 |
+
if isinstance(result, dict) and "error" in result:
|
| 318 |
+
result["error"] = _sanitize_error_text(result["error"])
|
| 319 |
+
return json.dumps(result)
|
| 320 |
+
except Exception as e:
|
| 321 |
+
return json.dumps(_error(f"Send failed: {e}"))
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def _parse_target_ref(platform_name: str, target_ref: str):
|
| 325 |
+
"""Parse a tool target into chat_id/thread_id and whether it is explicit."""
|
| 326 |
+
if platform_name == "telegram":
|
| 327 |
+
match = _TELEGRAM_TOPIC_TARGET_RE.fullmatch(target_ref)
|
| 328 |
+
if match:
|
| 329 |
+
return match.group(1), match.group(2), True
|
| 330 |
+
if platform_name == "feishu":
|
| 331 |
+
match = _FEISHU_TARGET_RE.fullmatch(target_ref)
|
| 332 |
+
if match:
|
| 333 |
+
return match.group(1), match.group(2), True
|
| 334 |
+
if platform_name == "discord":
|
| 335 |
+
match = _NUMERIC_TOPIC_RE.fullmatch(target_ref)
|
| 336 |
+
if match:
|
| 337 |
+
return match.group(1), match.group(2), True
|
| 338 |
+
if platform_name == "slack":
|
| 339 |
+
match = _SLACK_TARGET_RE.fullmatch(target_ref)
|
| 340 |
+
if match:
|
| 341 |
+
return match.group(1), None, True
|
| 342 |
+
if platform_name == "weixin":
|
| 343 |
+
match = _WEIXIN_TARGET_RE.fullmatch(target_ref)
|
| 344 |
+
if match:
|
| 345 |
+
return match.group(1), None, True
|
| 346 |
+
if platform_name == "yuanbao":
|
| 347 |
+
match = _YUANBAO_TARGET_RE.fullmatch(target_ref)
|
| 348 |
+
if match:
|
| 349 |
+
return match.group(1), None, True
|
| 350 |
+
if target_ref.strip().isdigit():
|
| 351 |
+
return f"group:{target_ref.strip()}", None, True
|
| 352 |
+
return None, None, False
|
| 353 |
+
if platform_name in _PHONE_PLATFORMS:
|
| 354 |
+
match = _E164_TARGET_RE.fullmatch(target_ref)
|
| 355 |
+
if match:
|
| 356 |
+
# Preserve the leading '+' — signal-cli and sms/whatsapp adapters
|
| 357 |
+
# expect E.164 format for direct recipients.
|
| 358 |
+
return target_ref.strip(), None, True
|
| 359 |
+
if target_ref.lstrip("-").isdigit():
|
| 360 |
+
return target_ref, None, True
|
| 361 |
+
# Matrix room IDs (start with !) and user IDs (start with @) are explicit
|
| 362 |
+
if platform_name == "matrix" and (target_ref.startswith("!") or target_ref.startswith("@")):
|
| 363 |
+
return target_ref, None, True
|
| 364 |
+
return None, None, False
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def _describe_media_for_mirror(media_files):
|
| 368 |
+
"""Return a human-readable mirror summary when a message only contains media."""
|
| 369 |
+
if not media_files:
|
| 370 |
+
return ""
|
| 371 |
+
if len(media_files) == 1:
|
| 372 |
+
media_path, is_voice = media_files[0]
|
| 373 |
+
ext = os.path.splitext(media_path)[1].lower()
|
| 374 |
+
if is_voice and ext in _VOICE_EXTS:
|
| 375 |
+
return "[Sent voice message]"
|
| 376 |
+
if ext in _IMAGE_EXTS:
|
| 377 |
+
return "[Sent image attachment]"
|
| 378 |
+
if ext in _VIDEO_EXTS:
|
| 379 |
+
return "[Sent video attachment]"
|
| 380 |
+
if ext in _AUDIO_EXTS:
|
| 381 |
+
return "[Sent audio attachment]"
|
| 382 |
+
return "[Sent document attachment]"
|
| 383 |
+
return f"[Sent {len(media_files)} media attachments]"
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def _get_cron_auto_delivery_target():
|
| 387 |
+
"""Return the cron scheduler's auto-delivery target for the current run, if any."""
|
| 388 |
+
from gateway.session_context import get_session_env
|
| 389 |
+
platform = get_session_env("HERMES_CRON_AUTO_DELIVER_PLATFORM", "").strip().lower()
|
| 390 |
+
chat_id = get_session_env("HERMES_CRON_AUTO_DELIVER_CHAT_ID", "").strip()
|
| 391 |
+
if not platform or not chat_id:
|
| 392 |
+
return None
|
| 393 |
+
thread_id = get_session_env("HERMES_CRON_AUTO_DELIVER_THREAD_ID", "").strip() or None
|
| 394 |
+
return {
|
| 395 |
+
"platform": platform,
|
| 396 |
+
"chat_id": chat_id,
|
| 397 |
+
"thread_id": thread_id,
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def _maybe_skip_cron_duplicate_send(platform_name: str, chat_id: str, thread_id: str | None):
|
| 402 |
+
"""Skip redundant cron send_message calls when the scheduler will auto-deliver there."""
|
| 403 |
+
auto_target = _get_cron_auto_delivery_target()
|
| 404 |
+
if not auto_target:
|
| 405 |
+
return None
|
| 406 |
+
|
| 407 |
+
same_target = (
|
| 408 |
+
auto_target["platform"] == platform_name
|
| 409 |
+
and str(auto_target["chat_id"]) == str(chat_id)
|
| 410 |
+
and auto_target.get("thread_id") == thread_id
|
| 411 |
+
)
|
| 412 |
+
if not same_target:
|
| 413 |
+
return None
|
| 414 |
+
|
| 415 |
+
target_label = f"{platform_name}:{chat_id}"
|
| 416 |
+
if thread_id is not None:
|
| 417 |
+
target_label += f":{thread_id}"
|
| 418 |
+
|
| 419 |
+
return {
|
| 420 |
+
"success": True,
|
| 421 |
+
"skipped": True,
|
| 422 |
+
"reason": "cron_auto_delivery_duplicate_target",
|
| 423 |
+
"target": target_label,
|
| 424 |
+
"note": (
|
| 425 |
+
f"Skipped send_message to {target_label}. This cron job will already auto-deliver "
|
| 426 |
+
"its final response to that same target. Put the intended user-facing content in "
|
| 427 |
+
"your final response instead, or use a different target if you want an additional message."
|
| 428 |
+
),
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None):
|
| 433 |
+
"""Route a message to the appropriate platform sender.
|
| 434 |
+
|
| 435 |
+
Long messages are automatically chunked to fit within platform limits
|
| 436 |
+
using the same smart-splitting algorithm as the gateway adapters
|
| 437 |
+
(preserves code-block boundaries, adds part indicators).
|
| 438 |
+
"""
|
| 439 |
+
from gateway.config import Platform
|
| 440 |
+
from gateway.platforms.base import BasePlatformAdapter, utf16_len
|
| 441 |
+
from gateway.platforms.discord import DiscordAdapter
|
| 442 |
+
from gateway.platforms.slack import SlackAdapter
|
| 443 |
+
|
| 444 |
+
# Telegram adapter import is optional (requires python-telegram-bot)
|
| 445 |
+
try:
|
| 446 |
+
from gateway.platforms.telegram import TelegramAdapter
|
| 447 |
+
_telegram_available = True
|
| 448 |
+
except ImportError:
|
| 449 |
+
_telegram_available = False
|
| 450 |
+
|
| 451 |
+
# Feishu adapter import is optional (requires lark-oapi)
|
| 452 |
+
try:
|
| 453 |
+
from gateway.platforms.feishu import FeishuAdapter
|
| 454 |
+
_feishu_available = True
|
| 455 |
+
except ImportError:
|
| 456 |
+
_feishu_available = False
|
| 457 |
+
|
| 458 |
+
media_files = media_files or []
|
| 459 |
+
|
| 460 |
+
if platform == Platform.SLACK and message:
|
| 461 |
+
try:
|
| 462 |
+
slack_adapter = SlackAdapter.__new__(SlackAdapter)
|
| 463 |
+
message = slack_adapter.format_message(message)
|
| 464 |
+
except Exception:
|
| 465 |
+
logger.debug("Failed to apply Slack mrkdwn formatting in _send_to_platform", exc_info=True)
|
| 466 |
+
|
| 467 |
+
# Platform message length limits (from adapter class attributes)
|
| 468 |
+
_MAX_LENGTHS = {
|
| 469 |
+
Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH if _telegram_available else 4096,
|
| 470 |
+
Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH,
|
| 471 |
+
Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH,
|
| 472 |
+
}
|
| 473 |
+
if _feishu_available:
|
| 474 |
+
_MAX_LENGTHS[Platform.FEISHU] = FeishuAdapter.MAX_MESSAGE_LENGTH
|
| 475 |
+
|
| 476 |
+
# Smart-chunk the message to fit within platform limits.
|
| 477 |
+
# For short messages or platforms without a known limit this is a no-op.
|
| 478 |
+
# Telegram measures length in UTF-16 code units, not Unicode codepoints.
|
| 479 |
+
max_len = _MAX_LENGTHS.get(platform)
|
| 480 |
+
if max_len:
|
| 481 |
+
_len_fn = utf16_len if platform == Platform.TELEGRAM else None
|
| 482 |
+
chunks = BasePlatformAdapter.truncate_message(message, max_len, len_fn=_len_fn)
|
| 483 |
+
else:
|
| 484 |
+
chunks = [message]
|
| 485 |
+
|
| 486 |
+
# --- Telegram: special handling for media attachments ---
|
| 487 |
+
if platform == Platform.TELEGRAM:
|
| 488 |
+
last_result = None
|
| 489 |
+
disable_link_previews = bool(getattr(pconfig, "extra", {}) and pconfig.extra.get("disable_link_previews"))
|
| 490 |
+
for i, chunk in enumerate(chunks):
|
| 491 |
+
is_last = (i == len(chunks) - 1)
|
| 492 |
+
result = await _send_telegram(
|
| 493 |
+
pconfig.token,
|
| 494 |
+
chat_id,
|
| 495 |
+
chunk,
|
| 496 |
+
media_files=media_files if is_last else [],
|
| 497 |
+
thread_id=thread_id,
|
| 498 |
+
disable_link_previews=disable_link_previews,
|
| 499 |
+
)
|
| 500 |
+
if isinstance(result, dict) and result.get("error"):
|
| 501 |
+
return result
|
| 502 |
+
last_result = result
|
| 503 |
+
return last_result
|
| 504 |
+
|
| 505 |
+
# --- Weixin: use the native one-shot adapter helper for text + media ---
|
| 506 |
+
if platform == Platform.WEIXIN:
|
| 507 |
+
return await _send_weixin(pconfig, chat_id, message, media_files=media_files)
|
| 508 |
+
|
| 509 |
+
# --- Feishu: use the native one-shot adapter helper for text + media ---
|
| 510 |
+
if platform == Platform.FEISHU:
|
| 511 |
+
return await _send_feishu(pconfig, chat_id, message, media_files=media_files, thread_id=thread_id)
|
| 512 |
+
|
| 513 |
+
# --- Discord: special handling for media attachments ---
|
| 514 |
+
if platform == Platform.DISCORD:
|
| 515 |
+
last_result = None
|
| 516 |
+
for i, chunk in enumerate(chunks):
|
| 517 |
+
is_last = (i == len(chunks) - 1)
|
| 518 |
+
result = await _send_discord(
|
| 519 |
+
pconfig.token,
|
| 520 |
+
chat_id,
|
| 521 |
+
chunk,
|
| 522 |
+
media_files=media_files if is_last else [],
|
| 523 |
+
thread_id=thread_id,
|
| 524 |
+
)
|
| 525 |
+
if isinstance(result, dict) and result.get("error"):
|
| 526 |
+
return result
|
| 527 |
+
last_result = result
|
| 528 |
+
return last_result
|
| 529 |
+
|
| 530 |
+
# --- Matrix: use the native adapter helper when media is present ---
|
| 531 |
+
if platform == Platform.MATRIX and media_files:
|
| 532 |
+
last_result = None
|
| 533 |
+
for i, chunk in enumerate(chunks):
|
| 534 |
+
is_last = (i == len(chunks) - 1)
|
| 535 |
+
result = await _send_matrix_via_adapter(
|
| 536 |
+
pconfig,
|
| 537 |
+
chat_id,
|
| 538 |
+
chunk,
|
| 539 |
+
media_files=media_files if is_last else [],
|
| 540 |
+
thread_id=thread_id,
|
| 541 |
+
)
|
| 542 |
+
if isinstance(result, dict) and result.get("error"):
|
| 543 |
+
return result
|
| 544 |
+
last_result = result
|
| 545 |
+
return last_result
|
| 546 |
+
|
| 547 |
+
# --- Signal: native attachment support via JSON-RPC attachments param ---
|
| 548 |
+
if platform == Platform.SIGNAL and media_files:
|
| 549 |
+
last_result = None
|
| 550 |
+
for i, chunk in enumerate(chunks):
|
| 551 |
+
is_last = (i == len(chunks) - 1)
|
| 552 |
+
result = await _send_signal(
|
| 553 |
+
pconfig.extra,
|
| 554 |
+
chat_id,
|
| 555 |
+
chunk,
|
| 556 |
+
media_files=media_files if is_last else [],
|
| 557 |
+
)
|
| 558 |
+
if isinstance(result, dict) and result.get("error"):
|
| 559 |
+
return result
|
| 560 |
+
last_result = result
|
| 561 |
+
return last_result
|
| 562 |
+
|
| 563 |
+
# --- Non-media platforms ---
|
| 564 |
+
if media_files and not message.strip():
|
| 565 |
+
return {
|
| 566 |
+
"error": (
|
| 567 |
+
f"send_message MEDIA delivery is currently only supported for telegram, discord, matrix, weixin, feishu, signal and yuanbao; "
|
| 568 |
+
f"target {platform.value} had only media attachments"
|
| 569 |
+
)
|
| 570 |
+
}
|
| 571 |
+
warning = None
|
| 572 |
+
if media_files:
|
| 573 |
+
warning = (
|
| 574 |
+
f"MEDIA attachments were omitted for {platform.value}; "
|
| 575 |
+
"native send_message media delivery is currently only supported for telegram, discord, matrix, weixin, feishu, signal and yuanbao"
|
| 576 |
+
)
|
| 577 |
+
|
| 578 |
+
last_result = None
|
| 579 |
+
for chunk in chunks:
|
| 580 |
+
if platform == Platform.SLACK:
|
| 581 |
+
result = await _send_slack(pconfig.token, chat_id, chunk)
|
| 582 |
+
elif platform == Platform.WHATSAPP:
|
| 583 |
+
result = await _send_whatsapp(pconfig.extra, chat_id, chunk)
|
| 584 |
+
elif platform == Platform.SIGNAL:
|
| 585 |
+
result = await _send_signal(pconfig.extra, chat_id, chunk)
|
| 586 |
+
elif platform == Platform.EMAIL:
|
| 587 |
+
result = await _send_email(pconfig.extra, chat_id, chunk)
|
| 588 |
+
elif platform == Platform.SMS:
|
| 589 |
+
result = await _send_sms(pconfig.api_key, chat_id, chunk)
|
| 590 |
+
elif platform == Platform.MATTERMOST:
|
| 591 |
+
result = await _send_mattermost(pconfig.token, pconfig.extra, chat_id, chunk)
|
| 592 |
+
elif platform == Platform.MATRIX:
|
| 593 |
+
result = await _send_matrix(pconfig.token, pconfig.extra, chat_id, chunk)
|
| 594 |
+
elif platform == Platform.HOMEASSISTANT:
|
| 595 |
+
result = await _send_homeassistant(pconfig.token, pconfig.extra, chat_id, chunk)
|
| 596 |
+
elif platform == Platform.DINGTALK:
|
| 597 |
+
result = await _send_dingtalk(pconfig.extra, chat_id, chunk)
|
| 598 |
+
elif platform == Platform.FEISHU:
|
| 599 |
+
result = await _send_feishu(pconfig, chat_id, chunk, thread_id=thread_id)
|
| 600 |
+
elif platform == Platform.WECOM:
|
| 601 |
+
result = await _send_wecom(pconfig.extra, chat_id, chunk)
|
| 602 |
+
elif platform == Platform.BLUEBUBBLES:
|
| 603 |
+
result = await _send_bluebubbles(pconfig.extra, chat_id, chunk)
|
| 604 |
+
elif platform == Platform.QQBOT:
|
| 605 |
+
result = await _send_qqbot(pconfig, chat_id, chunk)
|
| 606 |
+
else:
|
| 607 |
+
result = {"error": f"Direct sending not yet implemented for {platform.value}"}
|
| 608 |
+
|
| 609 |
+
if isinstance(result, dict) and result.get("error"):
|
| 610 |
+
return result
|
| 611 |
+
last_result = result
|
| 612 |
+
|
| 613 |
+
if warning and isinstance(last_result, dict) and last_result.get("success"):
|
| 614 |
+
warnings = list(last_result.get("warnings", []))
|
| 615 |
+
warnings.append(warning)
|
| 616 |
+
last_result["warnings"] = warnings
|
| 617 |
+
return last_result
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False):
|
| 621 |
+
"""Send via Telegram Bot API (one-shot, no polling needed).
|
| 622 |
+
|
| 623 |
+
Applies markdown→MarkdownV2 formatting (same as the gateway adapter)
|
| 624 |
+
so that bold, links, and headers render correctly. If the message
|
| 625 |
+
already contains HTML tags, it is sent with ``parse_mode='HTML'``
|
| 626 |
+
instead, bypassing MarkdownV2 conversion.
|
| 627 |
+
"""
|
| 628 |
+
try:
|
| 629 |
+
from telegram import Bot
|
| 630 |
+
from telegram.constants import ParseMode
|
| 631 |
+
|
| 632 |
+
# Auto-detect HTML tags — if present, skip MarkdownV2 and send as HTML.
|
| 633 |
+
# Inspired by github.com/ashaney — PR #1568.
|
| 634 |
+
_has_html = bool(re.search(r'<[a-zA-Z/][^>]*>', message))
|
| 635 |
+
|
| 636 |
+
if _has_html:
|
| 637 |
+
formatted = message
|
| 638 |
+
send_parse_mode = ParseMode.HTML
|
| 639 |
+
else:
|
| 640 |
+
# Reuse the gateway adapter's format_message for markdown→MarkdownV2
|
| 641 |
+
try:
|
| 642 |
+
from gateway.platforms.telegram import TelegramAdapter
|
| 643 |
+
_adapter = TelegramAdapter.__new__(TelegramAdapter)
|
| 644 |
+
formatted = _adapter.format_message(message)
|
| 645 |
+
except Exception:
|
| 646 |
+
# Fallback: send as-is if formatting unavailable
|
| 647 |
+
formatted = message
|
| 648 |
+
send_parse_mode = ParseMode.MARKDOWN_V2
|
| 649 |
+
|
| 650 |
+
bot = Bot(token=token)
|
| 651 |
+
int_chat_id = int(chat_id)
|
| 652 |
+
media_files = media_files or []
|
| 653 |
+
thread_kwargs = {}
|
| 654 |
+
if thread_id is not None:
|
| 655 |
+
thread_kwargs["message_thread_id"] = int(thread_id)
|
| 656 |
+
if disable_link_previews:
|
| 657 |
+
thread_kwargs["disable_web_page_preview"] = True
|
| 658 |
+
|
| 659 |
+
last_msg = None
|
| 660 |
+
warnings = []
|
| 661 |
+
|
| 662 |
+
if formatted.strip():
|
| 663 |
+
try:
|
| 664 |
+
last_msg = await _send_telegram_message_with_retry(
|
| 665 |
+
bot,
|
| 666 |
+
chat_id=int_chat_id, text=formatted,
|
| 667 |
+
parse_mode=send_parse_mode, **thread_kwargs
|
| 668 |
+
)
|
| 669 |
+
except Exception as md_error:
|
| 670 |
+
# Parse failed, fall back to plain text
|
| 671 |
+
if "parse" in str(md_error).lower() or "markdown" in str(md_error).lower() or "html" in str(md_error).lower():
|
| 672 |
+
logger.warning(
|
| 673 |
+
"Parse mode %s failed in _send_telegram, falling back to plain text: %s",
|
| 674 |
+
send_parse_mode,
|
| 675 |
+
_sanitize_error_text(md_error),
|
| 676 |
+
)
|
| 677 |
+
if not _has_html:
|
| 678 |
+
try:
|
| 679 |
+
from gateway.platforms.telegram import _strip_mdv2
|
| 680 |
+
plain = _strip_mdv2(formatted)
|
| 681 |
+
except Exception:
|
| 682 |
+
plain = message
|
| 683 |
+
else:
|
| 684 |
+
plain = message
|
| 685 |
+
last_msg = await _send_telegram_message_with_retry(
|
| 686 |
+
bot,
|
| 687 |
+
chat_id=int_chat_id, text=plain,
|
| 688 |
+
parse_mode=None, **thread_kwargs
|
| 689 |
+
)
|
| 690 |
+
else:
|
| 691 |
+
raise
|
| 692 |
+
|
| 693 |
+
for media_path, is_voice in media_files:
|
| 694 |
+
if not os.path.exists(media_path):
|
| 695 |
+
warning = f"Media file not found, skipping: {media_path}"
|
| 696 |
+
logger.warning(warning)
|
| 697 |
+
warnings.append(warning)
|
| 698 |
+
continue
|
| 699 |
+
|
| 700 |
+
ext = os.path.splitext(media_path)[1].lower()
|
| 701 |
+
try:
|
| 702 |
+
with open(media_path, "rb") as f:
|
| 703 |
+
if ext in _IMAGE_EXTS:
|
| 704 |
+
last_msg = await bot.send_photo(
|
| 705 |
+
chat_id=int_chat_id, photo=f, **thread_kwargs
|
| 706 |
+
)
|
| 707 |
+
elif ext in _VIDEO_EXTS:
|
| 708 |
+
last_msg = await bot.send_video(
|
| 709 |
+
chat_id=int_chat_id, video=f, **thread_kwargs
|
| 710 |
+
)
|
| 711 |
+
elif ext in _VOICE_EXTS and is_voice:
|
| 712 |
+
last_msg = await bot.send_voice(
|
| 713 |
+
chat_id=int_chat_id, voice=f, **thread_kwargs
|
| 714 |
+
)
|
| 715 |
+
elif ext in _AUDIO_EXTS:
|
| 716 |
+
last_msg = await bot.send_audio(
|
| 717 |
+
chat_id=int_chat_id, audio=f, **thread_kwargs
|
| 718 |
+
)
|
| 719 |
+
else:
|
| 720 |
+
last_msg = await bot.send_document(
|
| 721 |
+
chat_id=int_chat_id, document=f, **thread_kwargs
|
| 722 |
+
)
|
| 723 |
+
except Exception as e:
|
| 724 |
+
warning = _sanitize_error_text(f"Failed to send media {media_path}: {e}")
|
| 725 |
+
logger.error(warning)
|
| 726 |
+
warnings.append(warning)
|
| 727 |
+
|
| 728 |
+
if last_msg is None:
|
| 729 |
+
error = "No deliverable text or media remained after processing MEDIA tags"
|
| 730 |
+
if warnings:
|
| 731 |
+
return {"error": error, "warnings": warnings}
|
| 732 |
+
return {"error": error}
|
| 733 |
+
|
| 734 |
+
result = {
|
| 735 |
+
"success": True,
|
| 736 |
+
"platform": "telegram",
|
| 737 |
+
"chat_id": chat_id,
|
| 738 |
+
"message_id": str(last_msg.message_id),
|
| 739 |
+
}
|
| 740 |
+
if warnings:
|
| 741 |
+
result["warnings"] = warnings
|
| 742 |
+
return result
|
| 743 |
+
except ImportError:
|
| 744 |
+
return {"error": "python-telegram-bot not installed. Run: pip install python-telegram-bot"}
|
| 745 |
+
except Exception as e:
|
| 746 |
+
return _error(f"Telegram send failed: {e}")
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
def _derive_forum_thread_name(message: str) -> str:
|
| 750 |
+
"""Derive a thread name from the first line of the message, capped at 100 chars."""
|
| 751 |
+
first_line = message.strip().split("\n", 1)[0].strip()
|
| 752 |
+
# Strip common markdown heading prefixes
|
| 753 |
+
first_line = first_line.lstrip("#").strip()
|
| 754 |
+
if not first_line:
|
| 755 |
+
first_line = "New Post"
|
| 756 |
+
return first_line[:100]
|
| 757 |
+
|
| 758 |
+
|
| 759 |
+
# Process-local cache for Discord channel-type probes. Avoids re-probing the
|
| 760 |
+
# same channel on every send when the directory cache has no entry (e.g. fresh
|
| 761 |
+
# install, or channel created after the last directory build).
|
| 762 |
+
_DISCORD_CHANNEL_TYPE_PROBE_CACHE: Dict[str, bool] = {}
|
| 763 |
+
|
| 764 |
+
|
| 765 |
+
def _remember_channel_is_forum(chat_id: str, is_forum: bool) -> None:
|
| 766 |
+
_DISCORD_CHANNEL_TYPE_PROBE_CACHE[str(chat_id)] = bool(is_forum)
|
| 767 |
+
|
| 768 |
+
|
| 769 |
+
def _probe_is_forum_cached(chat_id: str) -> Optional[bool]:
|
| 770 |
+
return _DISCORD_CHANNEL_TYPE_PROBE_CACHE.get(str(chat_id))
|
| 771 |
+
|
| 772 |
+
|
| 773 |
+
async def _send_discord(token, chat_id, message, thread_id=None, media_files=None):
|
| 774 |
+
"""Send a single message via Discord REST API (no websocket client needed).
|
| 775 |
+
|
| 776 |
+
Chunking is handled by _send_to_platform() before this is called.
|
| 777 |
+
|
| 778 |
+
When thread_id is provided, the message is sent directly to that thread
|
| 779 |
+
via the /channels/{thread_id}/messages endpoint.
|
| 780 |
+
|
| 781 |
+
Media files are uploaded one-by-one via multipart/form-data after the
|
| 782 |
+
text message is sent (same pattern as Telegram).
|
| 783 |
+
|
| 784 |
+
Forum channels (type 15) reject POST /messages — a thread post is created
|
| 785 |
+
automatically via POST /channels/{id}/threads. Media files are uploaded
|
| 786 |
+
as multipart attachments on the starter message of the new thread.
|
| 787 |
+
|
| 788 |
+
Channel type is resolved from the channel directory first, then a
|
| 789 |
+
process-local probe cache, and only as a last resort with a live
|
| 790 |
+
GET /channels/{id} probe (whose result is memoized).
|
| 791 |
+
"""
|
| 792 |
+
try:
|
| 793 |
+
import aiohttp
|
| 794 |
+
except ImportError:
|
| 795 |
+
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
|
| 796 |
+
try:
|
| 797 |
+
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
| 798 |
+
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
|
| 799 |
+
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
| 800 |
+
auth_headers = {"Authorization": f"Bot {token}"}
|
| 801 |
+
json_headers = {**auth_headers, "Content-Type": "application/json"}
|
| 802 |
+
media_files = media_files or []
|
| 803 |
+
last_data = None
|
| 804 |
+
warnings = []
|
| 805 |
+
|
| 806 |
+
# Thread endpoint: Discord threads are channels; send directly to the thread ID.
|
| 807 |
+
if thread_id:
|
| 808 |
+
url = f"https://discord.com/api/v10/channels/{thread_id}/messages"
|
| 809 |
+
else:
|
| 810 |
+
# Check if the target channel is a forum channel (type 15).
|
| 811 |
+
# Forum channels reject POST /messages — create a thread post instead.
|
| 812 |
+
# Three-layer detection: directory cache → process-local probe
|
| 813 |
+
# cache → GET /channels/{id} probe (with result memoized).
|
| 814 |
+
_channel_type = None
|
| 815 |
+
try:
|
| 816 |
+
from gateway.channel_directory import lookup_channel_type
|
| 817 |
+
_channel_type = lookup_channel_type("discord", chat_id)
|
| 818 |
+
except Exception:
|
| 819 |
+
pass
|
| 820 |
+
|
| 821 |
+
if _channel_type == "forum":
|
| 822 |
+
is_forum = True
|
| 823 |
+
elif _channel_type is not None:
|
| 824 |
+
is_forum = False
|
| 825 |
+
else:
|
| 826 |
+
cached = _probe_is_forum_cached(chat_id)
|
| 827 |
+
if cached is not None:
|
| 828 |
+
is_forum = cached
|
| 829 |
+
else:
|
| 830 |
+
is_forum = False
|
| 831 |
+
try:
|
| 832 |
+
info_url = f"https://discord.com/api/v10/channels/{chat_id}"
|
| 833 |
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15), **_sess_kw) as info_sess:
|
| 834 |
+
async with info_sess.get(info_url, headers=json_headers, **_req_kw) as info_resp:
|
| 835 |
+
if info_resp.status == 200:
|
| 836 |
+
info = await info_resp.json()
|
| 837 |
+
is_forum = info.get("type") == 15
|
| 838 |
+
_remember_channel_is_forum(chat_id, is_forum)
|
| 839 |
+
except Exception:
|
| 840 |
+
logger.debug("Failed to probe channel type for %s", chat_id, exc_info=True)
|
| 841 |
+
|
| 842 |
+
if is_forum:
|
| 843 |
+
thread_name = _derive_forum_thread_name(message)
|
| 844 |
+
thread_url = f"https://discord.com/api/v10/channels/{chat_id}/threads"
|
| 845 |
+
|
| 846 |
+
# Filter to readable media files up front so we can pick the
|
| 847 |
+
# right code path (JSON vs multipart) before opening a session.
|
| 848 |
+
valid_media = []
|
| 849 |
+
for media_path, _is_voice in media_files:
|
| 850 |
+
if not os.path.exists(media_path):
|
| 851 |
+
warning = f"Media file not found, skipping: {media_path}"
|
| 852 |
+
logger.warning(warning)
|
| 853 |
+
warnings.append(warning)
|
| 854 |
+
continue
|
| 855 |
+
valid_media.append(media_path)
|
| 856 |
+
|
| 857 |
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60), **_sess_kw) as session:
|
| 858 |
+
if valid_media:
|
| 859 |
+
# Multipart: payload_json + files[N] creates a forum
|
| 860 |
+
# thread with the starter message plus attachments in
|
| 861 |
+
# a single API call.
|
| 862 |
+
attachments_meta = [
|
| 863 |
+
{"id": str(idx), "filename": os.path.basename(path)}
|
| 864 |
+
for idx, path in enumerate(valid_media)
|
| 865 |
+
]
|
| 866 |
+
starter_message = {"content": message, "attachments": attachments_meta}
|
| 867 |
+
payload_json = json.dumps({"name": thread_name, "message": starter_message})
|
| 868 |
+
|
| 869 |
+
form = aiohttp.FormData()
|
| 870 |
+
form.add_field("payload_json", payload_json, content_type="application/json")
|
| 871 |
+
|
| 872 |
+
# Buffer file bytes up front — aiohttp's FormData can
|
| 873 |
+
# read lazily and we don't want handles closing under
|
| 874 |
+
# it on retry.
|
| 875 |
+
try:
|
| 876 |
+
for idx, media_path in enumerate(valid_media):
|
| 877 |
+
with open(media_path, "rb") as fh:
|
| 878 |
+
form.add_field(
|
| 879 |
+
f"files[{idx}]",
|
| 880 |
+
fh.read(),
|
| 881 |
+
filename=os.path.basename(media_path),
|
| 882 |
+
)
|
| 883 |
+
async with session.post(thread_url, headers=auth_headers, data=form, **_req_kw) as resp:
|
| 884 |
+
if resp.status not in (200, 201):
|
| 885 |
+
body = await resp.text()
|
| 886 |
+
return _error(f"Discord forum thread creation error ({resp.status}): {body}")
|
| 887 |
+
data = await resp.json()
|
| 888 |
+
except Exception as e:
|
| 889 |
+
return _error(_sanitize_error_text(f"Discord forum thread upload failed: {e}"))
|
| 890 |
+
else:
|
| 891 |
+
# No media — simple JSON POST creates the thread with
|
| 892 |
+
# just the text starter.
|
| 893 |
+
async with session.post(
|
| 894 |
+
thread_url,
|
| 895 |
+
headers=json_headers,
|
| 896 |
+
json={
|
| 897 |
+
"name": thread_name,
|
| 898 |
+
"message": {"content": message},
|
| 899 |
+
},
|
| 900 |
+
**_req_kw,
|
| 901 |
+
) as resp:
|
| 902 |
+
if resp.status not in (200, 201):
|
| 903 |
+
body = await resp.text()
|
| 904 |
+
return _error(f"Discord forum thread creation error ({resp.status}): {body}")
|
| 905 |
+
data = await resp.json()
|
| 906 |
+
|
| 907 |
+
thread_id_created = data.get("id")
|
| 908 |
+
starter_msg_id = (data.get("message") or {}).get("id", thread_id_created)
|
| 909 |
+
result = {
|
| 910 |
+
"success": True,
|
| 911 |
+
"platform": "discord",
|
| 912 |
+
"chat_id": chat_id,
|
| 913 |
+
"thread_id": thread_id_created,
|
| 914 |
+
"message_id": starter_msg_id,
|
| 915 |
+
}
|
| 916 |
+
if warnings:
|
| 917 |
+
result["warnings"] = warnings
|
| 918 |
+
return result
|
| 919 |
+
|
| 920 |
+
url = f"https://discord.com/api/v10/channels/{chat_id}/messages"
|
| 921 |
+
|
| 922 |
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:
|
| 923 |
+
# Send text message (skip if empty and media is present)
|
| 924 |
+
if message.strip() or not media_files:
|
| 925 |
+
async with session.post(url, headers=json_headers, json={"content": message}, **_req_kw) as resp:
|
| 926 |
+
if resp.status not in (200, 201):
|
| 927 |
+
body = await resp.text()
|
| 928 |
+
return _error(f"Discord API error ({resp.status}): {body}")
|
| 929 |
+
last_data = await resp.json()
|
| 930 |
+
|
| 931 |
+
# Send each media file as a separate multipart upload
|
| 932 |
+
for media_path, _is_voice in media_files:
|
| 933 |
+
if not os.path.exists(media_path):
|
| 934 |
+
warning = f"Media file not found, skipping: {media_path}"
|
| 935 |
+
logger.warning(warning)
|
| 936 |
+
warnings.append(warning)
|
| 937 |
+
continue
|
| 938 |
+
try:
|
| 939 |
+
form = aiohttp.FormData()
|
| 940 |
+
filename = os.path.basename(media_path)
|
| 941 |
+
with open(media_path, "rb") as f:
|
| 942 |
+
form.add_field("files[0]", f, filename=filename)
|
| 943 |
+
async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp:
|
| 944 |
+
if resp.status not in (200, 201):
|
| 945 |
+
body = await resp.text()
|
| 946 |
+
warning = _sanitize_error_text(f"Failed to send media {media_path}: Discord API error ({resp.status}): {body}")
|
| 947 |
+
logger.error(warning)
|
| 948 |
+
warnings.append(warning)
|
| 949 |
+
continue
|
| 950 |
+
last_data = await resp.json()
|
| 951 |
+
except Exception as e:
|
| 952 |
+
warning = _sanitize_error_text(f"Failed to send media {media_path}: {e}")
|
| 953 |
+
logger.error(warning)
|
| 954 |
+
warnings.append(warning)
|
| 955 |
+
|
| 956 |
+
if last_data is None:
|
| 957 |
+
error = "No deliverable text or media remained after processing"
|
| 958 |
+
if warnings:
|
| 959 |
+
return {"error": error, "warnings": warnings}
|
| 960 |
+
return {"error": error}
|
| 961 |
+
|
| 962 |
+
result = {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": last_data.get("id")}
|
| 963 |
+
if warnings:
|
| 964 |
+
result["warnings"] = warnings
|
| 965 |
+
return result
|
| 966 |
+
except Exception as e:
|
| 967 |
+
return _error(f"Discord send failed: {e}")
|
| 968 |
+
|
| 969 |
+
|
| 970 |
+
async def _send_slack(token, chat_id, message):
|
| 971 |
+
"""Send via Slack Web API."""
|
| 972 |
+
try:
|
| 973 |
+
import aiohttp
|
| 974 |
+
except ImportError:
|
| 975 |
+
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
|
| 976 |
+
try:
|
| 977 |
+
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
| 978 |
+
_proxy = resolve_proxy_url()
|
| 979 |
+
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
| 980 |
+
url = "https://slack.com/api/chat.postMessage"
|
| 981 |
+
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
| 982 |
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:
|
| 983 |
+
payload = {"channel": chat_id, "text": message, "mrkdwn": True}
|
| 984 |
+
async with session.post(url, headers=headers, json=payload, **_req_kw) as resp:
|
| 985 |
+
data = await resp.json()
|
| 986 |
+
if data.get("ok"):
|
| 987 |
+
return {"success": True, "platform": "slack", "chat_id": chat_id, "message_id": data.get("ts")}
|
| 988 |
+
return _error(f"Slack API error: {data.get('error', 'unknown')}")
|
| 989 |
+
except Exception as e:
|
| 990 |
+
return _error(f"Slack send failed: {e}")
|
| 991 |
+
|
| 992 |
+
|
| 993 |
+
async def _send_whatsapp(extra, chat_id, message):
|
| 994 |
+
"""Send via the local WhatsApp bridge HTTP API."""
|
| 995 |
+
try:
|
| 996 |
+
import aiohttp
|
| 997 |
+
except ImportError:
|
| 998 |
+
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
|
| 999 |
+
try:
|
| 1000 |
+
bridge_port = extra.get("bridge_port", 3000)
|
| 1001 |
+
async with aiohttp.ClientSession() as session:
|
| 1002 |
+
async with session.post(
|
| 1003 |
+
f"http://localhost:{bridge_port}/send",
|
| 1004 |
+
json={"chatId": chat_id, "message": message},
|
| 1005 |
+
timeout=aiohttp.ClientTimeout(total=30),
|
| 1006 |
+
) as resp:
|
| 1007 |
+
if resp.status == 200:
|
| 1008 |
+
data = await resp.json()
|
| 1009 |
+
return {
|
| 1010 |
+
"success": True,
|
| 1011 |
+
"platform": "whatsapp",
|
| 1012 |
+
"chat_id": chat_id,
|
| 1013 |
+
"message_id": data.get("messageId"),
|
| 1014 |
+
}
|
| 1015 |
+
body = await resp.text()
|
| 1016 |
+
return _error(f"WhatsApp bridge error ({resp.status}): {body}")
|
| 1017 |
+
except Exception as e:
|
| 1018 |
+
return _error(f"WhatsApp send failed: {e}")
|
| 1019 |
+
|
| 1020 |
+
|
| 1021 |
+
async def _send_signal(extra, chat_id, message, media_files=None):
|
| 1022 |
+
"""Send via signal-cli JSON-RPC API.
|
| 1023 |
+
|
| 1024 |
+
Supports both text-only and text-with-attachments (images/audio/documents).
|
| 1025 |
+
Attachments are sent as an 'attachments' array in the JSON-RPC params.
|
| 1026 |
+
"""
|
| 1027 |
+
try:
|
| 1028 |
+
import httpx
|
| 1029 |
+
except ImportError:
|
| 1030 |
+
return {"error": "httpx not installed"}
|
| 1031 |
+
try:
|
| 1032 |
+
http_url = extra.get("http_url", "http://127.0.0.1:8080").rstrip("/")
|
| 1033 |
+
account = extra.get("account", "")
|
| 1034 |
+
if not account:
|
| 1035 |
+
return {"error": "Signal account not configured"}
|
| 1036 |
+
|
| 1037 |
+
params = {"account": account, "message": message}
|
| 1038 |
+
if chat_id.startswith("group:"):
|
| 1039 |
+
params["groupId"] = chat_id[6:]
|
| 1040 |
+
else:
|
| 1041 |
+
params["recipient"] = [chat_id]
|
| 1042 |
+
|
| 1043 |
+
# Add attachments if media_files are present
|
| 1044 |
+
valid_media = media_files or []
|
| 1045 |
+
attachment_paths = []
|
| 1046 |
+
for media_path, _is_voice in valid_media:
|
| 1047 |
+
if os.path.exists(media_path):
|
| 1048 |
+
attachment_paths.append(media_path)
|
| 1049 |
+
else:
|
| 1050 |
+
logger.warning("Signal media file not found, skipping: %s", media_path)
|
| 1051 |
+
|
| 1052 |
+
if attachment_paths:
|
| 1053 |
+
params["attachments"] = attachment_paths
|
| 1054 |
+
|
| 1055 |
+
payload = {
|
| 1056 |
+
"jsonrpc": "2.0",
|
| 1057 |
+
"method": "send",
|
| 1058 |
+
"params": params,
|
| 1059 |
+
"id": f"send_{int(time.time() * 1000)}",
|
| 1060 |
+
}
|
| 1061 |
+
|
| 1062 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 1063 |
+
resp = await client.post(f"{http_url}/api/v1/rpc", json=payload)
|
| 1064 |
+
resp.raise_for_status()
|
| 1065 |
+
data = resp.json()
|
| 1066 |
+
if "error" in data:
|
| 1067 |
+
return _error(f"Signal RPC error: {data['error']}")
|
| 1068 |
+
|
| 1069 |
+
# Return warning for any skipped media files
|
| 1070 |
+
result = {"success": True, "platform": "signal", "chat_id": chat_id}
|
| 1071 |
+
if len(attachment_paths) < len(valid_media):
|
| 1072 |
+
result["warnings"] = [f"Some media files were skipped (not found on disk)"]
|
| 1073 |
+
return result
|
| 1074 |
+
except Exception as e:
|
| 1075 |
+
return _error(f"Signal send failed: {e}")
|
| 1076 |
+
|
| 1077 |
+
|
| 1078 |
+
async def _send_email(extra, chat_id, message):
|
| 1079 |
+
"""Send via SMTP (one-shot, no persistent connection needed)."""
|
| 1080 |
+
import smtplib
|
| 1081 |
+
from email.mime.text import MIMEText
|
| 1082 |
+
from email.utils import formatdate
|
| 1083 |
+
|
| 1084 |
+
address = extra.get("address") or os.getenv("EMAIL_ADDRESS", "")
|
| 1085 |
+
password = os.getenv("EMAIL_PASSWORD", "")
|
| 1086 |
+
smtp_host = extra.get("smtp_host") or os.getenv("EMAIL_SMTP_HOST", "")
|
| 1087 |
+
try:
|
| 1088 |
+
smtp_port = int(os.getenv("EMAIL_SMTP_PORT", "587"))
|
| 1089 |
+
except (ValueError, TypeError):
|
| 1090 |
+
smtp_port = 587
|
| 1091 |
+
|
| 1092 |
+
if not all([address, password, smtp_host]):
|
| 1093 |
+
return {"error": "Email not configured (EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_SMTP_HOST required)"}
|
| 1094 |
+
|
| 1095 |
+
try:
|
| 1096 |
+
msg = MIMEText(message, "plain", "utf-8")
|
| 1097 |
+
msg["From"] = address
|
| 1098 |
+
msg["To"] = chat_id
|
| 1099 |
+
msg["Subject"] = "Hermes Agent"
|
| 1100 |
+
msg["Date"] = formatdate(localtime=True)
|
| 1101 |
+
|
| 1102 |
+
server = smtplib.SMTP(smtp_host, smtp_port)
|
| 1103 |
+
server.starttls(context=ssl.create_default_context())
|
| 1104 |
+
server.login(address, password)
|
| 1105 |
+
server.send_message(msg)
|
| 1106 |
+
server.quit()
|
| 1107 |
+
return {"success": True, "platform": "email", "chat_id": chat_id}
|
| 1108 |
+
except Exception as e:
|
| 1109 |
+
return _error(f"Email send failed: {e}")
|
| 1110 |
+
|
| 1111 |
+
|
| 1112 |
+
async def _send_sms(auth_token, chat_id, message):
|
| 1113 |
+
"""Send a single SMS via Twilio REST API.
|
| 1114 |
+
|
| 1115 |
+
Uses HTTP Basic auth (Account SID : Auth Token) and form-encoded POST.
|
| 1116 |
+
Chunking is handled by _send_to_platform() before this is called.
|
| 1117 |
+
"""
|
| 1118 |
+
try:
|
| 1119 |
+
import aiohttp
|
| 1120 |
+
except ImportError:
|
| 1121 |
+
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
|
| 1122 |
+
|
| 1123 |
+
import base64
|
| 1124 |
+
|
| 1125 |
+
account_sid = os.getenv("TWILIO_ACCOUNT_SID", "")
|
| 1126 |
+
from_number = os.getenv("TWILIO_PHONE_NUMBER", "")
|
| 1127 |
+
if not account_sid or not auth_token or not from_number:
|
| 1128 |
+
return {"error": "SMS not configured (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER required)"}
|
| 1129 |
+
|
| 1130 |
+
# Strip markdown — SMS renders it as literal characters
|
| 1131 |
+
message = re.sub(r"\*\*(.+?)\*\*", r"\1", message, flags=re.DOTALL)
|
| 1132 |
+
message = re.sub(r"\*(.+?)\*", r"\1", message, flags=re.DOTALL)
|
| 1133 |
+
message = re.sub(r"__(.+?)__", r"\1", message, flags=re.DOTALL)
|
| 1134 |
+
message = re.sub(r"_(.+?)_", r"\1", message, flags=re.DOTALL)
|
| 1135 |
+
message = re.sub(r"```[a-z]*\n?", "", message)
|
| 1136 |
+
message = re.sub(r"`(.+?)`", r"\1", message)
|
| 1137 |
+
message = re.sub(r"^#{1,6}\s+", "", message, flags=re.MULTILINE)
|
| 1138 |
+
message = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", message)
|
| 1139 |
+
message = re.sub(r"\n{3,}", "\n\n", message)
|
| 1140 |
+
message = message.strip()
|
| 1141 |
+
|
| 1142 |
+
try:
|
| 1143 |
+
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
| 1144 |
+
_proxy = resolve_proxy_url()
|
| 1145 |
+
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
| 1146 |
+
creds = f"{account_sid}:{auth_token}"
|
| 1147 |
+
encoded = base64.b64encode(creds.encode("ascii")).decode("ascii")
|
| 1148 |
+
url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json"
|
| 1149 |
+
headers = {"Authorization": f"Basic {encoded}"}
|
| 1150 |
+
|
| 1151 |
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:
|
| 1152 |
+
form_data = aiohttp.FormData()
|
| 1153 |
+
form_data.add_field("From", from_number)
|
| 1154 |
+
form_data.add_field("To", chat_id)
|
| 1155 |
+
form_data.add_field("Body", message)
|
| 1156 |
+
|
| 1157 |
+
async with session.post(url, data=form_data, headers=headers, **_req_kw) as resp:
|
| 1158 |
+
body = await resp.json()
|
| 1159 |
+
if resp.status >= 400:
|
| 1160 |
+
error_msg = body.get("message", str(body))
|
| 1161 |
+
return _error(f"Twilio API error ({resp.status}): {error_msg}")
|
| 1162 |
+
msg_sid = body.get("sid", "")
|
| 1163 |
+
return {"success": True, "platform": "sms", "chat_id": chat_id, "message_id": msg_sid}
|
| 1164 |
+
except Exception as e:
|
| 1165 |
+
return _error(f"SMS send failed: {e}")
|
| 1166 |
+
|
| 1167 |
+
|
| 1168 |
+
async def _send_mattermost(token, extra, chat_id, message):
|
| 1169 |
+
"""Send via Mattermost REST API."""
|
| 1170 |
+
try:
|
| 1171 |
+
import aiohttp
|
| 1172 |
+
except ImportError:
|
| 1173 |
+
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
|
| 1174 |
+
try:
|
| 1175 |
+
base_url = (extra.get("url") or os.getenv("MATTERMOST_URL", "")).rstrip("/")
|
| 1176 |
+
token = token or os.getenv("MATTERMOST_TOKEN", "")
|
| 1177 |
+
if not base_url or not token:
|
| 1178 |
+
return {"error": "Mattermost not configured (MATTERMOST_URL, MATTERMOST_TOKEN required)"}
|
| 1179 |
+
url = f"{base_url}/api/v4/posts"
|
| 1180 |
+
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
| 1181 |
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
|
| 1182 |
+
async with session.post(url, headers=headers, json={"channel_id": chat_id, "message": message}) as resp:
|
| 1183 |
+
if resp.status not in (200, 201):
|
| 1184 |
+
body = await resp.text()
|
| 1185 |
+
return _error(f"Mattermost API error ({resp.status}): {body}")
|
| 1186 |
+
data = await resp.json()
|
| 1187 |
+
return {"success": True, "platform": "mattermost", "chat_id": chat_id, "message_id": data.get("id")}
|
| 1188 |
+
except Exception as e:
|
| 1189 |
+
return _error(f"Mattermost send failed: {e}")
|
| 1190 |
+
|
| 1191 |
+
|
| 1192 |
+
async def _send_matrix(token, extra, chat_id, message):
|
| 1193 |
+
"""Send via Matrix Client-Server API.
|
| 1194 |
+
|
| 1195 |
+
Converts markdown to HTML for rich rendering in Matrix clients.
|
| 1196 |
+
Falls back to plain text if the ``markdown`` library is not installed.
|
| 1197 |
+
"""
|
| 1198 |
+
try:
|
| 1199 |
+
import aiohttp
|
| 1200 |
+
except ImportError:
|
| 1201 |
+
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
|
| 1202 |
+
try:
|
| 1203 |
+
homeserver = (extra.get("homeserver") or os.getenv("MATRIX_HOMESERVER", "")).rstrip("/")
|
| 1204 |
+
token = token or os.getenv("MATRIX_ACCESS_TOKEN", "")
|
| 1205 |
+
if not homeserver or not token:
|
| 1206 |
+
return {"error": "Matrix not configured (MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN required)"}
|
| 1207 |
+
txn_id = f"hermes_{int(time.time() * 1000)}_{os.urandom(4).hex()}"
|
| 1208 |
+
from urllib.parse import quote
|
| 1209 |
+
encoded_room = quote(chat_id, safe="")
|
| 1210 |
+
url = f"{homeserver}/_matrix/client/v3/rooms/{encoded_room}/send/m.room.message/{txn_id}"
|
| 1211 |
+
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
| 1212 |
+
|
| 1213 |
+
# Build message payload with optional HTML formatted_body.
|
| 1214 |
+
payload = {"msgtype": "m.text", "body": message}
|
| 1215 |
+
try:
|
| 1216 |
+
import markdown as _md
|
| 1217 |
+
html = _md.markdown(message, extensions=["fenced_code", "tables"])
|
| 1218 |
+
# Convert h1-h6 to bold for Element X compatibility.
|
| 1219 |
+
html = re.sub(r"<h[1-6]>(.*?)</h[1-6]>", r"<strong>\1</strong>", html)
|
| 1220 |
+
payload["format"] = "org.matrix.custom.html"
|
| 1221 |
+
payload["formatted_body"] = html
|
| 1222 |
+
except ImportError:
|
| 1223 |
+
pass
|
| 1224 |
+
|
| 1225 |
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
|
| 1226 |
+
async with session.put(url, headers=headers, json=payload) as resp:
|
| 1227 |
+
if resp.status not in (200, 201):
|
| 1228 |
+
body = await resp.text()
|
| 1229 |
+
return _error(f"Matrix API error ({resp.status}): {body}")
|
| 1230 |
+
data = await resp.json()
|
| 1231 |
+
return {"success": True, "platform": "matrix", "chat_id": chat_id, "message_id": data.get("event_id")}
|
| 1232 |
+
except Exception as e:
|
| 1233 |
+
return _error(f"Matrix send failed: {e}")
|
| 1234 |
+
|
| 1235 |
+
|
| 1236 |
+
async def _send_matrix_via_adapter(pconfig, chat_id, message, media_files=None, thread_id=None):
|
| 1237 |
+
"""Send via the Matrix adapter so native Matrix media uploads are preserved."""
|
| 1238 |
+
try:
|
| 1239 |
+
from gateway.platforms.matrix import MatrixAdapter
|
| 1240 |
+
except ImportError:
|
| 1241 |
+
return {"error": "Matrix dependencies not installed. Run: pip install 'mautrix[encryption]'"}
|
| 1242 |
+
|
| 1243 |
+
media_files = media_files or []
|
| 1244 |
+
|
| 1245 |
+
try:
|
| 1246 |
+
adapter = MatrixAdapter(pconfig)
|
| 1247 |
+
connected = await adapter.connect()
|
| 1248 |
+
if not connected:
|
| 1249 |
+
return _error("Matrix connect failed")
|
| 1250 |
+
|
| 1251 |
+
metadata = {"thread_id": thread_id} if thread_id else None
|
| 1252 |
+
last_result = None
|
| 1253 |
+
|
| 1254 |
+
if message.strip():
|
| 1255 |
+
last_result = await adapter.send(chat_id, message, metadata=metadata)
|
| 1256 |
+
if not last_result.success:
|
| 1257 |
+
return _error(f"Matrix send failed: {last_result.error}")
|
| 1258 |
+
|
| 1259 |
+
for media_path, is_voice in media_files:
|
| 1260 |
+
if not os.path.exists(media_path):
|
| 1261 |
+
return _error(f"Media file not found: {media_path}")
|
| 1262 |
+
|
| 1263 |
+
ext = os.path.splitext(media_path)[1].lower()
|
| 1264 |
+
if ext in _IMAGE_EXTS:
|
| 1265 |
+
last_result = await adapter.send_image_file(chat_id, media_path, metadata=metadata)
|
| 1266 |
+
elif ext in _VIDEO_EXTS:
|
| 1267 |
+
last_result = await adapter.send_video(chat_id, media_path, metadata=metadata)
|
| 1268 |
+
elif ext in _VOICE_EXTS and is_voice:
|
| 1269 |
+
last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
|
| 1270 |
+
elif ext in _AUDIO_EXTS:
|
| 1271 |
+
last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
|
| 1272 |
+
else:
|
| 1273 |
+
last_result = await adapter.send_document(chat_id, media_path, metadata=metadata)
|
| 1274 |
+
|
| 1275 |
+
if not last_result.success:
|
| 1276 |
+
return _error(f"Matrix media send failed: {last_result.error}")
|
| 1277 |
+
|
| 1278 |
+
if last_result is None:
|
| 1279 |
+
return {"error": "No deliverable text or media remained after processing MEDIA tags"}
|
| 1280 |
+
|
| 1281 |
+
return {
|
| 1282 |
+
"success": True,
|
| 1283 |
+
"platform": "matrix",
|
| 1284 |
+
"chat_id": chat_id,
|
| 1285 |
+
"message_id": last_result.message_id,
|
| 1286 |
+
}
|
| 1287 |
+
except Exception as e:
|
| 1288 |
+
return _error(f"Matrix send failed: {e}")
|
| 1289 |
+
finally:
|
| 1290 |
+
try:
|
| 1291 |
+
await adapter.disconnect()
|
| 1292 |
+
except Exception:
|
| 1293 |
+
pass
|
| 1294 |
+
|
| 1295 |
+
|
| 1296 |
+
async def _send_homeassistant(token, extra, chat_id, message):
|
| 1297 |
+
"""Send via Home Assistant notify service."""
|
| 1298 |
+
try:
|
| 1299 |
+
import aiohttp
|
| 1300 |
+
except ImportError:
|
| 1301 |
+
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
|
| 1302 |
+
try:
|
| 1303 |
+
hass_url = (extra.get("url") or os.getenv("HASS_URL", "")).rstrip("/")
|
| 1304 |
+
token = token or os.getenv("HASS_TOKEN", "")
|
| 1305 |
+
if not hass_url or not token:
|
| 1306 |
+
return {"error": "Home Assistant not configured (HASS_URL, HASS_TOKEN required)"}
|
| 1307 |
+
url = f"{hass_url}/api/services/notify/notify"
|
| 1308 |
+
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
| 1309 |
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
|
| 1310 |
+
async with session.post(url, headers=headers, json={"message": message, "target": chat_id}) as resp:
|
| 1311 |
+
if resp.status not in (200, 201):
|
| 1312 |
+
body = await resp.text()
|
| 1313 |
+
return _error(f"Home Assistant API error ({resp.status}): {body}")
|
| 1314 |
+
return {"success": True, "platform": "homeassistant", "chat_id": chat_id}
|
| 1315 |
+
except Exception as e:
|
| 1316 |
+
return _error(f"Home Assistant send failed: {e}")
|
| 1317 |
+
|
| 1318 |
+
|
| 1319 |
+
async def _send_dingtalk(extra, chat_id, message):
|
| 1320 |
+
"""Send via DingTalk robot webhook.
|
| 1321 |
+
|
| 1322 |
+
Note: The gateway's DingTalk adapter uses per-session webhook URLs from
|
| 1323 |
+
incoming messages (dingtalk-stream SDK). For cross-platform send_message
|
| 1324 |
+
delivery we use a static robot webhook URL instead, which must be
|
| 1325 |
+
configured via ``DINGTALK_WEBHOOK_URL`` env var or ``webhook_url`` in the
|
| 1326 |
+
platform's extra config.
|
| 1327 |
+
"""
|
| 1328 |
+
try:
|
| 1329 |
+
import httpx
|
| 1330 |
+
except ImportError:
|
| 1331 |
+
return {"error": "httpx not installed"}
|
| 1332 |
+
try:
|
| 1333 |
+
webhook_url = extra.get("webhook_url") or os.getenv("DINGTALK_WEBHOOK_URL", "")
|
| 1334 |
+
if not webhook_url:
|
| 1335 |
+
return {"error": "DingTalk not configured. Set DINGTALK_WEBHOOK_URL env var or webhook_url in dingtalk platform extra config."}
|
| 1336 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 1337 |
+
resp = await client.post(
|
| 1338 |
+
webhook_url,
|
| 1339 |
+
json={"msgtype": "text", "text": {"content": message}},
|
| 1340 |
+
)
|
| 1341 |
+
resp.raise_for_status()
|
| 1342 |
+
data = resp.json()
|
| 1343 |
+
if data.get("errcode", 0) != 0:
|
| 1344 |
+
return _error(f"DingTalk API error: {data.get('errmsg', 'unknown')}")
|
| 1345 |
+
return {"success": True, "platform": "dingtalk", "chat_id": chat_id}
|
| 1346 |
+
except Exception as e:
|
| 1347 |
+
return _error(f"DingTalk send failed: {e}")
|
| 1348 |
+
|
| 1349 |
+
|
| 1350 |
+
async def _send_wecom(extra, chat_id, message):
|
| 1351 |
+
"""Send via WeCom using the adapter's WebSocket send pipeline."""
|
| 1352 |
+
try:
|
| 1353 |
+
from gateway.platforms.wecom import WeComAdapter, check_wecom_requirements
|
| 1354 |
+
if not check_wecom_requirements():
|
| 1355 |
+
return {"error": "WeCom requirements not met. Need aiohttp + WECOM_BOT_ID/SECRET."}
|
| 1356 |
+
except ImportError:
|
| 1357 |
+
return {"error": "WeCom adapter not available."}
|
| 1358 |
+
|
| 1359 |
+
try:
|
| 1360 |
+
from gateway.config import PlatformConfig
|
| 1361 |
+
pconfig = PlatformConfig(extra=extra)
|
| 1362 |
+
adapter = WeComAdapter(pconfig)
|
| 1363 |
+
connected = await adapter.connect()
|
| 1364 |
+
if not connected:
|
| 1365 |
+
return _error(f"WeCom: failed to connect - {adapter.fatal_error_message or 'unknown error'}")
|
| 1366 |
+
try:
|
| 1367 |
+
result = await adapter.send(chat_id, message)
|
| 1368 |
+
if not result.success:
|
| 1369 |
+
return _error(f"WeCom send failed: {result.error}")
|
| 1370 |
+
return {"success": True, "platform": "wecom", "chat_id": chat_id, "message_id": result.message_id}
|
| 1371 |
+
finally:
|
| 1372 |
+
await adapter.disconnect()
|
| 1373 |
+
except Exception as e:
|
| 1374 |
+
return _error(f"WeCom send failed: {e}")
|
| 1375 |
+
|
| 1376 |
+
|
| 1377 |
+
async def _send_weixin(pconfig, chat_id, message, media_files=None):
|
| 1378 |
+
"""Send via Weixin iLink using the native adapter helper."""
|
| 1379 |
+
try:
|
| 1380 |
+
from gateway.platforms.weixin import check_weixin_requirements, send_weixin_direct
|
| 1381 |
+
if not check_weixin_requirements():
|
| 1382 |
+
return {"error": "Weixin requirements not met. Need aiohttp + cryptography."}
|
| 1383 |
+
except ImportError:
|
| 1384 |
+
return {"error": "Weixin adapter not available."}
|
| 1385 |
+
|
| 1386 |
+
try:
|
| 1387 |
+
return await send_weixin_direct(
|
| 1388 |
+
extra=pconfig.extra,
|
| 1389 |
+
token=pconfig.token,
|
| 1390 |
+
chat_id=chat_id,
|
| 1391 |
+
message=message,
|
| 1392 |
+
media_files=media_files,
|
| 1393 |
+
)
|
| 1394 |
+
except Exception as e:
|
| 1395 |
+
return _error(f"Weixin send failed: {e}")
|
| 1396 |
+
|
| 1397 |
+
|
| 1398 |
+
async def _send_bluebubbles(extra, chat_id, message):
|
| 1399 |
+
"""Send via BlueBubbles iMessage server using the adapter's REST API."""
|
| 1400 |
+
try:
|
| 1401 |
+
from gateway.platforms.bluebubbles import BlueBubblesAdapter, check_bluebubbles_requirements
|
| 1402 |
+
if not check_bluebubbles_requirements():
|
| 1403 |
+
return {"error": "BlueBubbles requirements not met (need aiohttp + httpx)."}
|
| 1404 |
+
except ImportError:
|
| 1405 |
+
return {"error": "BlueBubbles adapter not available."}
|
| 1406 |
+
|
| 1407 |
+
try:
|
| 1408 |
+
from gateway.config import PlatformConfig
|
| 1409 |
+
pconfig = PlatformConfig(extra=extra)
|
| 1410 |
+
adapter = BlueBubblesAdapter(pconfig)
|
| 1411 |
+
connected = await adapter.connect()
|
| 1412 |
+
if not connected:
|
| 1413 |
+
return _error("BlueBubbles: failed to connect to server")
|
| 1414 |
+
try:
|
| 1415 |
+
result = await adapter.send(chat_id, message)
|
| 1416 |
+
if not result.success:
|
| 1417 |
+
return _error(f"BlueBubbles send failed: {result.error}")
|
| 1418 |
+
return {"success": True, "platform": "bluebubbles", "chat_id": chat_id, "message_id": result.message_id}
|
| 1419 |
+
finally:
|
| 1420 |
+
await adapter.disconnect()
|
| 1421 |
+
except Exception as e:
|
| 1422 |
+
return _error(f"BlueBubbles send failed: {e}")
|
| 1423 |
+
|
| 1424 |
+
|
| 1425 |
+
async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=None):
|
| 1426 |
+
"""Send via Feishu/Lark using the adapter's send pipeline."""
|
| 1427 |
+
try:
|
| 1428 |
+
from gateway.platforms.feishu import FeishuAdapter, FEISHU_AVAILABLE
|
| 1429 |
+
if not FEISHU_AVAILABLE:
|
| 1430 |
+
return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"}
|
| 1431 |
+
from gateway.platforms.feishu import FEISHU_DOMAIN, LARK_DOMAIN
|
| 1432 |
+
except ImportError:
|
| 1433 |
+
return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"}
|
| 1434 |
+
|
| 1435 |
+
media_files = media_files or []
|
| 1436 |
+
|
| 1437 |
+
try:
|
| 1438 |
+
adapter = FeishuAdapter(pconfig)
|
| 1439 |
+
domain_name = getattr(adapter, "_domain_name", "feishu")
|
| 1440 |
+
domain = FEISHU_DOMAIN if domain_name != "lark" else LARK_DOMAIN
|
| 1441 |
+
adapter._client = adapter._build_lark_client(domain)
|
| 1442 |
+
metadata = {"thread_id": thread_id} if thread_id else None
|
| 1443 |
+
|
| 1444 |
+
last_result = None
|
| 1445 |
+
if message.strip():
|
| 1446 |
+
last_result = await adapter.send(chat_id, message, metadata=metadata)
|
| 1447 |
+
if not last_result.success:
|
| 1448 |
+
return _error(f"Feishu send failed: {last_result.error}")
|
| 1449 |
+
|
| 1450 |
+
for media_path, is_voice in media_files:
|
| 1451 |
+
if not os.path.exists(media_path):
|
| 1452 |
+
return _error(f"Media file not found: {media_path}")
|
| 1453 |
+
|
| 1454 |
+
ext = os.path.splitext(media_path)[1].lower()
|
| 1455 |
+
if ext in _IMAGE_EXTS:
|
| 1456 |
+
last_result = await adapter.send_image_file(chat_id, media_path, metadata=metadata)
|
| 1457 |
+
elif ext in _VIDEO_EXTS:
|
| 1458 |
+
last_result = await adapter.send_video(chat_id, media_path, metadata=metadata)
|
| 1459 |
+
elif ext in _VOICE_EXTS and is_voice:
|
| 1460 |
+
last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
|
| 1461 |
+
elif ext in _AUDIO_EXTS:
|
| 1462 |
+
last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
|
| 1463 |
+
else:
|
| 1464 |
+
last_result = await adapter.send_document(chat_id, media_path, metadata=metadata)
|
| 1465 |
+
|
| 1466 |
+
if not last_result.success:
|
| 1467 |
+
return _error(f"Feishu media send failed: {last_result.error}")
|
| 1468 |
+
|
| 1469 |
+
if last_result is None:
|
| 1470 |
+
return {"error": "No deliverable text or media remained after processing MEDIA tags"}
|
| 1471 |
+
|
| 1472 |
+
return {
|
| 1473 |
+
"success": True,
|
| 1474 |
+
"platform": "feishu",
|
| 1475 |
+
"chat_id": chat_id,
|
| 1476 |
+
"message_id": last_result.message_id,
|
| 1477 |
+
}
|
| 1478 |
+
except Exception as e:
|
| 1479 |
+
return _error(f"Feishu send failed: {e}")
|
| 1480 |
+
|
| 1481 |
+
|
| 1482 |
+
def _check_send_message():
|
| 1483 |
+
"""Gate send_message on gateway running (always available on messaging platforms)."""
|
| 1484 |
+
from gateway.session_context import get_session_env
|
| 1485 |
+
platform = get_session_env("HERMES_SESSION_PLATFORM", "")
|
| 1486 |
+
if platform and platform != "local":
|
| 1487 |
+
return True
|
| 1488 |
+
try:
|
| 1489 |
+
from gateway.status import is_gateway_running
|
| 1490 |
+
return is_gateway_running()
|
| 1491 |
+
except Exception:
|
| 1492 |
+
return False
|
| 1493 |
+
|
| 1494 |
+
|
| 1495 |
+
async def _send_qqbot(pconfig, chat_id, message):
|
| 1496 |
+
"""Send via QQBot using the REST API directly (no WebSocket needed).
|
| 1497 |
+
|
| 1498 |
+
Uses the QQ Bot Open Platform REST endpoints to get an access token
|
| 1499 |
+
and post a message. Works for guild channels without requiring
|
| 1500 |
+
a running gateway adapter.
|
| 1501 |
+
"""
|
| 1502 |
+
try:
|
| 1503 |
+
import httpx
|
| 1504 |
+
except ImportError:
|
| 1505 |
+
return _error("QQBot direct send requires httpx. Run: pip install httpx")
|
| 1506 |
+
|
| 1507 |
+
extra = pconfig.extra or {}
|
| 1508 |
+
appid = extra.get("app_id") or os.getenv("QQ_APP_ID", "")
|
| 1509 |
+
secret = (pconfig.token or extra.get("client_secret")
|
| 1510 |
+
or os.getenv("QQ_CLIENT_SECRET", ""))
|
| 1511 |
+
if not appid or not secret:
|
| 1512 |
+
return _error("QQBot: QQ_APP_ID / QQ_CLIENT_SECRET not configured.")
|
| 1513 |
+
|
| 1514 |
+
try:
|
| 1515 |
+
async with httpx.AsyncClient(timeout=15) as client:
|
| 1516 |
+
# Step 1: Get access token
|
| 1517 |
+
token_resp = await client.post(
|
| 1518 |
+
"https://bots.qq.com/app/getAppAccessToken",
|
| 1519 |
+
json={"appId": str(appid), "clientSecret": str(secret)},
|
| 1520 |
+
)
|
| 1521 |
+
if token_resp.status_code != 200:
|
| 1522 |
+
return _error(f"QQBot token request failed: {token_resp.status_code}")
|
| 1523 |
+
token_data = token_resp.json()
|
| 1524 |
+
access_token = token_data.get("access_token")
|
| 1525 |
+
if not access_token:
|
| 1526 |
+
return _error(f"QQBot: no access_token in response")
|
| 1527 |
+
|
| 1528 |
+
# Step 2: Send message via REST
|
| 1529 |
+
headers = {
|
| 1530 |
+
"Authorization": f"QQBot {access_token}",
|
| 1531 |
+
"Content-Type": "application/json",
|
| 1532 |
+
}
|
| 1533 |
+
url = f"https://api.sgroup.qq.com/channels/{chat_id}/messages"
|
| 1534 |
+
payload = {"content": message[:4000], "msg_type": 0}
|
| 1535 |
+
|
| 1536 |
+
resp = await client.post(url, json=payload, headers=headers)
|
| 1537 |
+
if resp.status_code in (200, 201):
|
| 1538 |
+
data = resp.json()
|
| 1539 |
+
return {"success": True, "platform": "qqbot", "chat_id": chat_id,
|
| 1540 |
+
"message_id": data.get("id")}
|
| 1541 |
+
else:
|
| 1542 |
+
return _error(f"QQBot send failed: {resp.status_code} {resp.text}")
|
| 1543 |
+
except Exception as e:
|
| 1544 |
+
return _error(f"QQBot send failed: {e}")
|
| 1545 |
+
|
| 1546 |
+
|
| 1547 |
+
async def _send_yuanbao(chat_id, message, media_files=None):
|
| 1548 |
+
"""Send via Yuanbao using the running gateway adapter's WebSocket connection.
|
| 1549 |
+
|
| 1550 |
+
Yuanbao uses a persistent WebSocket — unlike HTTP-based platforms, we
|
| 1551 |
+
cannot create a throwaway client. We obtain the running singleton from
|
| 1552 |
+
the adapter module itself (``get_active_adapter``).
|
| 1553 |
+
|
| 1554 |
+
chat_id format:
|
| 1555 |
+
- Group: "group:<group_code>"
|
| 1556 |
+
- DM: "direct:<account_id>" or just "<account_id>"
|
| 1557 |
+
"""
|
| 1558 |
+
try:
|
| 1559 |
+
from gateway.platforms.yuanbao import get_active_adapter, send_yuanbao_direct
|
| 1560 |
+
except ImportError:
|
| 1561 |
+
return _error("Yuanbao adapter module not available.")
|
| 1562 |
+
|
| 1563 |
+
adapter = get_active_adapter()
|
| 1564 |
+
if adapter is None:
|
| 1565 |
+
return _error(
|
| 1566 |
+
"Yuanbao adapter is not running. "
|
| 1567 |
+
"Start the gateway with yuanbao platform enabled first."
|
| 1568 |
+
)
|
| 1569 |
+
|
| 1570 |
+
try:
|
| 1571 |
+
return await send_yuanbao_direct(adapter, chat_id, message, media_files=media_files)
|
| 1572 |
+
except Exception as e:
|
| 1573 |
+
return _error(f"Yuanbao send failed: {e}")
|
| 1574 |
+
|
| 1575 |
+
|
| 1576 |
+
# --- Registry ---
|
| 1577 |
+
from tools.registry import registry, tool_error
|
| 1578 |
+
|
| 1579 |
+
registry.register(
|
| 1580 |
+
name="send_message",
|
| 1581 |
+
toolset="messaging",
|
| 1582 |
+
schema=SEND_MESSAGE_SCHEMA,
|
| 1583 |
+
handler=send_message_tool,
|
| 1584 |
+
check_fn=_check_send_message,
|
| 1585 |
+
emoji="📨",
|
| 1586 |
+
)
|