Z User commited on
Commit
ca5e3fb
·
1 Parent(s): 25d86d2

fix: Feishu/Weixin file sending + anti-hallucination prompts

Browse files
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 ![alt](url) 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 ![alt](url) 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 ![alt](url) 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 ![alt](url) 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 ![alt](url) 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 ![alt](url) 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 ![alt](url) 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 ![alt](url) 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 ![alt](url) 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 ![alt](url) 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
+ )