diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py
index fcd2eb12f391..0de263c415d6 100644
--- a/agent/auxiliary_client.py
+++ b/agent/auxiliary_client.py
@@ -627,8 +627,6 @@ def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str]]:
custom_key = runtime.get("api_key")
if not isinstance(custom_base, str) or not custom_base.strip():
return None, None
- if not isinstance(custom_key, str) or not custom_key.strip():
- return None, None
custom_base = custom_base.strip().rstrip("/")
if "openrouter.ai" in custom_base.lower():
@@ -636,6 +634,13 @@ def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str]]:
# configured. Treat that as "no custom endpoint" for auxiliary routing.
return None, None
+ # Local servers (Ollama, llama.cpp, vLLM, LM Studio) don't require auth.
+ # Use a placeholder key — the OpenAI SDK requires a non-empty string but
+ # local servers ignore the Authorization header. Same fix as cli.py
+ # _ensure_runtime_credentials() (PR #2556).
+ if not isinstance(custom_key, str) or not custom_key.strip():
+ custom_key = "no-key-required"
+
return custom_base, custom_key.strip()
@@ -737,16 +742,37 @@ def _resolve_forced_provider(forced: str) -> Tuple[Optional[OpenAI], Optional[st
return None, None
+_AUTO_PROVIDER_LABELS = {
+ "_try_openrouter": "openrouter",
+ "_try_nous": "nous",
+ "_try_custom_endpoint": "local/custom",
+ "_try_codex": "openai-codex",
+ "_resolve_api_key_provider": "api-key",
+}
+
+
def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]:
"""Full auto-detection chain: OpenRouter → Nous → custom → Codex → API-key → None."""
global auxiliary_is_nous
auxiliary_is_nous = False # Reset — _try_nous() will set True if it wins
+ tried = []
for try_fn in (_try_openrouter, _try_nous, _try_custom_endpoint,
_try_codex, _resolve_api_key_provider):
+ fn_name = getattr(try_fn, "__name__", "unknown")
+ label = _AUTO_PROVIDER_LABELS.get(fn_name, fn_name)
client, model = try_fn()
if client is not None:
+ if tried:
+ logger.info("Auxiliary auto-detect: using %s (%s) — skipped: %s",
+ label, model or "default", ", ".join(tried))
+ else:
+ logger.info("Auxiliary auto-detect: using %s (%s)", label, model or "default")
return client, model
- logger.debug("Auxiliary client: none available")
+ tried.append(label)
+ logger.warning("Auxiliary auto-detect: no provider available (tried: %s). "
+ "Compression, summarization, and memory flush will not work. "
+ "Set OPENROUTER_API_KEY or configure a local model in config.yaml.",
+ ", ".join(tried))
return None, None
@@ -897,11 +923,12 @@ def resolve_provider_client(
custom_key = (
(explicit_api_key or "").strip()
or os.getenv("OPENAI_API_KEY", "").strip()
+ or "no-key-required" # local servers don't need auth
)
- if not custom_base or not custom_key:
+ if not custom_base:
logger.warning(
"resolve_provider_client: explicit custom endpoint requested "
- "but no API key was found (set explicit_api_key or OPENAI_API_KEY)"
+ "but base_url is empty"
)
return None, None
final_model = model or _read_main_model() or "gpt-4o-mini"
@@ -1612,8 +1639,8 @@ def call_llm(
)
# For auto/custom, fall back to OpenRouter
if not resolved_base_url:
- logger.warning("Provider %s unavailable, falling back to openrouter",
- resolved_provider)
+ logger.info("Auxiliary %s: provider %s unavailable, falling back to openrouter",
+ task or "call", resolved_provider)
client, final_model = _get_cached_client(
"openrouter", resolved_model or _OPENROUTER_MODEL)
if client is None:
@@ -1623,6 +1650,13 @@ def call_llm(
effective_timeout = timeout if timeout is not None else _get_task_timeout(task)
+ # Log what we're about to do — makes auxiliary operations visible
+ _base_info = str(getattr(client, "base_url", resolved_base_url) or "")
+ if task:
+ logger.info("Auxiliary %s: using %s (%s)%s",
+ task, resolved_provider or "auto", final_model or "default",
+ f" at {_base_info}" if _base_info and "openrouter" not in _base_info else "")
+
kwargs = _build_call_kwargs(
resolved_provider, final_model, messages,
temperature=temperature, max_tokens=max_tokens,
diff --git a/agent/display.py b/agent/display.py
index caa94b7d05d0..de47002d0636 100644
--- a/agent/display.py
+++ b/agent/display.py
@@ -17,6 +17,23 @@ _RESET = "\033[0m"
logger = logging.getLogger(__name__)
+# =========================================================================
+# Configurable tool preview length (0 = no limit)
+# Set once at startup by CLI or gateway from display.tool_preview_length config.
+# =========================================================================
+_tool_preview_max_len: int = 0 # 0 = unlimited
+
+
+def set_tool_preview_max_len(n: int) -> None:
+ """Set the global max length for tool call previews. 0 = no limit."""
+ global _tool_preview_max_len
+ _tool_preview_max_len = max(int(n), 0) if n else 0
+
+
+def get_tool_preview_max_len() -> int:
+ """Return the configured max preview length (0 = unlimited)."""
+ return _tool_preview_max_len
+
# =========================================================================
# Skin-aware helpers (lazy import to avoid circular deps)
@@ -94,8 +111,14 @@ def _oneline(text: str) -> str:
return " ".join(text.split())
-def build_tool_preview(tool_name: str, args: dict, max_len: int = 40) -> str | None:
- """Build a short preview of a tool call's primary argument for display."""
+def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -> str | None:
+ """Build a short preview of a tool call's primary argument for display.
+
+ *max_len* controls truncation. ``None`` (default) defers to the global
+ ``_tool_preview_max_len`` set via config; ``0`` means unlimited.
+ """
+ if max_len is None:
+ max_len = _tool_preview_max_len
if not args:
return None
primary_args = {
@@ -190,7 +213,7 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int = 40) -> str | N
preview = _oneline(str(value))
if not preview:
return None
- if len(preview) > max_len:
+ if max_len > 0 and len(preview) > max_len:
preview = preview[:max_len - 3] + "..."
return preview
@@ -484,10 +507,14 @@ def get_cute_tool_message(
def _trunc(s, n=40):
s = str(s)
+ if _tool_preview_max_len == 0:
+ return s # no limit
return (s[:n-3] + "...") if len(s) > n else s
def _path(p, n=35):
p = str(p)
+ if _tool_preview_max_len == 0:
+ return p # no limit
return ("..." + p[-(n-3):]) if len(p) > n else p
def _wrap(line: str) -> str:
diff --git a/agent/model_metadata.py b/agent/model_metadata.py
index 162295f81d49..0c121e6f6307 100644
--- a/agent/model_metadata.py
+++ b/agent/model_metadata.py
@@ -171,6 +171,7 @@ _URL_TO_PROVIDER: Dict[str, str] = {
"dashscope.aliyuncs.com": "alibaba",
"dashscope-intl.aliyuncs.com": "alibaba",
"openrouter.ai": "openrouter",
+ "generativelanguage.googleapis.com": "google",
"inference-api.nousresearch.com": "nous",
"api.deepseek.com": "deepseek",
"api.githubcopilot.com": "copilot",
diff --git a/cli-config.yaml.example b/cli-config.yaml.example
index 1f7a515c9c42..922807f17a2e 100644
--- a/cli-config.yaml.example
+++ b/cli-config.yaml.example
@@ -11,14 +11,29 @@ model:
default: "anthropic/claude-opus-4.6"
# Inference provider selection:
- # "auto" - Use Nous Portal if logged in, otherwise OpenRouter/env vars (default)
- # "nous-api" - Use Nous Portal via API key (requires: NOUS_API_KEY)
- # "openrouter" - Always use OpenRouter API key from OPENROUTER_API_KEY
- # "nous" - Always use Nous Portal (requires: hermes login)
- # "zai" - Use z.ai / ZhipuAI GLM models (requires: GLM_API_KEY)
- # "kimi-coding"- Use Kimi / Moonshot AI models (requires: KIMI_API_KEY)
- # "minimax" - Use MiniMax global endpoint (requires: MINIMAX_API_KEY)
- # "minimax-cn" - Use MiniMax China endpoint (requires: MINIMAX_CN_API_KEY)
+ # "auto" - Auto-detect from credentials (default)
+ # "openrouter" - OpenRouter (requires: OPENROUTER_API_KEY or OPENAI_API_KEY)
+ # "nous" - Nous Portal OAuth (requires: hermes login)
+ # "nous-api" - Nous Portal API key (requires: NOUS_API_KEY)
+ # "anthropic" - Direct Anthropic API (requires: ANTHROPIC_API_KEY)
+ # "openai-codex" - OpenAI Codex (requires: hermes login --provider openai-codex)
+ # "copilot" - GitHub Copilot / GitHub Models (requires: GITHUB_TOKEN)
+ # "zai" - z.ai / ZhipuAI GLM (requires: GLM_API_KEY)
+ # "kimi-coding" - Kimi / Moonshot AI (requires: KIMI_API_KEY)
+ # "minimax" - MiniMax global (requires: MINIMAX_API_KEY)
+ # "minimax-cn" - MiniMax China (requires: MINIMAX_CN_API_KEY)
+ # "huggingface" - Hugging Face Inference (requires: HF_TOKEN)
+ # "kilocode" - KiloCode gateway (requires: KILOCODE_API_KEY)
+ # "ai-gateway" - Vercel AI Gateway (requires: AI_GATEWAY_API_KEY)
+ #
+ # Local servers (LM Studio, Ollama, vLLM, llama.cpp):
+ # "custom" - Any OpenAI-compatible endpoint. Set base_url below.
+ # Aliases: "lmstudio", "ollama", "vllm", "llamacpp" all map to "custom".
+ # Example for LM Studio:
+ # provider: "lmstudio"
+ # base_url: "http://localhost:1234/v1"
+ # No API key needed — local servers typically ignore auth.
+ #
# Can also be overridden with --provider flag or HERMES_INFERENCE_PROVIDER env var.
provider: "auto"
@@ -309,6 +324,9 @@ compression:
# vision:
# provider: "auto"
# model: "" # e.g. "google/gemini-2.5-flash", "openai/gpt-4o"
+# timeout: 30 # LLM API call timeout (seconds)
+# download_timeout: 30 # Image HTTP download timeout (seconds)
+# # Increase for slow connections or self-hosted image servers
#
# # Web page scraping / summarization + browser page text extraction
# web_extract:
diff --git a/cli.py b/cli.py
index 8b4f67296917..f9873ccf5d15 100644
--- a/cli.py
+++ b/cli.py
@@ -449,6 +449,14 @@ try:
except Exception:
pass # Skin engine is optional — default skin used if unavailable
+# Initialize tool preview length from config
+try:
+ from agent.display import set_tool_preview_max_len
+ _tpl = CLI_CONFIG.get("display", {}).get("tool_preview_length", 0)
+ set_tool_preview_max_len(int(_tpl) if _tpl else 0)
+except Exception:
+ pass
+
# Neuter AsyncHttpxClientWrapper.__del__ before any AsyncOpenAI clients are
# created. The SDK's __del__ schedules aclose() on asyncio.get_running_loop()
# which, during CLI idle time, finds prompt_toolkit's event loop and tries to
@@ -1079,10 +1087,10 @@ class HermesCLI:
# env vars would stomp each other.
_model_config = CLI_CONFIG.get("model", {})
_config_model = (_model_config.get("default") or _model_config.get("model") or "") if isinstance(_model_config, dict) else (_model_config or "")
- _FALLBACK_MODEL = "anthropic/claude-opus-4.6"
- self.model = model or _config_model or _FALLBACK_MODEL
- # Auto-detect model from local server if still on fallback
- if self.model == _FALLBACK_MODEL:
+ _DEFAULT_CONFIG_MODEL = "anthropic/claude-opus-4.6"
+ self.model = model or _config_model or _DEFAULT_CONFIG_MODEL
+ # Auto-detect model from local server if still on default
+ if self.model == _DEFAULT_CONFIG_MODEL:
_base_url = (_model_config.get("base_url") or "") if isinstance(_model_config, dict) else ""
if "localhost" in _base_url or "127.0.0.1" in _base_url:
from hermes_cli.runtime_provider import _auto_detect_local_model
@@ -1096,7 +1104,7 @@ class HermesCLI:
# explicit choice — the user just never changed it. But a config model
# like "gpt-5.3-codex" IS explicit and must be preserved.
self._model_is_default = not model and (
- not _config_model or _config_model == _FALLBACK_MODEL
+ not _config_model or _config_model == _DEFAULT_CONFIG_MODEL
)
self._explicit_api_key = api_key
@@ -1347,6 +1355,49 @@ class HermesCLI:
return snapshot
+ @staticmethod
+ def _status_bar_display_width(text: str) -> int:
+ """Return terminal cell width for status-bar text.
+
+ len() is not enough for prompt_toolkit layout decisions because some
+ glyphs can render wider than one Python codepoint. Keeping the status
+ bar within the real display width prevents it from wrapping onto a
+ second line and leaving behind duplicate rows.
+ """
+ try:
+ from prompt_toolkit.utils import get_cwidth
+ return get_cwidth(text or "")
+ except Exception:
+ return len(text or "")
+
+ @classmethod
+ def _trim_status_bar_text(cls, text: str, max_width: int) -> str:
+ """Trim status-bar text to a single terminal row."""
+ if max_width <= 0:
+ return ""
+ try:
+ from prompt_toolkit.utils import get_cwidth
+ except Exception:
+ get_cwidth = None
+
+ if cls._status_bar_display_width(text) <= max_width:
+ return text
+
+ ellipsis = "..."
+ ellipsis_width = cls._status_bar_display_width(ellipsis)
+ if max_width <= ellipsis_width:
+ return ellipsis[:max_width]
+
+ out = []
+ width = 0
+ for ch in text:
+ ch_width = get_cwidth(ch) if get_cwidth else len(ch)
+ if width + ch_width + ellipsis_width > max_width:
+ break
+ out.append(ch)
+ width += ch_width
+ return "".join(out).rstrip() + ellipsis
+
def _build_status_bar_text(self, width: Optional[int] = None) -> str:
try:
snapshot = self._get_status_bar_snapshot()
@@ -1361,11 +1412,12 @@ class HermesCLI:
duration_label = snapshot["duration"]
if width < 52:
- return f"⚕ {snapshot['model_short']} · {duration_label}"
+ text = f"⚕ {snapshot['model_short']} · {duration_label}"
+ return self._trim_status_bar_text(text, width)
if width < 76:
parts = [f"⚕ {snapshot['model_short']}", percent_label]
parts.append(duration_label)
- return " · ".join(parts)
+ return self._trim_status_bar_text(" · ".join(parts), width)
if snapshot["context_length"]:
ctx_total = _format_context_length(snapshot["context_length"])
@@ -1376,7 +1428,7 @@ class HermesCLI:
parts = [f"⚕ {snapshot['model_short']}", context_label, percent_label]
parts.append(duration_label)
- return " │ ".join(parts)
+ return self._trim_status_bar_text(" │ ".join(parts), width)
except Exception:
return f"⚕ {self.model if getattr(self, 'model', None) else 'Hermes'}"
@@ -1398,53 +1450,54 @@ class HermesCLI:
duration_label = snapshot["duration"]
if width < 52:
- return [
- ("class:status-bar", " ⚕ "),
- ("class:status-bar-strong", snapshot["model_short"]),
- ("class:status-bar-dim", " · "),
- ("class:status-bar-dim", duration_label),
- ("class:status-bar", " "),
- ]
-
- percent = snapshot["context_percent"]
- percent_label = f"{percent}%" if percent is not None else "--"
- if width < 76:
frags = [
("class:status-bar", " ⚕ "),
("class:status-bar-strong", snapshot["model_short"]),
- ("class:status-bar-dim", " · "),
- (self._status_bar_context_style(percent), percent_label),
- ]
- frags.extend([
("class:status-bar-dim", " · "),
("class:status-bar-dim", duration_label),
("class:status-bar", " "),
- ])
- return frags
-
- if snapshot["context_length"]:
- ctx_total = _format_context_length(snapshot["context_length"])
- ctx_used = format_token_count_compact(snapshot["context_tokens"])
- context_label = f"{ctx_used}/{ctx_total}"
+ ]
else:
- context_label = "ctx --"
+ percent = snapshot["context_percent"]
+ percent_label = f"{percent}%" if percent is not None else "--"
+ if width < 76:
+ frags = [
+ ("class:status-bar", " ⚕ "),
+ ("class:status-bar-strong", snapshot["model_short"]),
+ ("class:status-bar-dim", " · "),
+ (self._status_bar_context_style(percent), percent_label),
+ ("class:status-bar-dim", " · "),
+ ("class:status-bar-dim", duration_label),
+ ("class:status-bar", " "),
+ ]
+ else:
+ if snapshot["context_length"]:
+ ctx_total = _format_context_length(snapshot["context_length"])
+ ctx_used = format_token_count_compact(snapshot["context_tokens"])
+ context_label = f"{ctx_used}/{ctx_total}"
+ else:
+ context_label = "ctx --"
- bar_style = self._status_bar_context_style(percent)
- frags = [
- ("class:status-bar", " ⚕ "),
- ("class:status-bar-strong", snapshot["model_short"]),
- ("class:status-bar-dim", " │ "),
- ("class:status-bar-dim", context_label),
- ("class:status-bar-dim", " │ "),
- (bar_style, self._build_context_bar(percent)),
- ("class:status-bar-dim", " "),
- (bar_style, percent_label),
- ]
- frags.extend([
- ("class:status-bar-dim", " │ "),
- ("class:status-bar-dim", duration_label),
- ("class:status-bar", " "),
- ])
+ bar_style = self._status_bar_context_style(percent)
+ frags = [
+ ("class:status-bar", " ⚕ "),
+ ("class:status-bar-strong", snapshot["model_short"]),
+ ("class:status-bar-dim", " │ "),
+ ("class:status-bar-dim", context_label),
+ ("class:status-bar-dim", " │ "),
+ (bar_style, self._build_context_bar(percent)),
+ ("class:status-bar-dim", " "),
+ (bar_style, percent_label),
+ ("class:status-bar-dim", " │ "),
+ ("class:status-bar-dim", duration_label),
+ ("class:status-bar", " "),
+ ]
+
+ total_width = sum(self._status_bar_display_width(text) for _, text in frags)
+ if total_width > width:
+ plain_text = "".join(text for _, text in frags)
+ trimmed = self._trim_status_bar_text(plain_text, width)
+ return [("class:status-bar", trimmed)]
return frags
except Exception:
return [("class:status-bar", f" {self._build_status_bar_text()} ")]
@@ -2736,22 +2789,12 @@ class HermesCLI:
print(f" MCP tool: /tools {subcommand} github:create_issue")
return
- # Confirm session reset before applying
- verb = "Disable" if subcommand == "disable" else "Enable"
+ # Apply the change directly — the user typing the command is implicit
+ # consent. Do NOT use input() here; it hangs inside prompt_toolkit's
+ # TUI event loop (known pitfall).
+ verb = "Disabling" if subcommand == "disable" else "Enabling"
label = ", ".join(names)
- _cprint(f"{_GOLD}{verb} {label}?{_RST}")
- _cprint(f"{_DIM}This will save to config and reset your session so the "
- f"change takes effect cleanly.{_RST}")
- try:
- answer = input(" Continue? [y/N] ").strip().lower()
- except (EOFError, KeyboardInterrupt):
- print()
- _cprint(f"{_DIM}Cancelled.{_RST}")
- return
-
- if answer not in ("y", "yes"):
- _cprint(f"{_DIM}Cancelled.{_RST}")
- return
+ _cprint(f"{_GOLD}{verb} {label}...{_RST}")
tools_disable_enable_command(
Namespace(tools_action=subcommand, names=names, platform="cli"))
@@ -4786,8 +4829,10 @@ class HermesCLI:
from agent.display import get_tool_emoji
emoji = get_tool_emoji(function_name)
label = preview or function_name
- if len(label) > 50:
- label = label[:47] + "..."
+ from agent.display import get_tool_preview_max_len
+ _pl = get_tool_preview_max_len()
+ if _pl > 0 and len(label) > _pl:
+ label = label[:_pl - 3] + "..."
self._spinner_text = f"{emoji} {label}"
self._invalidate()
@@ -6159,6 +6204,11 @@ class HermesCLI:
self._interrupt_queue = queue.Queue() # For messages typed while agent is running
self._should_exit = False
self._last_ctrl_c_time = 0 # Track double Ctrl+C for force exit
+
+ # Give plugin manager a CLI reference so plugins can inject messages
+ from hermes_cli.plugins import get_plugin_manager
+ get_plugin_manager()._cli_ref = self
+
# Config file watcher — detect mcp_servers changes and auto-reload
from hermes_cli.config import get_config_path as _get_config_path
_cfg_path = _get_config_path()
diff --git a/cron/scheduler.py b/cron/scheduler.py
index 5784aec3d188..a03f00b76d26 100644
--- a/cron/scheduler.py
+++ b/cron/scheduler.py
@@ -87,6 +87,22 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:
chat_id, thread_id = rest.split(":", 1)
else:
chat_id, thread_id = rest, None
+
+ # Resolve human-friendly labels like "Alice (dm)" to real IDs.
+ # send_message(action="list") shows labels with display suffixes
+ # that aren't valid platform IDs (e.g. WhatsApp JIDs).
+ try:
+ from gateway.channel_directory import resolve_channel_name
+ target = chat_id
+ # Strip display suffix like " (dm)" or " (group)"
+ if target.endswith(")") and " (" in target:
+ target = target.rsplit(" (", 1)[0].strip()
+ resolved = resolve_channel_name(platform_name.lower(), target)
+ if resolved:
+ chat_id = resolved
+ except Exception:
+ pass
+
return {
"platform": platform_name,
"chat_id": chat_id,
@@ -146,6 +162,8 @@ def _deliver_result(job: dict, content: str) -> None:
"mattermost": Platform.MATTERMOST,
"homeassistant": Platform.HOMEASSISTANT,
"dingtalk": Platform.DINGTALK,
+ "feishu": Platform.FEISHU,
+ "wecom": Platform.WECOM,
"email": Platform.EMAIL,
"sms": Platform.SMS,
}
@@ -218,11 +236,12 @@ def _build_job_prompt(job: dict) -> str:
# Always prepend [SILENT] guidance so the cron agent can suppress
# delivery when it has nothing new or noteworthy to report.
silent_hint = (
- "[SYSTEM: If you have nothing new or noteworthy to report, respond "
- "with exactly \"[SILENT]\" (optionally followed by a brief internal "
- "note). This suppresses delivery to the user while still saving "
- "output locally. Only use [SILENT] when there are genuinely no "
- "changes worth reporting.]\n\n"
+ "[SYSTEM: If you have a meaningful status report or findings, "
+ "send them — that is the whole point of this job. Only respond "
+ "with exactly \"[SILENT]\" (nothing else) when there is genuinely "
+ "nothing new to report. [SILENT] suppresses delivery to the user. "
+ "Never combine [SILENT] with content — either report your "
+ "findings normally, or say [SILENT] and nothing more.]\n\n"
)
prompt = silent_hint + prompt
if skills is None:
@@ -320,7 +339,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
if delivery_target.get("thread_id") is not None:
os.environ["HERMES_CRON_AUTO_DELIVER_THREAD_ID"] = str(delivery_target["thread_id"])
- model = job.get("model") or os.getenv("HERMES_MODEL") or "anthropic/claude-opus-4.6"
+ model = job.get("model") or os.getenv("HERMES_MODEL") or ""
# Load config.yaml for model, reasoning, prefill, toolsets, provider routing
_cfg = {}
diff --git a/gateway/config.py b/gateway/config.py
index 5dbc81c86cc5..c8ce89a7d637 100644
--- a/gateway/config.py
+++ b/gateway/config.py
@@ -57,6 +57,8 @@ class Platform(Enum):
DINGTALK = "dingtalk"
API_SERVER = "api_server"
WEBHOOK = "webhook"
+ FEISHU = "feishu"
+ WECOM = "wecom"
@dataclass
@@ -274,6 +276,12 @@ class GatewayConfig:
# Webhook uses enabled flag only (secrets are per-route)
elif platform == Platform.WEBHOOK:
connected.append(platform)
+ # Feishu uses extra dict for app credentials
+ elif platform == Platform.FEISHU and config.extra.get("app_id"):
+ connected.append(platform)
+ # WeCom uses extra dict for bot credentials
+ elif platform == Platform.WECOM and config.extra.get("bot_id"):
+ connected.append(platform)
return connected
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
@@ -507,6 +515,10 @@ def load_gateway_config() -> GatewayConfig:
)
if "reply_prefix" in platform_cfg:
bridged["reply_prefix"] = platform_cfg["reply_prefix"]
+ if "require_mention" in platform_cfg:
+ bridged["require_mention"] = platform_cfg["require_mention"]
+ if "mention_patterns" in platform_cfg:
+ bridged["mention_patterns"] = platform_cfg["mention_patterns"]
if not bridged:
continue
plat_data = platforms_data.setdefault(plat.value, {})
@@ -531,6 +543,20 @@ def load_gateway_config() -> GatewayConfig:
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
+
+ # Telegram settings → env vars (env vars take precedence)
+ telegram_cfg = yaml_cfg.get("telegram", {})
+ if isinstance(telegram_cfg, dict):
+ if "require_mention" in telegram_cfg and not os.getenv("TELEGRAM_REQUIRE_MENTION"):
+ os.environ["TELEGRAM_REQUIRE_MENTION"] = str(telegram_cfg["require_mention"]).lower()
+ if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"):
+ import json as _json
+ os.environ["TELEGRAM_MENTION_PATTERNS"] = _json.dumps(telegram_cfg["mention_patterns"])
+ frc = telegram_cfg.get("free_response_chats")
+ if frc is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"):
+ if isinstance(frc, list):
+ frc = ",".join(str(v) for v in frc)
+ os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
except Exception as e:
logger.warning(
"Failed to process config.yaml — falling back to .env / gateway.json values. "
@@ -810,6 +836,55 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
if webhook_secret:
config.platforms[Platform.WEBHOOK].extra["secret"] = webhook_secret
+ # Feishu / Lark
+ feishu_app_id = os.getenv("FEISHU_APP_ID")
+ feishu_app_secret = os.getenv("FEISHU_APP_SECRET")
+ if feishu_app_id and feishu_app_secret:
+ if Platform.FEISHU not in config.platforms:
+ config.platforms[Platform.FEISHU] = PlatformConfig()
+ config.platforms[Platform.FEISHU].enabled = True
+ config.platforms[Platform.FEISHU].extra.update({
+ "app_id": feishu_app_id,
+ "app_secret": feishu_app_secret,
+ "domain": os.getenv("FEISHU_DOMAIN", "feishu"),
+ "connection_mode": os.getenv("FEISHU_CONNECTION_MODE", "websocket"),
+ })
+ feishu_encrypt_key = os.getenv("FEISHU_ENCRYPT_KEY", "")
+ if feishu_encrypt_key:
+ config.platforms[Platform.FEISHU].extra["encrypt_key"] = feishu_encrypt_key
+ feishu_verification_token = os.getenv("FEISHU_VERIFICATION_TOKEN", "")
+ if feishu_verification_token:
+ config.platforms[Platform.FEISHU].extra["verification_token"] = feishu_verification_token
+ feishu_home = os.getenv("FEISHU_HOME_CHANNEL")
+ if feishu_home:
+ config.platforms[Platform.FEISHU].home_channel = HomeChannel(
+ platform=Platform.FEISHU,
+ chat_id=feishu_home,
+ name=os.getenv("FEISHU_HOME_CHANNEL_NAME", "Home"),
+ )
+
+ # WeCom (Enterprise WeChat)
+ wecom_bot_id = os.getenv("WECOM_BOT_ID")
+ wecom_secret = os.getenv("WECOM_SECRET")
+ if wecom_bot_id and wecom_secret:
+ if Platform.WECOM not in config.platforms:
+ config.platforms[Platform.WECOM] = PlatformConfig()
+ config.platforms[Platform.WECOM].enabled = True
+ config.platforms[Platform.WECOM].extra.update({
+ "bot_id": wecom_bot_id,
+ "secret": wecom_secret,
+ })
+ wecom_ws_url = os.getenv("WECOM_WEBSOCKET_URL", "")
+ if wecom_ws_url:
+ config.platforms[Platform.WECOM].extra["websocket_url"] = wecom_ws_url
+ wecom_home = os.getenv("WECOM_HOME_CHANNEL")
+ if wecom_home:
+ config.platforms[Platform.WECOM].home_channel = HomeChannel(
+ platform=Platform.WECOM,
+ chat_id=wecom_home,
+ name=os.getenv("WECOM_HOME_CHANNEL_NAME", "Home"),
+ )
+
# Session settings
idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
if idle_minutes:
diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py
index efa5ed3184cd..9a821727ed6a 100644
--- a/gateway/platforms/base.py
+++ b/gateway/platforms/base.py
@@ -898,6 +898,26 @@ class BasePlatformAdapter(ABC):
except Exception:
pass
+ # ── Processing lifecycle hooks ──────────────────────────────────────────
+ # Subclasses override these to react to message processing events
+ # (e.g. Discord adds 👀/✅/❌ reactions).
+
+ async def on_processing_start(self, event: MessageEvent) -> None:
+ """Hook called when background processing begins."""
+
+ async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
+ """Hook called when background processing completes."""
+
+ async def _run_processing_hook(self, hook_name: str, *args: Any, **kwargs: Any) -> None:
+ """Run a lifecycle hook without letting failures break message flow."""
+ hook = getattr(self, hook_name, None)
+ if not callable(hook):
+ return
+ try:
+ await hook(*args, **kwargs)
+ except Exception as e:
+ logger.warning("[%s] %s hook failed: %s", self.name, hook_name, e)
+
@staticmethod
def _is_retryable_error(error: Optional[str]) -> bool:
"""Return True if the error string looks like a transient network failure."""
@@ -1060,6 +1080,18 @@ class BasePlatformAdapter(ABC):
async def _process_message_background(self, event: MessageEvent, session_key: str) -> None:
"""Background task that actually processes the message."""
+ # Track delivery outcomes for the processing-complete hook
+ delivery_attempted = False
+ delivery_succeeded = False
+
+ def _record_delivery(result):
+ nonlocal delivery_attempted, delivery_succeeded
+ if result is None:
+ return
+ delivery_attempted = True
+ if getattr(result, "success", False):
+ delivery_succeeded = True
+
# Create interrupt event for this session
interrupt_event = asyncio.Event()
self._active_sessions[session_key] = interrupt_event
@@ -1069,6 +1101,8 @@ class BasePlatformAdapter(ABC):
typing_task = asyncio.create_task(self._keep_typing(event.source.chat_id, metadata=_thread_metadata))
try:
+ await self._run_processing_hook("on_processing_start", event)
+
# Call the handler (this can take a while with tool calls)
response = await self._message_handler(event)
@@ -1138,6 +1172,7 @@ class BasePlatformAdapter(ABC):
reply_to=event.message_id,
metadata=_thread_metadata,
)
+ _record_delivery(result)
# Human-like pacing delay between text and media
human_delay = self._get_human_delay()
@@ -1237,6 +1272,10 @@ class BasePlatformAdapter(ABC):
except Exception as file_err:
logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
+ # Determine overall success for the processing hook
+ processing_ok = delivery_succeeded if delivery_attempted else not bool(response)
+ await self._run_processing_hook("on_processing_complete", event, processing_ok)
+
# Check if there's a pending message that was queued during our processing
if session_key in self._pending_messages:
pending_event = self._pending_messages.pop(session_key)
@@ -1253,7 +1292,11 @@ class BasePlatformAdapter(ABC):
await self._process_message_background(pending_event, session_key)
return # Already cleaned up
+ except asyncio.CancelledError:
+ await self._run_processing_hook("on_processing_complete", event, False)
+ raise
except Exception as e:
+ await self._run_processing_hook("on_processing_complete", event, False)
logger.error("[%s] Error handling message: %s", self.name, e, exc_info=True)
# Send the error to the user so they aren't left with radio silence
try:
diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py
index 1da9925cd5ce..9e0c9c123e60 100644
--- a/gateway/platforms/discord.py
+++ b/gateway/platforms/discord.py
@@ -660,6 +660,41 @@ class DiscordAdapter(BasePlatformAdapter):
pass
logger.info("[%s] Disconnected", self.name)
+
+ async def _add_reaction(self, message: Any, emoji: str) -> bool:
+ """Add an emoji reaction to a Discord message."""
+ if not message or not hasattr(message, "add_reaction"):
+ return False
+ try:
+ await message.add_reaction(emoji)
+ return True
+ except Exception as e:
+ logger.debug("[%s] add_reaction failed (%s): %s", self.name, emoji, e)
+ return False
+
+ async def _remove_reaction(self, message: Any, emoji: str) -> bool:
+ """Remove the bot's own emoji reaction from a Discord message."""
+ if not message or not hasattr(message, "remove_reaction") or not self._client or not self._client.user:
+ return False
+ try:
+ await message.remove_reaction(emoji, self._client.user)
+ return True
+ except Exception as e:
+ logger.debug("[%s] remove_reaction failed (%s): %s", self.name, emoji, e)
+ return False
+
+ async def on_processing_start(self, event: MessageEvent) -> None:
+ """Add an in-progress reaction for normal Discord message events."""
+ message = event.raw_message
+ if hasattr(message, "add_reaction"):
+ await self._add_reaction(message, "👀")
+
+ async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
+ """Swap the in-progress reaction for a final success/failure reaction."""
+ message = event.raw_message
+ if hasattr(message, "add_reaction"):
+ await self._remove_reaction(message, "👀")
+ await self._add_reaction(message, "✅" if success else "❌")
async def send(
self,
diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py
new file mode 100644
index 000000000000..d9aaae9a747f
--- /dev/null
+++ b/gateway/platforms/feishu.py
@@ -0,0 +1,3255 @@
+"""
+Feishu/Lark platform adapter.
+
+Supports:
+- WebSocket long connection and Webhook transport
+- Direct-message and group @mention-gated text receive/send
+- Inbound image/file/audio/media caching
+- Gateway allowlist integration via FEISHU_ALLOWED_USERS
+- Persistent dedup state across restarts
+- Per-chat serial message processing (matches openclaw createChatQueue)
+- Persistent ACK emoji reaction on inbound messages
+- Reaction events routed as synthetic text events (matches openclaw)
+- Interactive card button-click events routed as synthetic COMMAND events
+- Webhook anomaly tracking (matches openclaw createWebhookAnomalyTracker)
+- Verification token validation as second auth layer (matches openclaw)
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import hmac
+import json
+import logging
+import mimetypes
+import os
+import re
+import threading
+import time
+import uuid
+from dataclasses import dataclass, field
+from datetime import datetime
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any, Dict, List, Optional
+
+# aiohttp/websockets are independent optional deps — import outside lark_oapi
+# so they remain available for tests and webhook mode even if lark_oapi is missing.
+try:
+ import aiohttp
+ from aiohttp import web
+except ImportError:
+ aiohttp = None # type: ignore[assignment]
+ web = None # type: ignore[assignment]
+
+try:
+ import websockets
+except ImportError:
+ websockets = None # type: ignore[assignment]
+
+try:
+ import lark_oapi as lark
+ from lark_oapi.api.application.v6 import GetApplicationRequest
+ from lark_oapi.api.im.v1 import (
+ CreateFileRequest,
+ CreateFileRequestBody,
+ CreateImageRequest,
+ CreateImageRequestBody,
+ CreateMessageRequest,
+ CreateMessageRequestBody,
+ GetChatRequest,
+ GetMessageRequest,
+ GetImageRequest,
+ GetMessageResourceRequest,
+ P2ImMessageMessageReadV1,
+ ReplyMessageRequest,
+ ReplyMessageRequestBody,
+ UpdateMessageRequest,
+ UpdateMessageRequestBody,
+ )
+ from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
+ from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
+ from lark_oapi.event.dispatcher_handler import EventDispatcherHandler
+ from lark_oapi.ws import Client as FeishuWSClient
+
+ FEISHU_AVAILABLE = True
+except ImportError:
+ FEISHU_AVAILABLE = False
+ lark = None # type: ignore[assignment]
+ P2CardActionTriggerResponse = None # type: ignore[assignment]
+ EventDispatcherHandler = None # type: ignore[assignment]
+ FeishuWSClient = None # type: ignore[assignment]
+ FEISHU_DOMAIN = None # type: ignore[assignment]
+ LARK_DOMAIN = None # type: ignore[assignment]
+
+FEISHU_WEBSOCKET_AVAILABLE = websockets is not None
+FEISHU_WEBHOOK_AVAILABLE = aiohttp is not None
+
+from gateway.config import Platform, PlatformConfig
+from gateway.platforms.base import (
+ BasePlatformAdapter,
+ MessageEvent,
+ MessageType,
+ SendResult,
+ SUPPORTED_DOCUMENT_TYPES,
+ cache_document_from_bytes,
+ cache_image_from_url,
+ cache_audio_from_bytes,
+ cache_image_from_bytes,
+)
+from gateway.status import acquire_scoped_lock, release_scoped_lock
+from hermes_constants import get_hermes_home
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Regex patterns
+# ---------------------------------------------------------------------------
+
+_MARKDOWN_HINT_RE = re.compile(
+ r"(^#{1,6}\s)|(^\s*[-*]\s)|(^\s*\d+\.\s)|(^\s*---+\s*$)|(```)|(`[^`\n]+`)|(\*\*[^*\n].+?\*\*)|(~~[^~\n].+?~~)|(.+?)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)]+\))|(^>\s)",
+ re.MULTILINE,
+)
+_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
+_MENTION_RE = re.compile(r"@_user_\d+")
+_MULTISPACE_RE = re.compile(r"[ \t]{2,}")
+_POST_CONTENT_INVALID_RE = re.compile(r"content format of the post type is incorrect", re.IGNORECASE)
+# ---------------------------------------------------------------------------
+# Media type sets and upload constants
+# ---------------------------------------------------------------------------
+
+_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
+_AUDIO_EXTENSIONS = {".ogg", ".mp3", ".wav", ".m4a", ".aac", ".flac", ".opus", ".webm"}
+_VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".3gp"}
+_DOCUMENT_MIME_TO_EXT = {mime: ext for ext, mime in SUPPORTED_DOCUMENT_TYPES.items()}
+_FEISHU_IMAGE_UPLOAD_TYPE = "message"
+_FEISHU_FILE_UPLOAD_TYPE = "stream"
+_FEISHU_OPUS_UPLOAD_EXTENSIONS = {".ogg", ".opus"}
+_FEISHU_MEDIA_UPLOAD_EXTENSIONS = {".mp4", ".mov", ".avi", ".m4v"}
+_FEISHU_DOC_UPLOAD_TYPES = {
+ ".pdf": "pdf",
+ ".doc": "doc",
+ ".docx": "doc",
+ ".xls": "xls",
+ ".xlsx": "xls",
+ ".ppt": "ppt",
+ ".pptx": "ppt",
+}
+# ---------------------------------------------------------------------------
+# Connection, retry and batching tuning
+# ---------------------------------------------------------------------------
+
+_MAX_TEXT_INJECT_BYTES = 100 * 1024
+_FEISHU_CONNECT_ATTEMPTS = 3
+_FEISHU_SEND_ATTEMPTS = 3
+_FEISHU_APP_LOCK_SCOPE = "feishu-app-id"
+_DEFAULT_TEXT_BATCH_DELAY_SECONDS = 0.6
+_DEFAULT_TEXT_BATCH_MAX_MESSAGES = 8
+_DEFAULT_TEXT_BATCH_MAX_CHARS = 4000
+_DEFAULT_MEDIA_BATCH_DELAY_SECONDS = 0.8
+_DEFAULT_DEDUP_CACHE_SIZE = 2048
+_DEFAULT_WEBHOOK_HOST = "127.0.0.1"
+_DEFAULT_WEBHOOK_PORT = 8765
+_DEFAULT_WEBHOOK_PATH = "/feishu/webhook"
+# ---------------------------------------------------------------------------
+# TTL, rate-limit and webhook security constants
+# ---------------------------------------------------------------------------
+
+_FEISHU_DEDUP_TTL_SECONDS = 24 * 60 * 60 # 24 hours — matches openclaw
+_FEISHU_SENDER_NAME_TTL_SECONDS = 10 * 60 # 10 minutes sender-name cache
+_FEISHU_WEBHOOK_MAX_BODY_BYTES = 1 * 1024 * 1024 # 1 MB body limit
+_FEISHU_WEBHOOK_RATE_WINDOW_SECONDS = 60 # sliding window for rate limiter
+_FEISHU_WEBHOOK_RATE_LIMIT_MAX = 120 # max requests per window per IP — matches openclaw
+_FEISHU_WEBHOOK_RATE_MAX_KEYS = 4096 # max tracked keys (prevents unbounded growth)
+_FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS = 30 # max seconds to read request body
+_FEISHU_WEBHOOK_ANOMALY_THRESHOLD = 25 # consecutive error responses before WARNING log
+_FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS = 6 * 60 * 60 # anomaly tracker TTL (6 hours) — matches openclaw
+_FEISHU_CARD_ACTION_DEDUP_TTL_SECONDS = 15 * 60 # card action token dedup window (15 min)
+_FEISHU_BOT_MSG_TRACK_SIZE = 512 # LRU size for tracking sent message IDs
+_FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003}) # reply target withdrawn/missing → create fallback
+_FEISHU_ACK_EMOJI = "OK"
+# ---------------------------------------------------------------------------
+# Fallback display strings
+# ---------------------------------------------------------------------------
+
+FALLBACK_POST_TEXT = "[Rich text message]"
+FALLBACK_FORWARD_TEXT = "[Merged forward message]"
+FALLBACK_SHARE_CHAT_TEXT = "[Shared chat]"
+FALLBACK_INTERACTIVE_TEXT = "[Interactive message]"
+FALLBACK_IMAGE_TEXT = "[Image]"
+FALLBACK_ATTACHMENT_TEXT = "[Attachment]"
+# ---------------------------------------------------------------------------
+# Post/card parsing helpers
+# ---------------------------------------------------------------------------
+
+_PREFERRED_LOCALES = ("zh_cn", "en_us")
+_MARKDOWN_SPECIAL_CHARS_RE = re.compile(r"([\\`*_{}\[\]()#+\-!|>~])")
+_MENTION_PLACEHOLDER_RE = re.compile(r"@_user_\d+")
+_WHITESPACE_RE = re.compile(r"\s+")
+_SUPPORTED_CARD_TEXT_KEYS = (
+ "title",
+ "text",
+ "content",
+ "label",
+ "value",
+ "name",
+ "summary",
+ "subtitle",
+ "description",
+ "placeholder",
+ "hint",
+)
+_SKIP_TEXT_KEYS = {
+ "tag",
+ "type",
+ "msg_type",
+ "message_type",
+ "chat_id",
+ "open_chat_id",
+ "share_chat_id",
+ "file_key",
+ "image_key",
+ "user_id",
+ "open_id",
+ "union_id",
+ "url",
+ "href",
+ "link",
+ "token",
+ "template",
+ "locale",
+}
+
+
+@dataclass(frozen=True)
+class FeishuPostMediaRef:
+ file_key: str
+ file_name: str = ""
+ resource_type: str = "file"
+
+
+@dataclass(frozen=True)
+class FeishuPostParseResult:
+ text_content: str
+ image_keys: List[str] = field(default_factory=list)
+ media_refs: List[FeishuPostMediaRef] = field(default_factory=list)
+ mentioned_ids: List[str] = field(default_factory=list)
+
+
+@dataclass(frozen=True)
+class FeishuNormalizedMessage:
+ raw_type: str
+ text_content: str
+ preferred_message_type: str = "text"
+ image_keys: List[str] = field(default_factory=list)
+ media_refs: List[FeishuPostMediaRef] = field(default_factory=list)
+ mentioned_ids: List[str] = field(default_factory=list)
+ relation_kind: str = "plain"
+ metadata: Dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass(frozen=True)
+class FeishuAdapterSettings:
+ app_id: str
+ app_secret: str
+ domain_name: str
+ connection_mode: str
+ encrypt_key: str
+ verification_token: str
+ group_policy: str
+ allowed_group_users: frozenset[str]
+ bot_open_id: str
+ bot_user_id: str
+ bot_name: str
+ dedup_cache_size: int
+ text_batch_delay_seconds: float
+ text_batch_max_messages: int
+ text_batch_max_chars: int
+ media_batch_delay_seconds: float
+ webhook_host: str
+ webhook_port: int
+ webhook_path: str
+
+
+@dataclass
+class FeishuBatchState:
+ events: Dict[str, MessageEvent] = field(default_factory=dict)
+ tasks: Dict[str, asyncio.Task] = field(default_factory=dict)
+ counts: Dict[str, int] = field(default_factory=dict)
+
+
+# ---------------------------------------------------------------------------
+# Markdown rendering helpers
+# ---------------------------------------------------------------------------
+
+
+def _escape_markdown_text(text: str) -> str:
+ return _MARKDOWN_SPECIAL_CHARS_RE.sub(r"\\\1", text)
+
+
+def _to_boolean(value: Any) -> bool:
+ return value is True or value == 1 or value == "true"
+
+
+def _is_style_enabled(style: Dict[str, Any] | None, key: str) -> bool:
+ if not style:
+ return False
+ return _to_boolean(style.get(key))
+
+
+def _wrap_inline_code(text: str) -> str:
+ max_run = max([0, *[len(run) for run in re.findall(r"`+", text)]])
+ fence = "`" * (max_run + 1)
+ body = f" {text} " if text.startswith("`") or text.endswith("`") else text
+ return f"{fence}{body}{fence}"
+
+
+def _sanitize_fence_language(language: str) -> str:
+ return language.strip().replace("\n", " ").replace("\r", " ")
+
+
+def _render_text_element(element: Dict[str, Any]) -> str:
+ text = str(element.get("text", "") or "")
+ style = element.get("style")
+ style_dict = style if isinstance(style, dict) else None
+
+ if _is_style_enabled(style_dict, "code"):
+ return _wrap_inline_code(text)
+
+ rendered = _escape_markdown_text(text)
+ if not rendered:
+ return ""
+ if _is_style_enabled(style_dict, "bold"):
+ rendered = f"**{rendered}**"
+ if _is_style_enabled(style_dict, "italic"):
+ rendered = f"*{rendered}*"
+ if _is_style_enabled(style_dict, "underline"):
+ rendered = f"{rendered}"
+ if _is_style_enabled(style_dict, "strikethrough"):
+ rendered = f"~~{rendered}~~"
+ return rendered
+
+
+def _render_code_block_element(element: Dict[str, Any]) -> str:
+ language = _sanitize_fence_language(
+ str(element.get("language", "") or "") or str(element.get("lang", "") or "")
+ )
+ code = (
+ str(element.get("text", "") or "") or str(element.get("content", "") or "")
+ ).replace("\r\n", "\n")
+ trailing_newline = "" if code.endswith("\n") else "\n"
+ return f"```{language}\n{code}{trailing_newline}```"
+
+
+def _strip_markdown_to_plain_text(text: str) -> str:
+ plain = text.replace("\r\n", "\n")
+ plain = _MARKDOWN_LINK_RE.sub(lambda m: f"{m.group(1)} ({m.group(2).strip()})", plain)
+ plain = re.sub(r"^#{1,6}\s+", "", plain, flags=re.MULTILINE)
+ plain = re.sub(r"^>\s?", "", plain, flags=re.MULTILINE)
+ plain = re.sub(r"^\s*---+\s*$", "---", plain, flags=re.MULTILINE)
+ plain = re.sub(r"```(?:[^\n]*\n)?([\s\S]*?)```", lambda m: m.group(1).strip("\n"), plain)
+ plain = re.sub(r"`([^`\n]+)`", r"\1", plain)
+ plain = re.sub(r"\*\*([^*\n]+)\*\*", r"\1", plain)
+ plain = re.sub(r"\*([^*\n]+)\*", r"\1", plain)
+ plain = re.sub(r"~~([^~\n]+)~~", r"\1", plain)
+ plain = re.sub(r"([\s\S]*?)", r"\1", plain)
+ plain = re.sub(r"\n{3,}", "\n\n", plain)
+ return plain.strip()
+
+
+# ---------------------------------------------------------------------------
+# Post payload builders and parsers
+# ---------------------------------------------------------------------------
+
+
+def _build_markdown_post_payload(content: str) -> str:
+ return json.dumps(
+ {
+ "zh_cn": {
+ "content": [
+ [
+ {
+ "tag": "md",
+ "text": content,
+ }
+ ]
+ ],
+ }
+ },
+ ensure_ascii=False,
+ )
+
+
+def parse_feishu_post_content(raw_content: str) -> FeishuPostParseResult:
+ try:
+ parsed = json.loads(raw_content) if raw_content else {}
+ except json.JSONDecodeError:
+ return FeishuPostParseResult(text_content=FALLBACK_POST_TEXT)
+ return parse_feishu_post_payload(parsed)
+
+
+def parse_feishu_post_payload(payload: Any) -> FeishuPostParseResult:
+ resolved = _resolve_post_payload(payload)
+ if not resolved:
+ return FeishuPostParseResult(text_content=FALLBACK_POST_TEXT)
+
+ image_keys: List[str] = []
+ media_refs: List[FeishuPostMediaRef] = []
+ mentioned_ids: List[str] = []
+ parts: List[str] = []
+
+ title = _normalize_feishu_text(str(resolved.get("title", "")).strip())
+ if title:
+ parts.append(title)
+
+ for row in resolved.get("content", []) or []:
+ if not isinstance(row, list):
+ continue
+ row_text = _normalize_feishu_text(
+ "".join(_render_post_element(item, image_keys, media_refs, mentioned_ids) for item in row)
+ )
+ if row_text:
+ parts.append(row_text)
+
+ return FeishuPostParseResult(
+ text_content="\n".join(parts).strip() or FALLBACK_POST_TEXT,
+ image_keys=image_keys,
+ media_refs=media_refs,
+ mentioned_ids=mentioned_ids,
+ )
+
+
+def _resolve_post_payload(payload: Any) -> Dict[str, Any]:
+ direct = _to_post_payload(payload)
+ if direct:
+ return direct
+ if not isinstance(payload, dict):
+ return {}
+
+ wrapped = payload.get("post")
+ wrapped_direct = _resolve_locale_payload(wrapped)
+ if wrapped_direct:
+ return wrapped_direct
+ return _resolve_locale_payload(payload)
+
+
+def _resolve_locale_payload(payload: Any) -> Dict[str, Any]:
+ direct = _to_post_payload(payload)
+ if direct:
+ return direct
+ if not isinstance(payload, dict):
+ return {}
+
+ for key in _PREFERRED_LOCALES:
+ candidate = _to_post_payload(payload.get(key))
+ if candidate:
+ return candidate
+ for value in payload.values():
+ candidate = _to_post_payload(value)
+ if candidate:
+ return candidate
+ return {}
+
+
+def _to_post_payload(candidate: Any) -> Dict[str, Any]:
+ if not isinstance(candidate, dict):
+ return {}
+ content = candidate.get("content")
+ if not isinstance(content, list):
+ return {}
+ return {
+ "title": str(candidate.get("title", "") or ""),
+ "content": content,
+ }
+
+
+def _render_post_element(
+ element: Any,
+ image_keys: List[str],
+ media_refs: List[FeishuPostMediaRef],
+ mentioned_ids: List[str],
+) -> str:
+ if isinstance(element, str):
+ return element
+ if not isinstance(element, dict):
+ return ""
+
+ tag = str(element.get("tag", "")).strip().lower()
+ if tag == "text":
+ return _render_text_element(element)
+ if tag == "a":
+ href = str(element.get("href", "")).strip()
+ label = str(element.get("text", href) or "").strip()
+ if not label:
+ return ""
+ escaped_label = _escape_markdown_text(label)
+ return f"[{escaped_label}]({href})" if href else escaped_label
+ if tag == "at":
+ mentioned_id = (
+ str(element.get("open_id", "")).strip()
+ or str(element.get("user_id", "")).strip()
+ )
+ if mentioned_id and mentioned_id not in mentioned_ids:
+ mentioned_ids.append(mentioned_id)
+ display_name = (
+ str(element.get("user_name", "")).strip()
+ or str(element.get("name", "")).strip()
+ or str(element.get("text", "")).strip()
+ or mentioned_id
+ )
+ return f"@{_escape_markdown_text(display_name)}" if display_name else "@"
+ if tag in {"img", "image"}:
+ image_key = str(element.get("image_key", "")).strip()
+ if image_key and image_key not in image_keys:
+ image_keys.append(image_key)
+ alt = str(element.get("text", "")).strip() or str(element.get("alt", "")).strip()
+ return f"[Image: {alt}]" if alt else "[Image]"
+ if tag in {"media", "file", "audio", "video"}:
+ file_key = str(element.get("file_key", "")).strip()
+ file_name = (
+ str(element.get("file_name", "")).strip()
+ or str(element.get("title", "")).strip()
+ or str(element.get("text", "")).strip()
+ )
+ if file_key:
+ media_refs.append(
+ FeishuPostMediaRef(
+ file_key=file_key,
+ file_name=file_name,
+ resource_type=tag if tag in {"audio", "video"} else "file",
+ )
+ )
+ return f"[Attachment: {file_name}]" if file_name else "[Attachment]"
+ if tag in {"emotion", "emoji"}:
+ label = str(element.get("text", "")).strip() or str(element.get("emoji_type", "")).strip()
+ return f":{_escape_markdown_text(label)}:" if label else "[Emoji]"
+ if tag == "br":
+ return "\n"
+ if tag in {"hr", "divider"}:
+ return "\n\n---\n\n"
+ if tag == "code":
+ code = str(element.get("text", "") or "") or str(element.get("content", "") or "")
+ return _wrap_inline_code(code) if code else ""
+ if tag in {"code_block", "pre"}:
+ return _render_code_block_element(element)
+
+ nested_parts: List[str] = []
+ for key in ("text", "title", "content", "children", "elements"):
+ value = element.get(key)
+ extracted = _render_nested_post(value, image_keys, media_refs, mentioned_ids)
+ if extracted:
+ nested_parts.append(extracted)
+ return " ".join(part for part in nested_parts if part)
+
+
+def _render_nested_post(
+ value: Any,
+ image_keys: List[str],
+ media_refs: List[FeishuPostMediaRef],
+ mentioned_ids: List[str],
+) -> str:
+ if isinstance(value, str):
+ return _escape_markdown_text(value)
+ if isinstance(value, list):
+ return " ".join(
+ part
+ for item in value
+ for part in [_render_nested_post(item, image_keys, media_refs, mentioned_ids)]
+ if part
+ )
+ if isinstance(value, dict):
+ direct = _render_post_element(value, image_keys, media_refs, mentioned_ids)
+ if direct:
+ return direct
+ return " ".join(
+ part
+ for item in value.values()
+ for part in [_render_nested_post(item, image_keys, media_refs, mentioned_ids)]
+ if part
+ )
+ return ""
+
+
+# ---------------------------------------------------------------------------
+# Message normalization
+# ---------------------------------------------------------------------------
+
+
+def normalize_feishu_message(*, message_type: str, raw_content: str) -> FeishuNormalizedMessage:
+ normalized_type = str(message_type or "").strip().lower()
+ payload = _load_feishu_payload(raw_content)
+
+ if normalized_type == "text":
+ return FeishuNormalizedMessage(
+ raw_type=normalized_type,
+ text_content=_normalize_feishu_text(str(payload.get("text", "") or "")),
+ )
+ if normalized_type == "post":
+ parsed_post = parse_feishu_post_payload(payload)
+ return FeishuNormalizedMessage(
+ raw_type=normalized_type,
+ text_content=parsed_post.text_content,
+ image_keys=list(parsed_post.image_keys),
+ media_refs=list(parsed_post.media_refs),
+ mentioned_ids=list(parsed_post.mentioned_ids),
+ relation_kind="post",
+ )
+ if normalized_type == "image":
+ image_key = str(payload.get("image_key", "") or "").strip()
+ alt_text = _normalize_feishu_text(
+ str(payload.get("text", "") or "")
+ or str(payload.get("alt", "") or "")
+ or FALLBACK_IMAGE_TEXT
+ )
+ return FeishuNormalizedMessage(
+ raw_type=normalized_type,
+ text_content=alt_text if alt_text != FALLBACK_IMAGE_TEXT else "",
+ preferred_message_type="photo",
+ image_keys=[image_key] if image_key else [],
+ relation_kind="image",
+ )
+ if normalized_type in {"file", "audio", "media"}:
+ media_ref = _build_media_ref_from_payload(payload, resource_type=normalized_type)
+ placeholder = _attachment_placeholder(media_ref.file_name)
+ return FeishuNormalizedMessage(
+ raw_type=normalized_type,
+ text_content="",
+ preferred_message_type="audio" if normalized_type == "audio" else "document",
+ media_refs=[media_ref] if media_ref.file_key else [],
+ relation_kind=normalized_type,
+ metadata={"placeholder_text": placeholder},
+ )
+ if normalized_type == "merge_forward":
+ return _normalize_merge_forward_message(payload)
+ if normalized_type == "share_chat":
+ return _normalize_share_chat_message(payload)
+ if normalized_type in {"interactive", "card"}:
+ return _normalize_interactive_message(normalized_type, payload)
+
+ return FeishuNormalizedMessage(raw_type=normalized_type, text_content="")
+
+
+def _load_feishu_payload(raw_content: str) -> Dict[str, Any]:
+ try:
+ parsed = json.loads(raw_content) if raw_content else {}
+ except json.JSONDecodeError:
+ return {"text": raw_content}
+ return parsed if isinstance(parsed, dict) else {"content": parsed}
+
+
+def _normalize_merge_forward_message(payload: Dict[str, Any]) -> FeishuNormalizedMessage:
+ title = _first_non_empty_text(
+ payload.get("title"),
+ payload.get("summary"),
+ payload.get("preview"),
+ _find_first_text(payload, keys=("title", "summary", "preview", "description")),
+ )
+ entries = _collect_forward_entries(payload)
+ lines: List[str] = []
+ if title:
+ lines.append(title)
+ lines.extend(entries[:8])
+ text_content = "\n".join(lines).strip() or FALLBACK_FORWARD_TEXT
+ return FeishuNormalizedMessage(
+ raw_type="merge_forward",
+ text_content=text_content,
+ relation_kind="merge_forward",
+ metadata={"entry_count": len(entries), "title": title},
+ )
+
+
+def _normalize_share_chat_message(payload: Dict[str, Any]) -> FeishuNormalizedMessage:
+ chat_name = _first_non_empty_text(
+ payload.get("chat_name"),
+ payload.get("name"),
+ payload.get("title"),
+ _find_first_text(payload, keys=("chat_name", "name", "title")),
+ )
+ share_id = _first_non_empty_text(
+ payload.get("chat_id"),
+ payload.get("open_chat_id"),
+ payload.get("share_chat_id"),
+ )
+ lines = []
+ if chat_name:
+ lines.append(f"Shared chat: {chat_name}")
+ else:
+ lines.append(FALLBACK_SHARE_CHAT_TEXT)
+ if share_id:
+ lines.append(f"Chat ID: {share_id}")
+ text_content = "\n".join(lines)
+ return FeishuNormalizedMessage(
+ raw_type="share_chat",
+ text_content=text_content,
+ relation_kind="share_chat",
+ metadata={"chat_id": share_id, "chat_name": chat_name},
+ )
+
+
+def _normalize_interactive_message(message_type: str, payload: Dict[str, Any]) -> FeishuNormalizedMessage:
+ card_payload = payload.get("card") if isinstance(payload.get("card"), dict) else payload
+ title = _first_non_empty_text(
+ _find_header_title(card_payload),
+ payload.get("title"),
+ _find_first_text(card_payload, keys=("title", "summary", "subtitle")),
+ )
+ body_lines = _collect_card_lines(card_payload)
+ actions = _collect_action_labels(card_payload)
+
+ lines: List[str] = []
+ if title:
+ lines.append(title)
+ for line in body_lines:
+ if line != title:
+ lines.append(line)
+ if actions:
+ lines.append(f"Actions: {', '.join(actions)}")
+
+ text_content = "\n".join(lines[:12]).strip() or FALLBACK_INTERACTIVE_TEXT
+ return FeishuNormalizedMessage(
+ raw_type=message_type,
+ text_content=text_content,
+ relation_kind="interactive",
+ metadata={"title": title, "actions": actions},
+ )
+
+
+# ---------------------------------------------------------------------------
+# Content extraction utilities (card / forward / text walking)
+# ---------------------------------------------------------------------------
+
+
+def _collect_forward_entries(payload: Dict[str, Any]) -> List[str]:
+ candidates: List[Any] = []
+ for key in ("messages", "items", "message_list", "records", "content"):
+ value = payload.get(key)
+ if isinstance(value, list):
+ candidates.extend(value)
+ entries: List[str] = []
+ for item in candidates:
+ if not isinstance(item, dict):
+ text = _normalize_feishu_text(str(item or ""))
+ if text:
+ entries.append(f"- {text}")
+ continue
+ sender = _first_non_empty_text(
+ item.get("sender_name"),
+ item.get("user_name"),
+ item.get("sender"),
+ item.get("name"),
+ )
+ nested_type = str(item.get("message_type", "") or item.get("msg_type", "")).strip().lower()
+ if nested_type == "post":
+ body = parse_feishu_post_payload(item.get("content") or item).text_content
+ else:
+ body = _first_non_empty_text(
+ item.get("text"),
+ item.get("summary"),
+ item.get("preview"),
+ item.get("content"),
+ _find_first_text(item, keys=("text", "content", "summary", "preview", "title")),
+ )
+ body = _normalize_feishu_text(body)
+ if sender and body:
+ entries.append(f"- {sender}: {body}")
+ elif body:
+ entries.append(f"- {body}")
+ return _unique_lines(entries)
+
+
+def _collect_card_lines(payload: Any) -> List[str]:
+ lines = _collect_text_segments(payload, in_rich_block=False)
+ normalized = [_normalize_feishu_text(line) for line in lines]
+ return _unique_lines([line for line in normalized if line])
+
+
+def _collect_action_labels(payload: Any) -> List[str]:
+ labels: List[str] = []
+ for item in _walk_nodes(payload):
+ if not isinstance(item, dict):
+ continue
+ tag = str(item.get("tag", "") or item.get("type", "")).strip().lower()
+ if tag not in {"button", "select_static", "overflow", "date_picker", "picker"}:
+ continue
+ label = _first_non_empty_text(
+ item.get("text"),
+ item.get("name"),
+ item.get("value"),
+ _find_first_text(item, keys=("text", "content", "name", "value")),
+ )
+ if label:
+ labels.append(label)
+ return _unique_lines(labels)
+
+
+def _collect_text_segments(value: Any, *, in_rich_block: bool) -> List[str]:
+ if isinstance(value, str):
+ return [_normalize_feishu_text(value)] if in_rich_block else []
+ if isinstance(value, list):
+ segments: List[str] = []
+ for item in value:
+ segments.extend(_collect_text_segments(item, in_rich_block=in_rich_block))
+ return segments
+ if not isinstance(value, dict):
+ return []
+
+ tag = str(value.get("tag", "") or value.get("type", "")).strip().lower()
+ next_in_rich_block = in_rich_block or tag in {
+ "plain_text",
+ "lark_md",
+ "markdown",
+ "note",
+ "div",
+ "column_set",
+ "column",
+ "action",
+ "button",
+ "select_static",
+ "date_picker",
+ }
+
+ segments: List[str] = []
+ for key in _SUPPORTED_CARD_TEXT_KEYS:
+ item = value.get(key)
+ if isinstance(item, str) and next_in_rich_block:
+ normalized = _normalize_feishu_text(item)
+ if normalized:
+ segments.append(normalized)
+
+ for key, item in value.items():
+ if key in _SKIP_TEXT_KEYS:
+ continue
+ segments.extend(_collect_text_segments(item, in_rich_block=next_in_rich_block))
+ return segments
+
+
+def _build_media_ref_from_payload(payload: Dict[str, Any], *, resource_type: str) -> FeishuPostMediaRef:
+ file_key = str(payload.get("file_key", "") or "").strip()
+ file_name = _first_non_empty_text(
+ payload.get("file_name"),
+ payload.get("title"),
+ payload.get("text"),
+ )
+ effective_type = resource_type if resource_type in {"audio", "video"} else "file"
+ return FeishuPostMediaRef(file_key=file_key, file_name=file_name, resource_type=effective_type)
+
+
+def _attachment_placeholder(file_name: str) -> str:
+ normalized_name = _normalize_feishu_text(file_name)
+ return f"[Attachment: {normalized_name}]" if normalized_name else FALLBACK_ATTACHMENT_TEXT
+
+
+def _find_header_title(payload: Any) -> str:
+ if not isinstance(payload, dict):
+ return ""
+ header = payload.get("header")
+ if not isinstance(header, dict):
+ return ""
+ title = header.get("title")
+ if isinstance(title, dict):
+ return _first_non_empty_text(title.get("content"), title.get("text"), title.get("name"))
+ return _normalize_feishu_text(str(title or ""))
+
+
+def _find_first_text(payload: Any, *, keys: tuple[str, ...]) -> str:
+ for node in _walk_nodes(payload):
+ if not isinstance(node, dict):
+ continue
+ for key in keys:
+ value = node.get(key)
+ if isinstance(value, str):
+ normalized = _normalize_feishu_text(value)
+ if normalized:
+ return normalized
+ return ""
+
+
+def _walk_nodes(value: Any):
+ if isinstance(value, dict):
+ yield value
+ for item in value.values():
+ yield from _walk_nodes(item)
+ elif isinstance(value, list):
+ for item in value:
+ yield from _walk_nodes(item)
+
+
+def _first_non_empty_text(*values: Any) -> str:
+ for value in values:
+ if isinstance(value, str):
+ normalized = _normalize_feishu_text(value)
+ if normalized:
+ return normalized
+ elif value is not None and not isinstance(value, (dict, list)):
+ normalized = _normalize_feishu_text(str(value))
+ if normalized:
+ return normalized
+ return ""
+
+
+# ---------------------------------------------------------------------------
+# General text utilities
+# ---------------------------------------------------------------------------
+
+
+def _normalize_feishu_text(text: str) -> str:
+ cleaned = _MENTION_PLACEHOLDER_RE.sub(" ", text or "")
+ cleaned = cleaned.replace("\r\n", "\n").replace("\r", "\n")
+ cleaned = "\n".join(_WHITESPACE_RE.sub(" ", line).strip() for line in cleaned.split("\n"))
+ cleaned = "\n".join(line for line in cleaned.split("\n") if line)
+ cleaned = _MULTISPACE_RE.sub(" ", cleaned)
+ return cleaned.strip()
+
+
+def _unique_lines(lines: List[str]) -> List[str]:
+ seen: set[str] = set()
+ unique: List[str] = []
+ for line in lines:
+ if not line or line in seen:
+ continue
+ seen.add(line)
+ unique.append(line)
+ return unique
+
+
+def _run_official_feishu_ws_client(ws_client: Any) -> None:
+ """Run the official Lark WS client in its own thread-local event loop."""
+ import lark_oapi.ws.client as ws_client_module
+
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ ws_client_module.loop = loop
+ ws_client.start()
+
+
+def check_feishu_requirements() -> bool:
+ """Check if Feishu/Lark dependencies are available."""
+ return FEISHU_AVAILABLE
+
+
+class FeishuAdapter(BasePlatformAdapter):
+ """Feishu/Lark bot adapter."""
+
+ MAX_MESSAGE_LENGTH = 8000
+
+ # =========================================================================
+ # Lifecycle — init / settings / connect / disconnect
+ # =========================================================================
+
+ def __init__(self, config: PlatformConfig):
+ super().__init__(config, Platform.FEISHU)
+
+ self._settings = self._load_settings(config.extra or {})
+ self._apply_settings(self._settings)
+ self._client: Optional[Any] = None
+ self._ws_client: Optional[Any] = None
+ self._ws_future: Optional[asyncio.Future] = None
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
+ self._webhook_runner: Optional[Any] = None
+ self._webhook_site: Optional[Any] = None
+ self._event_handler = self._build_event_handler()
+ self._seen_message_ids: Dict[str, float] = {} # message_id → seen_at (time.time())
+ self._seen_message_order: List[str] = []
+ self._dedup_state_path = get_hermes_home() / "feishu_seen_message_ids.json"
+ self._dedup_lock = threading.Lock()
+ self._sender_name_cache: Dict[str, tuple[str, float]] = {} # sender_id → (name, expire_at)
+ self._webhook_rate_counts: Dict[str, tuple[int, float]] = {} # rate_key → (count, window_start)
+ self._webhook_anomaly_counts: Dict[str, tuple[int, str, float]] = {} # ip → (count, last_status, first_seen)
+ self._card_action_tokens: Dict[str, float] = {} # token → first_seen_time
+ self._chat_locks: Dict[str, asyncio.Lock] = {} # chat_id → lock (per-chat serial processing)
+ self._sent_message_ids_to_chat: Dict[str, str] = {} # message_id → chat_id (for reaction routing)
+ self._sent_message_id_order: List[str] = [] # LRU order for _sent_message_ids_to_chat
+ self._chat_info_cache: Dict[str, Dict[str, Any]] = {}
+ self._message_text_cache: Dict[str, Optional[str]] = {}
+ self._app_lock_identity: Optional[str] = None
+ self._text_batch_state = FeishuBatchState()
+ self._pending_text_batches = self._text_batch_state.events
+ self._pending_text_batch_tasks = self._text_batch_state.tasks
+ self._pending_text_batch_counts = self._text_batch_state.counts
+ self._media_batch_state = FeishuBatchState()
+ self._pending_media_batches = self._media_batch_state.events
+ self._pending_media_batch_tasks = self._media_batch_state.tasks
+ self._load_seen_message_ids()
+
+ @staticmethod
+ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings:
+ return FeishuAdapterSettings(
+ app_id=str(extra.get("app_id") or os.getenv("FEISHU_APP_ID", "")).strip(),
+ app_secret=str(extra.get("app_secret") or os.getenv("FEISHU_APP_SECRET", "")).strip(),
+ domain_name=str(extra.get("domain") or os.getenv("FEISHU_DOMAIN", "feishu")).strip().lower(),
+ connection_mode=str(
+ extra.get("connection_mode") or os.getenv("FEISHU_CONNECTION_MODE", "websocket")
+ ).strip().lower(),
+ encrypt_key=os.getenv("FEISHU_ENCRYPT_KEY", "").strip(),
+ verification_token=os.getenv("FEISHU_VERIFICATION_TOKEN", "").strip(),
+ group_policy=os.getenv("FEISHU_GROUP_POLICY", "allowlist").strip().lower(),
+ allowed_group_users=frozenset(
+ item.strip()
+ for item in os.getenv("FEISHU_ALLOWED_USERS", "").split(",")
+ if item.strip()
+ ),
+ bot_open_id=os.getenv("FEISHU_BOT_OPEN_ID", "").strip(),
+ bot_user_id=os.getenv("FEISHU_BOT_USER_ID", "").strip(),
+ bot_name=os.getenv("FEISHU_BOT_NAME", "").strip(),
+ dedup_cache_size=max(
+ 32,
+ int(os.getenv("HERMES_FEISHU_DEDUP_CACHE_SIZE", str(_DEFAULT_DEDUP_CACHE_SIZE))),
+ ),
+ text_batch_delay_seconds=float(
+ os.getenv("HERMES_FEISHU_TEXT_BATCH_DELAY_SECONDS", str(_DEFAULT_TEXT_BATCH_DELAY_SECONDS))
+ ),
+ text_batch_max_messages=max(
+ 1,
+ int(os.getenv("HERMES_FEISHU_TEXT_BATCH_MAX_MESSAGES", str(_DEFAULT_TEXT_BATCH_MAX_MESSAGES))),
+ ),
+ text_batch_max_chars=max(
+ 1,
+ int(os.getenv("HERMES_FEISHU_TEXT_BATCH_MAX_CHARS", str(_DEFAULT_TEXT_BATCH_MAX_CHARS))),
+ ),
+ media_batch_delay_seconds=float(
+ os.getenv("HERMES_FEISHU_MEDIA_BATCH_DELAY_SECONDS", str(_DEFAULT_MEDIA_BATCH_DELAY_SECONDS))
+ ),
+ webhook_host=str(
+ extra.get("webhook_host") or os.getenv("FEISHU_WEBHOOK_HOST", _DEFAULT_WEBHOOK_HOST)
+ ).strip(),
+ webhook_port=int(
+ extra.get("webhook_port") or os.getenv("FEISHU_WEBHOOK_PORT", str(_DEFAULT_WEBHOOK_PORT))
+ ),
+ webhook_path=(
+ str(extra.get("webhook_path") or os.getenv("FEISHU_WEBHOOK_PATH", _DEFAULT_WEBHOOK_PATH)).strip()
+ or _DEFAULT_WEBHOOK_PATH
+ ),
+ )
+
+ def _apply_settings(self, settings: FeishuAdapterSettings) -> None:
+ self._app_id = settings.app_id
+ self._app_secret = settings.app_secret
+ self._domain_name = settings.domain_name
+ self._connection_mode = settings.connection_mode
+ self._encrypt_key = settings.encrypt_key
+ self._verification_token = settings.verification_token
+ self._group_policy = settings.group_policy
+ self._allowed_group_users = set(settings.allowed_group_users)
+ self._bot_open_id = settings.bot_open_id
+ self._bot_user_id = settings.bot_user_id
+ self._bot_name = settings.bot_name
+ self._dedup_cache_size = settings.dedup_cache_size
+ self._text_batch_delay_seconds = settings.text_batch_delay_seconds
+ self._text_batch_max_messages = settings.text_batch_max_messages
+ self._text_batch_max_chars = settings.text_batch_max_chars
+ self._media_batch_delay_seconds = settings.media_batch_delay_seconds
+ self._webhook_host = settings.webhook_host
+ self._webhook_port = settings.webhook_port
+ self._webhook_path = settings.webhook_path
+
+ def _build_event_handler(self) -> Any:
+ if EventDispatcherHandler is None:
+ return None
+ return (
+ EventDispatcherHandler.builder(
+ self._encrypt_key,
+ self._verification_token,
+ )
+ .register_p2_im_message_message_read_v1(self._on_message_read_event)
+ .register_p2_im_message_receive_v1(self._on_message_event)
+ .register_p2_im_message_reaction_created_v1(
+ lambda data: self._on_reaction_event("im.message.reaction.created_v1", data)
+ )
+ .register_p2_im_message_reaction_deleted_v1(
+ lambda data: self._on_reaction_event("im.message.reaction.deleted_v1", data)
+ )
+ .register_p2_card_action_trigger(self._on_card_action_trigger)
+ .build()
+ )
+
+ async def connect(self) -> bool:
+ """Connect to Feishu/Lark."""
+ if not FEISHU_AVAILABLE:
+ logger.error("[Feishu] lark-oapi not installed")
+ return False
+ if not self._app_id or not self._app_secret:
+ logger.error("[Feishu] FEISHU_APP_ID or FEISHU_APP_SECRET not set")
+ return False
+ if self._connection_mode not in {"websocket", "webhook"}:
+ logger.error(
+ "[Feishu] Unsupported FEISHU_CONNECTION_MODE=%s. Supported modes: websocket, webhook.",
+ self._connection_mode,
+ )
+ return False
+
+ try:
+ self._app_lock_identity = self._app_id
+ acquired, existing = acquire_scoped_lock(
+ _FEISHU_APP_LOCK_SCOPE,
+ self._app_lock_identity,
+ metadata={"platform": self.platform.value},
+ )
+ if not acquired:
+ owner_pid = existing.get("pid") if isinstance(existing, dict) else None
+ message = (
+ "Another local Hermes gateway is already using this Feishu app_id"
+ + (f" (PID {owner_pid})." if owner_pid else ".")
+ + " Stop the other gateway before starting a second Feishu websocket client."
+ )
+ logger.error("[Feishu] %s", message)
+ self._set_fatal_error("feishu_app_lock", message, retryable=False)
+ return False
+
+ self._loop = asyncio.get_running_loop()
+ await self._connect_with_retry()
+ self._mark_connected()
+ logger.info("[Feishu] Connected in %s mode (%s)", self._connection_mode, self._domain_name)
+ return True
+ except Exception as exc:
+ await self._release_app_lock()
+ message = f"Feishu startup failed: {exc}"
+ self._set_fatal_error("feishu_connect_error", message, retryable=True)
+ logger.error("[Feishu] Failed to connect: %s", exc, exc_info=True)
+ return False
+
+ async def disconnect(self) -> None:
+ """Disconnect from Feishu/Lark."""
+ self._running = False
+ await self._cancel_pending_tasks(self._pending_text_batch_tasks)
+ await self._cancel_pending_tasks(self._pending_media_batch_tasks)
+ self._reset_batch_buffers()
+ self._disable_websocket_auto_reconnect()
+ await self._stop_webhook_server()
+ self._ws_future = None
+ self._loop = None
+ self._persist_seen_message_ids()
+ await self._release_app_lock()
+
+ self._mark_disconnected()
+ logger.info("[Feishu] Disconnected")
+
+ async def _cancel_pending_tasks(self, tasks: Dict[str, asyncio.Task]) -> None:
+ pending = [task for task in tasks.values() if task and not task.done()]
+ for task in pending:
+ task.cancel()
+ if pending:
+ await asyncio.gather(*pending, return_exceptions=True)
+ tasks.clear()
+
+ def _reset_batch_buffers(self) -> None:
+ self._pending_text_batches.clear()
+ self._pending_text_batch_counts.clear()
+ self._pending_media_batches.clear()
+
+ def _disable_websocket_auto_reconnect(self) -> None:
+ if self._ws_client is None:
+ return
+ try:
+ setattr(self._ws_client, "_auto_reconnect", False)
+ except Exception:
+ pass
+ finally:
+ self._ws_client = None
+
+ async def _stop_webhook_server(self) -> None:
+ if self._webhook_runner is None:
+ return
+ try:
+ await self._webhook_runner.cleanup()
+ finally:
+ self._webhook_runner = None
+ self._webhook_site = None
+
+ # =========================================================================
+ # Outbound — send / edit / send_image / send_voice / …
+ # =========================================================================
+
+ async def send(
+ self,
+ chat_id: str,
+ content: str,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Send a Feishu message."""
+ if not self._client:
+ return SendResult(success=False, error="Not connected")
+
+ formatted = self.format_message(content)
+ chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
+ last_response = None
+
+ try:
+ for chunk in chunks:
+ msg_type, payload = self._build_outbound_payload(chunk)
+ try:
+ response = await self._feishu_send_with_retry(
+ chat_id=chat_id,
+ msg_type=msg_type,
+ payload=payload,
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ except Exception as exc:
+ if msg_type != "post" or not _POST_CONTENT_INVALID_RE.search(str(exc)):
+ raise
+ logger.warning("[Feishu] Invalid post payload rejected by API; falling back to plain text")
+ response = await self._feishu_send_with_retry(
+ chat_id=chat_id,
+ msg_type="text",
+ payload=json.dumps({"text": _strip_markdown_to_plain_text(chunk)}, ensure_ascii=False),
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ if (
+ msg_type == "post"
+ and not self._response_succeeded(response)
+ and _POST_CONTENT_INVALID_RE.search(str(getattr(response, "msg", "") or ""))
+ ):
+ logger.warning("[Feishu] Post payload rejected by API response; falling back to plain text")
+ response = await self._feishu_send_with_retry(
+ chat_id=chat_id,
+ msg_type="text",
+ payload=json.dumps({"text": _strip_markdown_to_plain_text(chunk)}, ensure_ascii=False),
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ last_response = response
+
+ return self._finalize_send_result(last_response, "send failed")
+ except Exception as exc:
+ logger.error("[Feishu] Send error: %s", exc, exc_info=True)
+ return SendResult(success=False, error=str(exc))
+
+ async def edit_message(
+ self,
+ chat_id: str,
+ message_id: str,
+ content: str,
+ ) -> SendResult:
+ """Edit a previously sent Feishu text/post message."""
+ if not self._client:
+ return SendResult(success=False, error="Not connected")
+
+ try:
+ msg_type, payload = self._build_outbound_payload(content)
+ body = self._build_update_message_body(msg_type=msg_type, content=payload)
+ request = self._build_update_message_request(message_id=message_id, request_body=body)
+ response = await asyncio.to_thread(self._client.im.v1.message.update, request)
+ result = self._finalize_send_result(response, "update failed")
+ if not result.success and msg_type == "post" and _POST_CONTENT_INVALID_RE.search(result.error or ""):
+ logger.warning("[Feishu] Invalid post update payload rejected by API; falling back to plain text")
+ fallback_body = self._build_update_message_body(
+ msg_type="text",
+ content=json.dumps({"text": _strip_markdown_to_plain_text(content)}, ensure_ascii=False),
+ )
+ fallback_request = self._build_update_message_request(message_id=message_id, request_body=fallback_body)
+ fallback_response = await asyncio.to_thread(self._client.im.v1.message.update, fallback_request)
+ result = self._finalize_send_result(fallback_response, "update failed")
+ if result.success:
+ result.message_id = message_id
+ return result
+ except Exception as exc:
+ logger.error("[Feishu] Failed to edit message %s: %s", message_id, exc, exc_info=True)
+ return SendResult(success=False, error=str(exc))
+
+ async def send_voice(
+ self,
+ chat_id: str,
+ audio_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ **kwargs,
+ ) -> SendResult:
+ """Send audio to Feishu as a file attachment plus optional caption."""
+ return await self._send_uploaded_file_message(
+ chat_id=chat_id,
+ file_path=audio_path,
+ reply_to=reply_to,
+ metadata=metadata,
+ caption=caption,
+ outbound_message_type="audio",
+ )
+
+ async def send_document(
+ self,
+ chat_id: str,
+ file_path: str,
+ caption: Optional[str] = None,
+ file_name: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ **kwargs,
+ ) -> SendResult:
+ """Send a document/file attachment to Feishu."""
+ return await self._send_uploaded_file_message(
+ chat_id=chat_id,
+ file_path=file_path,
+ reply_to=reply_to,
+ metadata=metadata,
+ caption=caption,
+ file_name=file_name,
+ )
+
+ async def send_video(
+ self,
+ chat_id: str,
+ video_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ **kwargs,
+ ) -> SendResult:
+ """Send a video file to Feishu."""
+ return await self._send_uploaded_file_message(
+ chat_id=chat_id,
+ file_path=video_path,
+ reply_to=reply_to,
+ metadata=metadata,
+ caption=caption,
+ outbound_message_type="media",
+ )
+
+ async def send_image_file(
+ self,
+ chat_id: str,
+ image_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ **kwargs,
+ ) -> SendResult:
+ """Send a local image file to Feishu."""
+ if not self._client:
+ return SendResult(success=False, error="Not connected")
+ if not os.path.exists(image_path):
+ return SendResult(success=False, error=f"Image file not found: {image_path}")
+
+ try:
+ with open(image_path, "rb") as image_file:
+ body = self._build_image_upload_body(
+ image_type=_FEISHU_IMAGE_UPLOAD_TYPE,
+ image=image_file,
+ )
+ request = self._build_image_upload_request(body)
+ upload_response = await asyncio.to_thread(self._client.im.v1.image.create, request)
+ image_key = self._extract_response_field(upload_response, "image_key")
+ if not image_key:
+ return self._response_error_result(
+ upload_response,
+ default_message="image upload failed",
+ override_error="Feishu image upload missing image_key",
+ )
+
+ if caption:
+ post_payload = self._build_media_post_payload(
+ caption=caption,
+ media_tag={"tag": "img", "image_key": image_key},
+ )
+ message_response = await self._feishu_send_with_retry(
+ chat_id=chat_id,
+ msg_type="post",
+ payload=post_payload,
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ else:
+ message_response = await self._feishu_send_with_retry(
+ chat_id=chat_id,
+ msg_type="image",
+ payload=json.dumps({"image_key": image_key}, ensure_ascii=False),
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ return self._finalize_send_result(message_response, "image send failed")
+ except Exception as exc:
+ logger.error("[Feishu] Failed to send image %s: %s", image_path, exc, exc_info=True)
+ return SendResult(success=False, error=str(exc))
+
+ async def send_typing(self, chat_id: str, metadata=None) -> None:
+ """Feishu bot API does not expose a typing indicator."""
+ return None
+
+ async def send_image(
+ self,
+ chat_id: str,
+ image_url: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Download a remote image then send it through the native Feishu image flow."""
+ try:
+ image_path = await self._download_remote_image(image_url)
+ except Exception as exc:
+ logger.error("[Feishu] Failed to download image %s: %s", image_url, exc, exc_info=True)
+ return await super().send_image(
+ chat_id=chat_id,
+ image_url=image_url,
+ caption=caption,
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ return await self.send_image_file(
+ chat_id=chat_id,
+ image_path=image_path,
+ caption=caption,
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+
+ async def send_animation(
+ self,
+ chat_id: str,
+ animation_url: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Feishu has no native GIF bubble; degrade to a downloadable file."""
+ try:
+ file_path, file_name = await self._download_remote_document(
+ animation_url,
+ default_ext=".gif",
+ preferred_name="animation.gif",
+ )
+ except Exception as exc:
+ logger.error("[Feishu] Failed to download animation %s: %s", animation_url, exc, exc_info=True)
+ return await super().send_animation(
+ chat_id=chat_id,
+ animation_url=animation_url,
+ caption=caption,
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ degraded_caption = f"[GIF downgraded to file]\n{caption}" if caption else "[GIF downgraded to file]"
+ return await self.send_document(
+ chat_id=chat_id,
+ file_path=file_path,
+ file_name=file_name,
+ caption=degraded_caption,
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
+ """Return real chat metadata from Feishu when available."""
+ fallback = {
+ "chat_id": chat_id,
+ "name": chat_id,
+ "type": "dm",
+ }
+ if not self._client:
+ return fallback
+
+ cached = self._chat_info_cache.get(chat_id)
+ if cached is not None:
+ return dict(cached)
+
+ try:
+ request = self._build_get_chat_request(chat_id)
+ response = await asyncio.to_thread(self._client.im.v1.chat.get, request)
+ if not response or getattr(response, "success", lambda: False)() is False:
+ code = getattr(response, "code", "unknown")
+ msg = getattr(response, "msg", "chat lookup failed")
+ logger.warning("[Feishu] Failed to get chat info for %s: [%s] %s", chat_id, code, msg)
+ return fallback
+
+ data = getattr(response, "data", None)
+ raw_chat_type = str(getattr(data, "chat_type", "") or "").strip().lower()
+ info = {
+ "chat_id": chat_id,
+ "name": str(getattr(data, "name", None) or chat_id),
+ "type": self._map_chat_type(raw_chat_type),
+ "raw_type": raw_chat_type or None,
+ }
+ self._chat_info_cache[chat_id] = info
+ return dict(info)
+ except Exception:
+ logger.warning("[Feishu] Failed to get chat info for %s", chat_id, exc_info=True)
+ return fallback
+
+ def format_message(self, content: str) -> str:
+ """Feishu text messages are plain text by default."""
+ return content.strip()
+
+ # =========================================================================
+ # Inbound event handlers
+ # =========================================================================
+
+ def _on_message_event(self, data: Any) -> None:
+ """Normalize Feishu inbound events into MessageEvent."""
+ if self._loop is None:
+ logger.warning("[Feishu] Dropping inbound message before adapter loop is ready")
+ return
+ future = asyncio.run_coroutine_threadsafe(
+ self._handle_message_event_data(data),
+ self._loop,
+ )
+ future.add_done_callback(self._log_background_failure)
+
+ async def _handle_message_event_data(self, data: Any) -> None:
+ """Shared inbound message handling for websocket and webhook transports."""
+ event = getattr(data, "event", None)
+ message = getattr(event, "message", None)
+ sender = getattr(event, "sender", None)
+ sender_id = getattr(sender, "sender_id", None)
+ if not message or not sender_id:
+ logger.debug("[Feishu] Dropping malformed inbound event: missing message or sender_id")
+ return
+
+ message_id = getattr(message, "message_id", None)
+ if not message_id or self._is_duplicate(message_id):
+ logger.debug("[Feishu] Dropping duplicate/missing message_id: %s", message_id)
+ return
+ if getattr(sender, "sender_type", "") == "bot":
+ logger.debug("[Feishu] Dropping bot-originated event: %s", message_id)
+ return
+
+ chat_type = getattr(message, "chat_type", "p2p")
+ if chat_type != "p2p" and not self._should_accept_group_message(message, sender_id):
+ logger.debug("[Feishu] Dropping group message that failed mention/policy gate: %s", message_id)
+ return
+ await self._process_inbound_message(
+ data=data,
+ message=message,
+ sender_id=sender_id,
+ chat_type=chat_type,
+ message_id=message_id,
+ )
+
+ def _on_message_read_event(self, data: P2ImMessageMessageReadV1) -> None:
+ """Ignore read-receipt events that Hermes does not act on."""
+ event = getattr(data, "event", None)
+ message = getattr(event, "message", None)
+ message_id = getattr(message, "message_id", None) or ""
+ logger.debug("[Feishu] Ignoring message_read event: %s", message_id)
+
+ def _on_bot_added_to_chat(self, data: Any) -> None:
+ """Handle bot being added to a group chat."""
+ event = getattr(data, "event", None)
+ chat_id = str(getattr(event, "chat_id", "") or "")
+ logger.info("[Feishu] Bot added to chat: %s", chat_id)
+ self._chat_info_cache.pop(chat_id, None)
+
+ def _on_bot_removed_from_chat(self, data: Any) -> None:
+ """Handle bot being removed from a group chat."""
+ event = getattr(data, "event", None)
+ chat_id = str(getattr(event, "chat_id", "") or "")
+ logger.info("[Feishu] Bot removed from chat: %s", chat_id)
+ self._chat_info_cache.pop(chat_id, None)
+
+ def _on_reaction_event(self, event_type: str, data: Any) -> None:
+ """Route user reactions on bot messages as synthetic text events."""
+ event = getattr(data, "event", None)
+ message_id = str(getattr(event, "message_id", "") or "")
+ operator_type = str(getattr(event, "operator_type", "") or "")
+ reaction_type_obj = getattr(event, "reaction_type", None)
+ emoji_type = str(getattr(reaction_type_obj, "emoji_type", "") or "")
+ action = "added" if "created" in event_type else "removed"
+ logger.debug(
+ "[Feishu] Reaction %s on message %s (operator_type=%s, emoji=%s)",
+ action,
+ message_id,
+ operator_type,
+ emoji_type,
+ )
+ # Only process reactions from real users. Ignore app/bot-generated reactions
+ # and Hermes' own ACK emoji to avoid feedback loops.
+ if (
+ operator_type in {"bot", "app"}
+ or emoji_type == _FEISHU_ACK_EMOJI
+ or not message_id
+ or self._loop is None
+ ):
+ return
+ future = asyncio.run_coroutine_threadsafe(
+ self._handle_reaction_event(event_type, data),
+ self._loop,
+ )
+ future.add_done_callback(self._log_background_failure)
+
+ def _on_card_action_trigger(self, data: Any) -> Any:
+ """Schedule Feishu card actions on the adapter loop and acknowledge immediately."""
+ if self._loop is None:
+ logger.warning("[Feishu] Dropping card action before adapter loop is ready")
+ else:
+ future = asyncio.run_coroutine_threadsafe(
+ self._handle_card_action_event(data),
+ self._loop,
+ )
+ future.add_done_callback(self._log_background_failure)
+ if P2CardActionTriggerResponse is None:
+ return None
+ return P2CardActionTriggerResponse()
+
+ async def _handle_reaction_event(self, event_type: str, data: Any) -> None:
+ """Fetch the reacted-to message; if it was sent by this bot, emit a synthetic text event."""
+ if not self._client:
+ return
+ event = getattr(data, "event", None)
+ message_id = str(getattr(event, "message_id", "") or "")
+ if not message_id:
+ return
+
+ # Fetch the target message to verify it was sent by us and to obtain chat context.
+ try:
+ request = self._build_get_message_request(message_id)
+ response = await asyncio.to_thread(self._client.im.v1.message.get, request)
+ if not response or not getattr(response, "success", lambda: False)():
+ return
+ items = getattr(getattr(response, "data", None), "items", None) or []
+ msg = items[0] if items else None
+ if not msg:
+ return
+ sender = getattr(msg, "sender", None)
+ sender_type = str(getattr(sender, "sender_type", "") or "").lower()
+ if sender_type != "app":
+ return # only route reactions on our own bot messages
+ chat_id = str(getattr(msg, "chat_id", "") or "")
+ chat_type_raw = str(getattr(msg, "chat_type", "p2p") or "p2p")
+ if not chat_id:
+ return
+ except Exception:
+ logger.debug("[Feishu] Failed to fetch message for reaction routing", exc_info=True)
+ return
+
+ user_id_obj = getattr(event, "user_id", None)
+ reaction_type_obj = getattr(event, "reaction_type", None)
+ emoji_type = str(getattr(reaction_type_obj, "emoji_type", "") or "UNKNOWN")
+ action = "added" if "created" in event_type else "removed"
+ synthetic_text = f"reaction:{action}:{emoji_type}"
+
+ sender_profile = await self._resolve_sender_profile(user_id_obj)
+ chat_info = await self.get_chat_info(chat_id)
+ source = self.build_source(
+ chat_id=chat_id,
+ chat_name=chat_info.get("name") or chat_id or "Feishu Chat",
+ chat_type=self._resolve_source_chat_type(chat_info=chat_info, event_chat_type=chat_type_raw),
+ user_id=sender_profile["user_id"],
+ user_name=sender_profile["user_name"],
+ thread_id=None,
+ user_id_alt=sender_profile["user_id_alt"],
+ )
+ synthetic_event = MessageEvent(
+ text=synthetic_text,
+ message_type=MessageType.TEXT,
+ source=source,
+ raw_message=data,
+ message_id=message_id,
+ timestamp=datetime.now(),
+ )
+ logger.info("[Feishu] Routing reaction %s:%s on bot message %s as synthetic event", action, emoji_type, message_id)
+ await self._handle_message_with_guards(synthetic_event)
+
+ def _is_card_action_duplicate(self, token: str) -> bool:
+ """Return True if this card action token was already processed within the dedup window."""
+ now = time.time()
+ # Prune expired tokens lazily each call.
+ expired = [t for t, ts in self._card_action_tokens.items() if now - ts > _FEISHU_CARD_ACTION_DEDUP_TTL_SECONDS]
+ for t in expired:
+ del self._card_action_tokens[t]
+ if token in self._card_action_tokens:
+ return True
+ self._card_action_tokens[token] = now
+ return False
+
+ async def _handle_card_action_event(self, data: Any) -> None:
+ """Route Feishu interactive card button clicks as synthetic COMMAND events."""
+ event = getattr(data, "event", None)
+ token = str(getattr(event, "token", "") or "")
+ if token and self._is_card_action_duplicate(token):
+ logger.debug("[Feishu] Dropping duplicate card action token: %s", token)
+ return
+
+ context = getattr(event, "context", None)
+ chat_id = str(getattr(context, "open_chat_id", "") or "")
+ operator = getattr(event, "operator", None)
+ open_id = str(getattr(operator, "open_id", "") or "")
+ if not chat_id or not open_id:
+ logger.debug("[Feishu] Card action missing chat_id or operator open_id, dropping")
+ return
+
+ action = getattr(event, "action", None)
+ action_tag = str(getattr(action, "tag", "") or "button")
+ action_value = getattr(action, "value", {}) or {}
+ synthetic_text = f"/card {action_tag}"
+ if action_value:
+ try:
+ synthetic_text += f" {json.dumps(action_value, ensure_ascii=False)}"
+ except Exception:
+ pass
+
+ sender_id = SimpleNamespace(open_id=open_id, user_id=None, union_id=None)
+ sender_profile = await self._resolve_sender_profile(sender_id)
+ chat_info = await self.get_chat_info(chat_id)
+ source = self.build_source(
+ chat_id=chat_id,
+ chat_name=chat_info.get("name") or chat_id or "Feishu Chat",
+ chat_type=self._resolve_source_chat_type(chat_info=chat_info, event_chat_type="group"),
+ user_id=sender_profile["user_id"],
+ user_name=sender_profile["user_name"],
+ thread_id=None,
+ user_id_alt=sender_profile["user_id_alt"],
+ )
+ synthetic_event = MessageEvent(
+ text=synthetic_text,
+ message_type=MessageType.COMMAND,
+ source=source,
+ raw_message=data,
+ message_id=token or str(uuid.uuid4()),
+ timestamp=datetime.now(),
+ )
+ logger.info("[Feishu] Routing card action %r from %s in %s as synthetic command", action_tag, open_id, chat_id)
+ await self._handle_message_with_guards(synthetic_event)
+
+ # =========================================================================
+ # Per-chat serialization and typing indicator
+ # =========================================================================
+
+ def _get_chat_lock(self, chat_id: str) -> asyncio.Lock:
+ """Return (creating if needed) the per-chat asyncio.Lock for serial message processing."""
+ lock = self._chat_locks.get(chat_id)
+ if lock is None:
+ lock = asyncio.Lock()
+ self._chat_locks[chat_id] = lock
+ return lock
+
+ async def _handle_message_with_guards(self, event: MessageEvent) -> None:
+ """Dispatch a single event through the agent pipeline with per-chat serialization
+ and a persistent ACK emoji reaction before processing starts.
+
+ - Per-chat lock: ensures messages in the same chat are processed one at a time
+ (matches openclaw's createChatQueue serial queue behaviour).
+ - ACK indicator: adds a CHECK reaction to the triggering message before handing
+ off to the agent and leaves it in place as a receipt marker.
+ """
+ chat_id = getattr(event.source, "chat_id", "") or "" if event.source else ""
+ chat_lock = self._get_chat_lock(chat_id)
+ async with chat_lock:
+ message_id = event.message_id
+ if message_id:
+ await self._add_ack_reaction(message_id)
+ await self.handle_message(event)
+
+ async def _add_ack_reaction(self, message_id: str) -> Optional[str]:
+ """Add a persistent ACK emoji reaction to signal the message was received."""
+ if not self._client or not message_id:
+ return None
+ try:
+ from lark_oapi.api.im.v1 import ( # lazy import — keeps optional dep optional
+ CreateMessageReactionRequest,
+ CreateMessageReactionRequestBody,
+ )
+ body = (
+ CreateMessageReactionRequestBody.builder()
+ .reaction_type({"emoji_type": _FEISHU_ACK_EMOJI})
+ .build()
+ )
+ request = (
+ CreateMessageReactionRequest.builder()
+ .message_id(message_id)
+ .request_body(body)
+ .build()
+ )
+ response = await asyncio.to_thread(self._client.im.v1.message_reaction.create, request)
+ if response and getattr(response, "success", lambda: False)():
+ data = getattr(response, "data", None)
+ return getattr(data, "reaction_id", None)
+ logger.warning(
+ "[Feishu] Failed to add ack reaction to %s: code=%s msg=%s",
+ message_id,
+ getattr(response, "code", None),
+ getattr(response, "msg", None),
+ )
+ except Exception:
+ logger.warning("[Feishu] Failed to add ack reaction to %s", message_id, exc_info=True)
+ return None
+
+ # =========================================================================
+ # Webhook server and security
+ # =========================================================================
+
+ def _record_webhook_anomaly(self, remote_ip: str, status: str) -> None:
+ """Increment the anomaly counter for remote_ip and emit a WARNING every threshold hits.
+
+ Mirrors openclaw's createWebhookAnomalyTracker: TTL 6 hours, log every 25 consecutive
+ error responses from the same IP.
+ """
+ now = time.time()
+ entry = self._webhook_anomaly_counts.get(remote_ip)
+ if entry is not None:
+ count, _last_status, first_seen = entry
+ if now - first_seen < _FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS:
+ count += 1
+ if count % _FEISHU_WEBHOOK_ANOMALY_THRESHOLD == 0:
+ logger.warning(
+ "[Feishu] Webhook anomaly: %d consecutive error responses (%s) from %s "
+ "over the last %.0fs",
+ count,
+ status,
+ remote_ip,
+ now - first_seen,
+ )
+ self._webhook_anomaly_counts[remote_ip] = (count, status, first_seen)
+ return
+ # Either first occurrence or TTL expired — start fresh.
+ self._webhook_anomaly_counts[remote_ip] = (1, status, now)
+
+ def _clear_webhook_anomaly(self, remote_ip: str) -> None:
+ """Reset the anomaly counter for remote_ip after a successful request."""
+ self._webhook_anomaly_counts.pop(remote_ip, None)
+
+ # =========================================================================
+ # Inbound processing pipeline
+ # =========================================================================
+
+ async def _process_inbound_message(
+ self,
+ *,
+ data: Any,
+ message: Any,
+ sender_id: Any,
+ chat_type: str,
+ message_id: str,
+ ) -> None:
+ text, inbound_type, media_urls, media_types = await self._extract_message_content(message)
+ if inbound_type == MessageType.TEXT and not text and not media_urls:
+ logger.debug("[Feishu] Ignoring unsupported or empty message type: %s", getattr(message, "message_type", ""))
+ return
+
+ if inbound_type == MessageType.TEXT and text.startswith("/"):
+ inbound_type = MessageType.COMMAND
+
+ reply_to_message_id = (
+ getattr(message, "parent_id", None)
+ or getattr(message, "upper_message_id", None)
+ or None
+ )
+ reply_to_text = await self._fetch_message_text(reply_to_message_id) if reply_to_message_id else None
+
+ logger.info(
+ "[Feishu] Inbound %s message received: id=%s type=%s chat_id=%s text=%r media=%d",
+ "dm" if chat_type == "p2p" else "group",
+ message_id,
+ inbound_type.value,
+ getattr(message, "chat_id", "") or "",
+ text[:120],
+ len(media_urls),
+ )
+
+ chat_id = getattr(message, "chat_id", "") or ""
+ chat_info = await self.get_chat_info(chat_id)
+ sender_profile = await self._resolve_sender_profile(sender_id)
+ source = self.build_source(
+ chat_id=chat_id,
+ chat_name=chat_info.get("name") or chat_id or "Feishu Chat",
+ chat_type=self._resolve_source_chat_type(chat_info=chat_info, event_chat_type=chat_type),
+ user_id=sender_profile["user_id"],
+ user_name=sender_profile["user_name"],
+ thread_id=getattr(message, "thread_id", None) or None,
+ user_id_alt=sender_profile["user_id_alt"],
+ )
+ normalized = MessageEvent(
+ text=text,
+ message_type=inbound_type,
+ source=source,
+ raw_message=data,
+ message_id=message_id,
+ media_urls=media_urls,
+ media_types=media_types,
+ reply_to_message_id=reply_to_message_id,
+ reply_to_text=reply_to_text,
+ timestamp=datetime.now(),
+ )
+ await self._dispatch_inbound_event(normalized)
+
+ async def _dispatch_inbound_event(self, event: MessageEvent) -> None:
+ """Apply Feishu-specific burst protection before entering the base adapter."""
+ if event.message_type == MessageType.TEXT and not event.is_command():
+ await self._enqueue_text_event(event)
+ return
+ if self._should_batch_media_event(event):
+ await self._enqueue_media_event(event)
+ return
+ await self._handle_message_with_guards(event)
+
+ # =========================================================================
+ # Media batching
+ # =========================================================================
+
+ def _should_batch_media_event(self, event: MessageEvent) -> bool:
+ return bool(
+ event.media_urls
+ and event.message_type in {MessageType.PHOTO, MessageType.VIDEO, MessageType.DOCUMENT, MessageType.AUDIO}
+ )
+
+ def _media_batch_key(self, event: MessageEvent) -> str:
+ from gateway.session import build_session_key
+
+ session_key = build_session_key(
+ event.source,
+ group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
+ )
+ return f"{session_key}:media:{event.message_type.value}"
+
+ @staticmethod
+ def _media_batch_is_compatible(existing: MessageEvent, incoming: MessageEvent) -> bool:
+ return (
+ existing.message_type == incoming.message_type
+ and existing.reply_to_message_id == incoming.reply_to_message_id
+ and existing.reply_to_text == incoming.reply_to_text
+ and existing.source.thread_id == incoming.source.thread_id
+ )
+
+ async def _enqueue_media_event(self, event: MessageEvent) -> None:
+ key = self._media_batch_key(event)
+ existing = self._pending_media_batches.get(key)
+ if existing is None:
+ self._pending_media_batches[key] = event
+ self._schedule_media_batch_flush(key)
+ return
+ if not self._media_batch_is_compatible(existing, event):
+ await self._flush_media_batch_now(key)
+ self._pending_media_batches[key] = event
+ self._schedule_media_batch_flush(key)
+ return
+ existing.media_urls.extend(event.media_urls)
+ existing.media_types.extend(event.media_types)
+ if event.text:
+ if not existing.text:
+ existing.text = event.text
+ elif event.text not in existing.text.split("\n\n"):
+ existing.text = f"{existing.text}\n\n{event.text}"
+ existing.timestamp = event.timestamp
+ if event.message_id:
+ existing.message_id = event.message_id
+ self._schedule_media_batch_flush(key)
+
+ def _schedule_media_batch_flush(self, key: str) -> None:
+ self._reschedule_batch_task(
+ self._pending_media_batch_tasks,
+ key,
+ self._flush_media_batch,
+ )
+
+ async def _flush_media_batch(self, key: str) -> None:
+ current_task = asyncio.current_task()
+ try:
+ await asyncio.sleep(self._media_batch_delay_seconds)
+ await self._flush_media_batch_now(key)
+ finally:
+ if self._pending_media_batch_tasks.get(key) is current_task:
+ self._pending_media_batch_tasks.pop(key, None)
+
+ async def _flush_media_batch_now(self, key: str) -> None:
+ event = self._pending_media_batches.pop(key, None)
+ if not event:
+ return
+ logger.info(
+ "[Feishu] Flushing media batch %s with %d attachment(s)",
+ key,
+ len(event.media_urls),
+ )
+ await self._handle_message_with_guards(event)
+
+ async def _download_remote_image(self, image_url: str) -> str:
+ ext = self._guess_remote_extension(image_url, default=".jpg")
+ return await cache_image_from_url(image_url, ext=ext)
+
+ async def _download_remote_document(
+ self,
+ file_url: str,
+ *,
+ default_ext: str,
+ preferred_name: str,
+ ) -> tuple[str, str]:
+ import httpx
+
+ async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
+ response = await client.get(
+ file_url,
+ headers={
+ "User-Agent": "Mozilla/5.0 (compatible; HermesAgent/1.0)",
+ "Accept": "*/*",
+ },
+ )
+ response.raise_for_status()
+ filename = self._derive_remote_filename(
+ file_url,
+ content_type=str(response.headers.get("Content-Type", "")),
+ default_name=preferred_name,
+ default_ext=default_ext,
+ )
+ cached_path = cache_document_from_bytes(response.content, filename)
+ return cached_path, filename
+
+ @staticmethod
+ def _guess_remote_extension(url: str, *, default: str) -> str:
+ ext = Path((url or "").split("?", 1)[0]).suffix.lower()
+ return ext if ext in (_IMAGE_EXTENSIONS | _AUDIO_EXTENSIONS | _VIDEO_EXTENSIONS | set(SUPPORTED_DOCUMENT_TYPES)) else default
+
+ @staticmethod
+ def _derive_remote_filename(file_url: str, *, content_type: str, default_name: str, default_ext: str) -> str:
+ candidate = Path((file_url or "").split("?", 1)[0]).name or default_name
+ ext = Path(candidate).suffix.lower()
+ if not ext:
+ guessed = mimetypes.guess_extension((content_type or "").split(";", 1)[0].strip().lower() or "") or default_ext
+ candidate = f"{candidate}{guessed}"
+ return candidate
+
+ @staticmethod
+ def _namespace_from_mapping(value: Any) -> Any:
+ if isinstance(value, dict):
+ return SimpleNamespace(**{key: FeishuAdapter._namespace_from_mapping(item) for key, item in value.items()})
+ if isinstance(value, list):
+ return [FeishuAdapter._namespace_from_mapping(item) for item in value]
+ return value
+
+ async def _handle_webhook_request(self, request: Any) -> Any:
+ remote_ip = (getattr(request, "remote", None) or "unknown")
+
+ # Rate limiting — composite key: app_id:path:remote_ip (matches openclaw key structure).
+ rate_key = f"{self._app_id}:{self._webhook_path}:{remote_ip}"
+ if not self._check_webhook_rate_limit(rate_key):
+ logger.warning("[Feishu] Webhook rate limit exceeded for %s", remote_ip)
+ self._record_webhook_anomaly(remote_ip, "429")
+ return web.Response(status=429, text="Too Many Requests")
+
+ # Content-Type guard — Feishu always sends application/json.
+ headers = getattr(request, "headers", {}) or {}
+ content_type = str(headers.get("Content-Type", "") or "").split(";")[0].strip().lower()
+ if content_type and content_type != "application/json":
+ logger.warning("[Feishu] Webhook rejected: unexpected Content-Type %r from %s", content_type, remote_ip)
+ self._record_webhook_anomaly(remote_ip, "415")
+ return web.Response(status=415, text="Unsupported Media Type")
+
+ # Body size guard — reject early via Content-Length when present.
+ content_length = getattr(request, "content_length", None)
+ if content_length is not None and content_length > _FEISHU_WEBHOOK_MAX_BODY_BYTES:
+ logger.warning("[Feishu] Webhook body too large (%d bytes) from %s", content_length, remote_ip)
+ self._record_webhook_anomaly(remote_ip, "413")
+ return web.Response(status=413, text="Request body too large")
+
+ try:
+ body_bytes: bytes = await asyncio.wait_for(
+ request.read(),
+ timeout=_FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS,
+ )
+ except asyncio.TimeoutError:
+ logger.warning("[Feishu] Webhook body read timed out after %ds from %s", _FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS, remote_ip)
+ self._record_webhook_anomaly(remote_ip, "408")
+ return web.Response(status=408, text="Request Timeout")
+ except Exception:
+ self._record_webhook_anomaly(remote_ip, "400")
+ return web.json_response({"code": 400, "msg": "failed to read body"}, status=400)
+
+ if len(body_bytes) > _FEISHU_WEBHOOK_MAX_BODY_BYTES:
+ logger.warning("[Feishu] Webhook body exceeds limit (%d bytes) from %s", len(body_bytes), remote_ip)
+ self._record_webhook_anomaly(remote_ip, "413")
+ return web.Response(status=413, text="Request body too large")
+
+ try:
+ payload = json.loads(body_bytes.decode("utf-8"))
+ except (json.JSONDecodeError, UnicodeDecodeError):
+ self._record_webhook_anomaly(remote_ip, "400")
+ return web.json_response({"code": 400, "msg": "invalid json"}, status=400)
+
+ # URL verification challenge — respond before other checks so that Feishu's
+ # subscription setup works even before encrypt_key is wired.
+ if payload.get("type") == "url_verification":
+ return web.json_response({"challenge": payload.get("challenge", "")})
+
+ # Verification token check — second layer of defence beyond signature (matches openclaw).
+ if self._verification_token:
+ header = payload.get("header") or {}
+ incoming_token = str(header.get("token") or payload.get("token") or "")
+ if not incoming_token or not hmac.compare_digest(incoming_token, self._verification_token):
+ logger.warning("[Feishu] Webhook rejected: invalid verification token from %s", remote_ip)
+ self._record_webhook_anomaly(remote_ip, "401-token")
+ return web.Response(status=401, text="Invalid verification token")
+
+ # Timing-safe signature verification (only enforced when encrypt_key is set).
+ if self._encrypt_key and not self._is_webhook_signature_valid(request.headers, body_bytes):
+ logger.warning("[Feishu] Webhook rejected: invalid signature from %s", remote_ip)
+ self._record_webhook_anomaly(remote_ip, "401-sig")
+ return web.Response(status=401, text="Invalid signature")
+
+ if payload.get("encrypt"):
+ logger.error("[Feishu] Encrypted webhook payloads are not supported by Hermes webhook mode")
+ self._record_webhook_anomaly(remote_ip, "400-encrypted")
+ return web.json_response({"code": 400, "msg": "encrypted webhook payloads are not supported"}, status=400)
+
+ self._clear_webhook_anomaly(remote_ip)
+
+ event_type = str((payload.get("header") or {}).get("event_type") or "")
+ data = self._namespace_from_mapping(payload)
+ if event_type == "im.message.receive_v1":
+ await self._handle_message_event_data(data)
+ elif event_type == "im.message.message_read_v1":
+ self._on_message_read_event(data)
+ elif event_type == "im.chat.member.bot.added_v1":
+ self._on_bot_added_to_chat(data)
+ elif event_type == "im.chat.member.bot.deleted_v1":
+ self._on_bot_removed_from_chat(data)
+ elif event_type in ("im.message.reaction.created_v1", "im.message.reaction.deleted_v1"):
+ self._on_reaction_event(event_type, data)
+ elif event_type == "card.action.trigger":
+ asyncio.ensure_future(self._handle_card_action_event(data))
+ else:
+ logger.debug("[Feishu] Ignoring webhook event type: %s", event_type or "unknown")
+ return web.json_response({"code": 0, "msg": "ok"})
+
+ def _is_webhook_signature_valid(self, headers: Any, body_bytes: bytes) -> bool:
+ """Verify Feishu webhook signature using timing-safe comparison.
+
+ Feishu signature algorithm:
+ SHA256(timestamp + nonce + encrypt_key + body_string)
+ Headers checked: x-lark-request-timestamp, x-lark-request-nonce, x-lark-signature.
+ """
+ timestamp = str(headers.get("x-lark-request-timestamp", "") or "")
+ nonce = str(headers.get("x-lark-request-nonce", "") or "")
+ signature = str(headers.get("x-lark-signature", "") or "")
+ if not timestamp or not nonce or not signature:
+ return False
+ try:
+ body_str = body_bytes.decode("utf-8", errors="replace")
+ content = f"{timestamp}{nonce}{self._encrypt_key}{body_str}"
+ computed = hashlib.sha256(content.encode("utf-8")).hexdigest()
+ return hmac.compare_digest(computed, signature)
+ except Exception:
+ logger.debug("[Feishu] Signature verification raised an exception", exc_info=True)
+ return False
+
+ def _check_webhook_rate_limit(self, rate_key: str) -> bool:
+ """Return False when the composite rate_key has exceeded _FEISHU_WEBHOOK_RATE_LIMIT_MAX.
+
+ The rate_key is composed as "{app_id}:{path}:{remote_ip}" — matching openclaw's key
+ structure so the limit is scoped to a specific (account, endpoint, IP) triple rather
+ than a bare IP, which causes fewer false-positive denials in multi-tenant setups.
+
+ The tracking dict is capped at _FEISHU_WEBHOOK_RATE_MAX_KEYS entries to prevent unbounded
+ memory growth. Stale (expired) entries are pruned when the cap is reached.
+ """
+ now = time.time()
+ # Fast path: existing entry within the current window.
+ entry = self._webhook_rate_counts.get(rate_key)
+ if entry is not None:
+ count, window_start = entry
+ if now - window_start < _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS:
+ if count >= _FEISHU_WEBHOOK_RATE_LIMIT_MAX:
+ return False
+ self._webhook_rate_counts[rate_key] = (count + 1, window_start)
+ return True
+ # New window for an existing key, or a brand-new key — prune stale entries first.
+ if len(self._webhook_rate_counts) >= _FEISHU_WEBHOOK_RATE_MAX_KEYS:
+ stale_keys = [
+ k for k, (_, ws) in self._webhook_rate_counts.items()
+ if now - ws >= _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS
+ ]
+ for k in stale_keys:
+ del self._webhook_rate_counts[k]
+ # If still at capacity after pruning, allow through without tracking.
+ if rate_key not in self._webhook_rate_counts and len(self._webhook_rate_counts) >= _FEISHU_WEBHOOK_RATE_MAX_KEYS:
+ return True
+ self._webhook_rate_counts[rate_key] = (1, now)
+ return True
+
+ # =========================================================================
+ # Text batching
+ # =========================================================================
+
+ def _text_batch_key(self, event: MessageEvent) -> str:
+ """Return the session-scoped key used for Feishu text aggregation."""
+ from gateway.session import build_session_key
+
+ return build_session_key(
+ event.source,
+ group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
+ )
+
+ @staticmethod
+ def _text_batch_is_compatible(existing: MessageEvent, incoming: MessageEvent) -> bool:
+ """Only merge text events when reply/thread context is identical."""
+ return (
+ existing.reply_to_message_id == incoming.reply_to_message_id
+ and existing.reply_to_text == incoming.reply_to_text
+ and existing.source.thread_id == incoming.source.thread_id
+ )
+
+ async def _enqueue_text_event(self, event: MessageEvent) -> None:
+ """Debounce rapid Feishu text bursts into a single MessageEvent."""
+ key = self._text_batch_key(event)
+ existing = self._pending_text_batches.get(key)
+ if existing is None:
+ self._pending_text_batches[key] = event
+ self._pending_text_batch_counts[key] = 1
+ self._schedule_text_batch_flush(key)
+ return
+
+ if not self._text_batch_is_compatible(existing, event):
+ await self._flush_text_batch_now(key)
+ self._pending_text_batches[key] = event
+ self._pending_text_batch_counts[key] = 1
+ self._schedule_text_batch_flush(key)
+ return
+
+ existing_count = self._pending_text_batch_counts.get(key, 1)
+ next_count = existing_count + 1
+ appended_text = event.text or ""
+ next_text = f"{existing.text}\n{appended_text}" if existing.text and appended_text else (existing.text or appended_text)
+ if next_count > self._text_batch_max_messages or len(next_text) > self._text_batch_max_chars:
+ await self._flush_text_batch_now(key)
+ self._pending_text_batches[key] = event
+ self._pending_text_batch_counts[key] = 1
+ self._schedule_text_batch_flush(key)
+ return
+
+ existing.text = next_text
+ existing.timestamp = event.timestamp
+ if event.message_id:
+ existing.message_id = event.message_id
+ self._pending_text_batch_counts[key] = next_count
+ self._schedule_text_batch_flush(key)
+
+ def _schedule_text_batch_flush(self, key: str) -> None:
+ """Reset the debounce timer for a pending Feishu text batch."""
+ self._reschedule_batch_task(
+ self._pending_text_batch_tasks,
+ key,
+ self._flush_text_batch,
+ )
+
+ @staticmethod
+ def _reschedule_batch_task(
+ task_map: Dict[str, asyncio.Task],
+ key: str,
+ flush_fn: Any,
+ ) -> None:
+ prior_task = task_map.get(key)
+ if prior_task and not prior_task.done():
+ prior_task.cancel()
+ task_map[key] = asyncio.create_task(flush_fn(key))
+
+ async def _flush_text_batch(self, key: str) -> None:
+ """Flush a pending text batch after the quiet period."""
+ current_task = asyncio.current_task()
+ try:
+ await asyncio.sleep(self._text_batch_delay_seconds)
+ await self._flush_text_batch_now(key)
+ finally:
+ if self._pending_text_batch_tasks.get(key) is current_task:
+ self._pending_text_batch_tasks.pop(key, None)
+
+ async def _flush_text_batch_now(self, key: str) -> None:
+ """Dispatch the current text batch immediately."""
+ event = self._pending_text_batches.pop(key, None)
+ self._pending_text_batch_counts.pop(key, None)
+ if not event:
+ return
+ logger.info(
+ "[Feishu] Flushing text batch %s (%d chars)",
+ key,
+ len(event.text or ""),
+ )
+ await self._handle_message_with_guards(event)
+
+ # =========================================================================
+ # Message content extraction and resource download
+ # =========================================================================
+
+ async def _extract_message_content(self, message: Any) -> tuple[str, MessageType, List[str], List[str]]:
+ """Extract text and cached media from a normalized Feishu message."""
+ raw_content = getattr(message, "content", "") or ""
+ raw_type = getattr(message, "message_type", "") or ""
+ message_id = str(getattr(message, "message_id", "") or "")
+ logger.info("[Feishu] Received raw message type=%s message_id=%s", raw_type, message_id)
+
+ normalized = normalize_feishu_message(message_type=raw_type, raw_content=raw_content)
+ media_urls, media_types = await self._download_feishu_message_resources(
+ message_id=message_id,
+ normalized=normalized,
+ )
+ inbound_type = self._resolve_normalized_message_type(normalized, media_types)
+ text = normalized.text_content
+
+ if (
+ inbound_type in {MessageType.DOCUMENT, MessageType.AUDIO, MessageType.VIDEO, MessageType.PHOTO}
+ and len(media_urls) == 1
+ and normalized.preferred_message_type in {"document", "audio"}
+ ):
+ injected = await self._maybe_extract_text_document(media_urls[0], media_types[0])
+ if injected:
+ text = injected
+
+ return text, inbound_type, media_urls, media_types
+
+ async def _download_feishu_message_resources(
+ self,
+ *,
+ message_id: str,
+ normalized: FeishuNormalizedMessage,
+ ) -> tuple[List[str], List[str]]:
+ media_urls: List[str] = []
+ media_types: List[str] = []
+
+ for image_key in normalized.image_keys:
+ cached_path, media_type = await self._download_feishu_image(
+ message_id=message_id,
+ image_key=image_key,
+ )
+ if cached_path:
+ media_urls.append(cached_path)
+ media_types.append(media_type)
+
+ for media_ref in normalized.media_refs:
+ cached_path, media_type = await self._download_feishu_message_resource(
+ message_id=message_id,
+ file_key=media_ref.file_key,
+ resource_type=media_ref.resource_type,
+ fallback_filename=media_ref.file_name,
+ )
+ if cached_path:
+ media_urls.append(cached_path)
+ media_types.append(media_type)
+
+ return media_urls, media_types
+
+ @staticmethod
+ def _resolve_media_message_type(media_type: str, *, default: MessageType) -> MessageType:
+ normalized = (media_type or "").lower()
+ if normalized.startswith("image/"):
+ return MessageType.PHOTO
+ if normalized.startswith("audio/"):
+ return MessageType.AUDIO
+ if normalized.startswith("video/"):
+ return MessageType.VIDEO
+ return default
+
+ def _resolve_normalized_message_type(
+ self,
+ normalized: FeishuNormalizedMessage,
+ media_types: List[str],
+ ) -> MessageType:
+ preferred = normalized.preferred_message_type
+ if preferred == "photo":
+ return self._resolve_media_message_type(media_types[0] if media_types else "", default=MessageType.PHOTO)
+ if preferred == "audio":
+ return self._resolve_media_message_type(media_types[0] if media_types else "", default=MessageType.AUDIO)
+ if preferred == "document":
+ return self._resolve_media_message_type(media_types[0] if media_types else "", default=MessageType.DOCUMENT)
+ return MessageType.TEXT
+
+ def _normalize_inbound_text(self, text: str) -> str:
+ """Strip Feishu mention placeholders from inbound text."""
+ text = _MENTION_RE.sub(" ", text or "")
+ text = _MULTISPACE_RE.sub(" ", text)
+ return text.strip()
+
+ async def _maybe_extract_text_document(self, cached_path: str, media_type: str) -> str:
+ if not cached_path or not media_type.startswith("text/"):
+ return ""
+ try:
+ if os.path.getsize(cached_path) > _MAX_TEXT_INJECT_BYTES:
+ return ""
+ ext = Path(cached_path).suffix.lower()
+ if ext not in {".txt", ".md"} and media_type not in {"text/plain", "text/markdown"}:
+ return ""
+ content = Path(cached_path).read_text(encoding="utf-8")
+ display_name = self._display_name_from_cached_path(cached_path)
+ return f"[Content of {display_name}]:\n{content}"
+ except (OSError, UnicodeDecodeError):
+ logger.warning("[Feishu] Failed to inject text document content from %s", cached_path, exc_info=True)
+ return ""
+
+ async def _download_feishu_image(self, *, message_id: str, image_key: str) -> tuple[str, str]:
+ if not self._client or not message_id:
+ return "", ""
+ try:
+ request = self._build_message_resource_request(
+ message_id=message_id,
+ file_key=image_key,
+ resource_type="image",
+ )
+ response = await asyncio.to_thread(self._client.im.v1.message_resource.get, request)
+ if not response or not response.success():
+ logger.warning(
+ "[Feishu] Failed to download image %s: %s %s",
+ image_key,
+ getattr(response, "code", "unknown"),
+ getattr(response, "msg", "request failed"),
+ )
+ return "", ""
+ raw_bytes = self._read_binary_response(response)
+ if not raw_bytes:
+ return "", ""
+ content_type = self._get_response_header(response, "Content-Type")
+ filename = getattr(response, "file_name", None) or f"{image_key}.jpg"
+ ext = self._guess_extension(filename, content_type, ".jpg", allowed=_IMAGE_EXTENSIONS)
+ cached_path = cache_image_from_bytes(raw_bytes, ext=ext)
+ media_type = self._normalize_media_type(content_type, default=self._default_image_media_type(ext))
+ return cached_path, media_type
+ except Exception:
+ logger.warning("[Feishu] Failed to cache image resource %s", image_key, exc_info=True)
+ return "", ""
+
+ async def _download_feishu_message_resource(
+ self,
+ *,
+ message_id: str,
+ file_key: str,
+ resource_type: str,
+ fallback_filename: str,
+ ) -> tuple[str, str]:
+ if not self._client or not message_id:
+ return "", ""
+
+ request_types = [resource_type]
+ if resource_type in {"audio", "media"}:
+ request_types.append("file")
+
+ for request_type in request_types:
+ try:
+ request = self._build_message_resource_request(
+ message_id=message_id,
+ file_key=file_key,
+ resource_type=request_type,
+ )
+ response = await asyncio.to_thread(self._client.im.v1.message_resource.get, request)
+ if not response or not response.success():
+ logger.debug(
+ "[Feishu] Resource download failed for %s/%s via type=%s: %s %s",
+ message_id,
+ file_key,
+ request_type,
+ getattr(response, "code", "unknown"),
+ getattr(response, "msg", "request failed"),
+ )
+ continue
+
+ raw_bytes = self._read_binary_response(response)
+ if not raw_bytes:
+ continue
+ content_type = self._get_response_header(response, "Content-Type")
+ response_filename = getattr(response, "file_name", None) or ""
+ filename = response_filename or fallback_filename or f"{request_type}_{file_key}"
+ media_type = self._normalize_media_type(
+ content_type,
+ default=self._guess_media_type_from_filename(filename),
+ )
+
+ if media_type.startswith("image/"):
+ ext = self._guess_extension(filename, content_type, ".jpg", allowed=_IMAGE_EXTENSIONS)
+ cached_path = cache_image_from_bytes(raw_bytes, ext=ext)
+ logger.info("[Feishu] Cached message image resource at %s", cached_path)
+ return cached_path, media_type or self._default_image_media_type(ext)
+
+ if request_type == "audio" or media_type.startswith("audio/"):
+ ext = self._guess_extension(filename, content_type, ".ogg", allowed=_AUDIO_EXTENSIONS)
+ cached_path = cache_audio_from_bytes(raw_bytes, ext=ext)
+ logger.info("[Feishu] Cached message audio resource at %s", cached_path)
+ return cached_path, (media_type or f"audio/{ext.lstrip('.') or 'ogg'}")
+
+ if media_type.startswith("video/"):
+ if not Path(filename).suffix:
+ filename = f"{filename}.mp4"
+ cached_path = cache_document_from_bytes(raw_bytes, filename)
+ logger.info("[Feishu] Cached message video resource at %s", cached_path)
+ return cached_path, media_type
+
+ if not Path(filename).suffix and media_type in _DOCUMENT_MIME_TO_EXT:
+ filename = f"{filename}{_DOCUMENT_MIME_TO_EXT[media_type]}"
+ cached_path = cache_document_from_bytes(raw_bytes, filename)
+ logger.info("[Feishu] Cached message document resource at %s", cached_path)
+ return cached_path, (media_type or self._guess_document_media_type(filename))
+ except Exception:
+ logger.warning(
+ "[Feishu] Failed to cache message resource %s/%s",
+ message_id,
+ file_key,
+ exc_info=True,
+ )
+ return "", ""
+
+ # =========================================================================
+ # Static helpers — extension / media-type guessing
+ # =========================================================================
+
+ @staticmethod
+ def _read_binary_response(response: Any) -> bytes:
+ file_obj = getattr(response, "file", None)
+ if file_obj is None:
+ return b""
+ if hasattr(file_obj, "getvalue"):
+ return bytes(file_obj.getvalue())
+ return bytes(file_obj.read())
+
+ @staticmethod
+ def _get_response_header(response: Any, name: str) -> str:
+ raw = getattr(response, "raw", None)
+ headers = getattr(raw, "headers", {}) or {}
+ return str(headers.get(name, headers.get(name.lower(), "")) or "").split(";", 1)[0].strip().lower()
+
+ @staticmethod
+ def _guess_extension(filename: str, content_type: str, default: str, *, allowed: set[str]) -> str:
+ ext = Path(filename or "").suffix.lower()
+ if ext in allowed:
+ return ext
+ guessed = mimetypes.guess_extension((content_type or "").split(";", 1)[0].strip().lower() or "")
+ if guessed in allowed:
+ return guessed
+ return default
+
+ @staticmethod
+ def _normalize_media_type(content_type: str, *, default: str) -> str:
+ normalized = (content_type or "").split(";", 1)[0].strip().lower()
+ return normalized or default
+
+ @staticmethod
+ def _guess_document_media_type(filename: str) -> str:
+ ext = Path(filename or "").suffix.lower()
+ return SUPPORTED_DOCUMENT_TYPES.get(ext, mimetypes.guess_type(filename or "")[0] or "application/octet-stream")
+
+ @staticmethod
+ def _display_name_from_cached_path(path: str) -> str:
+ basename = os.path.basename(path)
+ parts = basename.split("_", 2)
+ display_name = parts[2] if len(parts) >= 3 else basename
+ return re.sub(r"[^\w.\- ]", "_", display_name)
+
+ @staticmethod
+ def _guess_media_type_from_filename(filename: str) -> str:
+ guessed = (mimetypes.guess_type(filename or "")[0] or "").lower()
+ if guessed:
+ return guessed
+ ext = Path(filename or "").suffix.lower()
+ if ext in _VIDEO_EXTENSIONS:
+ return f"video/{ext.lstrip('.')}"
+ if ext in _AUDIO_EXTENSIONS:
+ return f"audio/{ext.lstrip('.')}"
+ if ext in _IMAGE_EXTENSIONS:
+ return FeishuAdapter._default_image_media_type(ext)
+ return ""
+
+ @staticmethod
+ def _map_chat_type(raw_chat_type: str) -> str:
+ normalized = (raw_chat_type or "").strip().lower()
+ if normalized == "p2p":
+ return "dm"
+ if "topic" in normalized or "thread" in normalized or "forum" in normalized:
+ return "forum"
+ if normalized == "group":
+ return "group"
+ return "dm"
+
+ @staticmethod
+ def _resolve_source_chat_type(*, chat_info: Dict[str, Any], event_chat_type: str) -> str:
+ resolved = str(chat_info.get("type") or "").strip().lower()
+ if resolved in {"group", "forum"}:
+ return resolved
+ if event_chat_type == "p2p":
+ return "dm"
+ return "group"
+
+ async def _resolve_sender_profile(self, sender_id: Any) -> Dict[str, Optional[str]]:
+ open_id = getattr(sender_id, "open_id", None) or None
+ user_id = getattr(sender_id, "user_id", None) or None
+ union_id = getattr(sender_id, "union_id", None) or None
+ primary_id = open_id or user_id
+ display_name = await self._resolve_sender_name_from_api(primary_id or union_id)
+ return {
+ "user_id": primary_id,
+ "user_name": display_name,
+ "user_id_alt": union_id,
+ }
+
+ async def _resolve_sender_name_from_api(self, sender_id: Optional[str]) -> Optional[str]:
+ """Fetch the sender's display name from the Feishu contact API with a 10-minute cache.
+
+ ID-type detection mirrors openclaw: ou_ → open_id, on_ → union_id, else user_id.
+ Failures are silently suppressed; the message pipeline must not block on name resolution.
+ """
+ if not sender_id or not self._client:
+ return None
+ trimmed = sender_id.strip()
+ if not trimmed:
+ return None
+ now = time.time()
+ cached = self._sender_name_cache.get(trimmed)
+ if cached is not None:
+ name, expire_at = cached
+ if now < expire_at:
+ return name
+ try:
+ from lark_oapi.api.contact.v3 import GetUserRequest # lazy import
+ if trimmed.startswith("ou_"):
+ id_type = "open_id"
+ elif trimmed.startswith("on_"):
+ id_type = "union_id"
+ else:
+ id_type = "user_id"
+ request = GetUserRequest.builder().user_id(trimmed).user_id_type(id_type).build()
+ response = await asyncio.to_thread(self._client.contact.v3.user.get, request)
+ if not response or not response.success():
+ return None
+ user = getattr(getattr(response, "data", None), "user", None)
+ name = (
+ getattr(user, "name", None)
+ or getattr(user, "display_name", None)
+ or getattr(user, "nickname", None)
+ or getattr(user, "en_name", None)
+ )
+ if name and isinstance(name, str):
+ name = name.strip()
+ if name:
+ self._sender_name_cache[trimmed] = (name, now + _FEISHU_SENDER_NAME_TTL_SECONDS)
+ return name
+ except Exception:
+ logger.debug("[Feishu] Failed to resolve sender name for %s", sender_id, exc_info=True)
+ return None
+
+ async def _fetch_message_text(self, message_id: str) -> Optional[str]:
+ if not self._client or not message_id:
+ return None
+ if message_id in self._message_text_cache:
+ return self._message_text_cache[message_id]
+ try:
+ request = self._build_get_message_request(message_id)
+ response = await asyncio.to_thread(self._client.im.v1.message.get, request)
+ if not response or getattr(response, "success", lambda: False)() is False:
+ code = getattr(response, "code", "unknown")
+ msg = getattr(response, "msg", "message lookup failed")
+ logger.warning("[Feishu] Failed to fetch parent message %s: [%s] %s", message_id, code, msg)
+ return None
+ items = getattr(getattr(response, "data", None), "items", None) or []
+ parent = items[0] if items else None
+ body = getattr(parent, "body", None)
+ msg_type = getattr(parent, "msg_type", "") or ""
+ raw_content = getattr(body, "content", "") or ""
+ text = self._extract_text_from_raw_content(msg_type=msg_type, raw_content=raw_content)
+ self._message_text_cache[message_id] = text
+ return text
+ except Exception:
+ logger.warning("[Feishu] Failed to fetch parent message %s", message_id, exc_info=True)
+ return None
+
+ def _extract_text_from_raw_content(self, *, msg_type: str, raw_content: str) -> Optional[str]:
+ normalized = normalize_feishu_message(message_type=msg_type, raw_content=raw_content)
+ if normalized.text_content:
+ return normalized.text_content
+ placeholder = normalized.metadata.get("placeholder_text") if isinstance(normalized.metadata, dict) else None
+ return str(placeholder).strip() or None
+
+ @staticmethod
+ def _default_image_media_type(ext: str) -> str:
+ normalized_ext = (ext or "").lower()
+ if normalized_ext in {".jpg", ".jpeg"}:
+ return "image/jpeg"
+ return f"image/{normalized_ext.lstrip('.') or 'jpeg'}"
+
+ @staticmethod
+ def _log_background_failure(future: Any) -> None:
+ try:
+ future.result()
+ except Exception:
+ logger.exception("[Feishu] Background inbound processing failed")
+
+ # =========================================================================
+ # Group policy and mention gating
+ # =========================================================================
+
+ def _allow_group_message(self, sender_id: Any) -> bool:
+ """Current group policy gate for non-DM traffic."""
+ if self._group_policy == "disabled":
+ return False
+ sender_open_id = getattr(sender_id, "open_id", None) or getattr(sender_id, "user_id", None)
+ if self._group_policy == "open":
+ return True
+ return bool(sender_open_id and sender_open_id in self._allowed_group_users)
+
+ def _should_accept_group_message(self, message: Any, sender_id: Any) -> bool:
+ """Require an explicit @mention before group messages enter the agent."""
+ if not self._allow_group_message(sender_id):
+ return False
+ # @_all is Feishu's @everyone placeholder — always route to the bot.
+ raw_content = getattr(message, "content", "") or ""
+ if "@_all" in raw_content:
+ return True
+ mentions = getattr(message, "mentions", None) or []
+ if mentions:
+ return self._message_mentions_bot(mentions)
+ normalized = normalize_feishu_message(
+ message_type=getattr(message, "message_type", "") or "",
+ raw_content=raw_content,
+ )
+ if normalized.mentioned_ids:
+ return self._post_mentions_bot(normalized.mentioned_ids)
+ return False
+
+ def _message_mentions_bot(self, mentions: List[Any]) -> bool:
+ """Check whether any mention targets the configured or inferred bot identity."""
+ for mention in mentions:
+ mention_id = getattr(mention, "id", None)
+ mention_open_id = getattr(mention_id, "open_id", None)
+ mention_user_id = getattr(mention_id, "user_id", None)
+ mention_name = (getattr(mention, "name", None) or "").strip()
+
+ if self._bot_open_id and mention_open_id == self._bot_open_id:
+ return True
+ if self._bot_user_id and mention_user_id == self._bot_user_id:
+ return True
+ if self._bot_name and mention_name == self._bot_name:
+ return True
+
+ return False
+
+ def _post_mentions_bot(self, mentioned_ids: List[str]) -> bool:
+ if not mentioned_ids:
+ return False
+ if self._bot_open_id and self._bot_open_id in mentioned_ids:
+ return True
+ if self._bot_user_id and self._bot_user_id in mentioned_ids:
+ return True
+ return False
+
+ async def _hydrate_bot_identity(self) -> None:
+ """Best-effort discovery of bot identity for precise group mention gating."""
+ if not self._client:
+ return
+ if any((self._bot_open_id, self._bot_user_id, self._bot_name)):
+ return
+ try:
+ request = self._build_get_application_request(app_id=self._app_id, lang="en_us")
+ response = await asyncio.to_thread(self._client.application.v6.application.get, request)
+ if not response or not response.success():
+ code = getattr(response, "code", None)
+ if code == 99991672:
+ logger.warning(
+ "[Feishu] Unable to hydrate bot identity from application info. "
+ "Grant admin:app.info:readonly or application:application:self_manage "
+ "so group @mention gating can resolve the bot name precisely."
+ )
+ return
+ app = getattr(getattr(response, "data", None), "app", None)
+ app_name = (getattr(app, "app_name", None) or "").strip()
+ if app_name:
+ self._bot_name = app_name
+ except Exception:
+ logger.debug("[Feishu] Failed to hydrate bot identity", exc_info=True)
+
+ # =========================================================================
+ # Deduplication — seen message ID cache (persistent)
+ # =========================================================================
+
+ def _load_seen_message_ids(self) -> None:
+ try:
+ payload = json.loads(self._dedup_state_path.read_text(encoding="utf-8"))
+ except FileNotFoundError:
+ return
+ except (OSError, json.JSONDecodeError):
+ logger.warning("[Feishu] Failed to load persisted dedup state from %s", self._dedup_state_path, exc_info=True)
+ return
+ seen_data = payload.get("message_ids", {}) if isinstance(payload, dict) else {}
+ now = time.time()
+ ttl = _FEISHU_DEDUP_TTL_SECONDS
+ # Backward-compat: old format stored a plain list of IDs (no timestamps).
+ if isinstance(seen_data, list):
+ entries: Dict[str, float] = {str(item).strip(): 0.0 for item in seen_data if str(item).strip()}
+ elif isinstance(seen_data, dict):
+ entries = {k: float(v) for k, v in seen_data.items() if isinstance(k, str) and k.strip()}
+ else:
+ return
+ # Filter out TTL-expired entries (entries saved with ts=0.0 are treated as immortal
+ # for one migration cycle to avoid nuking old data on first upgrade).
+ valid: Dict[str, float] = {
+ msg_id: ts for msg_id, ts in entries.items()
+ if ts == 0.0 or ttl <= 0 or now - ts < ttl
+ }
+ # Apply size cap; keep the most recently seen IDs.
+ sorted_ids = sorted(valid, key=lambda k: valid[k], reverse=True)[:self._dedup_cache_size]
+ self._seen_message_order = list(reversed(sorted_ids))
+ self._seen_message_ids = {k: valid[k] for k in sorted_ids}
+
+ def _persist_seen_message_ids(self) -> None:
+ try:
+ self._dedup_state_path.parent.mkdir(parents=True, exist_ok=True)
+ recent = self._seen_message_order[-self._dedup_cache_size:]
+ # Save as {msg_id: timestamp} so TTL filtering works across restarts.
+ payload = {"message_ids": {k: self._seen_message_ids[k] for k in recent if k in self._seen_message_ids}}
+ self._dedup_state_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
+ except OSError:
+ logger.warning("[Feishu] Failed to persist dedup state to %s", self._dedup_state_path, exc_info=True)
+
+ def _is_duplicate(self, message_id: str) -> bool:
+ now = time.time()
+ ttl = _FEISHU_DEDUP_TTL_SECONDS
+ with self._dedup_lock:
+ seen_at = self._seen_message_ids.get(message_id)
+ if seen_at is not None and (ttl <= 0 or now - seen_at < ttl):
+ return True
+ # Record with current wall-clock timestamp so TTL works across restarts.
+ self._seen_message_ids[message_id] = now
+ self._seen_message_order.append(message_id)
+ while len(self._seen_message_order) > self._dedup_cache_size:
+ stale = self._seen_message_order.pop(0)
+ self._seen_message_ids.pop(stale, None)
+ self._persist_seen_message_ids()
+ return False
+
+ # =========================================================================
+ # Outbound payload construction and send pipeline
+ # =========================================================================
+
+ def _build_outbound_payload(self, content: str) -> tuple[str, str]:
+ if _MARKDOWN_HINT_RE.search(content):
+ return "post", _build_markdown_post_payload(content)
+ text_payload = {"text": content}
+ return "text", json.dumps(text_payload, ensure_ascii=False)
+
+ async def _send_uploaded_file_message(
+ self,
+ *,
+ chat_id: str,
+ file_path: str,
+ reply_to: Optional[str],
+ metadata: Optional[Dict[str, Any]],
+ caption: Optional[str] = None,
+ file_name: Optional[str] = None,
+ outbound_message_type: str = "file",
+ ) -> SendResult:
+ if not self._client:
+ return SendResult(success=False, error="Not connected")
+ if not os.path.exists(file_path):
+ return SendResult(success=False, error=f"File not found: {file_path}")
+
+ display_name = file_name or os.path.basename(file_path)
+ upload_file_type, resolved_message_type = self._resolve_outbound_file_routing(
+ file_path=display_name,
+ requested_message_type=outbound_message_type,
+ )
+ try:
+ with open(file_path, "rb") as file_obj:
+ body = self._build_file_upload_body(
+ file_type=upload_file_type,
+ file_name=display_name,
+ file=file_obj,
+ )
+ request = self._build_file_upload_request(body)
+ upload_response = await asyncio.to_thread(self._client.im.v1.file.create, request)
+ file_key = self._extract_response_field(upload_response, "file_key")
+ if not file_key:
+ return self._response_error_result(
+ upload_response,
+ default_message="file upload failed",
+ override_error="Feishu file upload missing file_key",
+ )
+
+ if caption:
+ media_tag = {
+ "tag": "media",
+ "file_key": file_key,
+ "file_name": display_name,
+ }
+ message_response = await self._feishu_send_with_retry(
+ chat_id=chat_id,
+ msg_type="post",
+ payload=self._build_media_post_payload(caption=caption, media_tag=media_tag),
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ else:
+ message_response = await self._feishu_send_with_retry(
+ chat_id=chat_id,
+ msg_type=resolved_message_type,
+ payload=json.dumps({"file_key": file_key}, ensure_ascii=False),
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ return self._finalize_send_result(message_response, "file send failed")
+ except Exception as exc:
+ logger.error("[Feishu] Failed to send file %s: %s", file_path, exc, exc_info=True)
+ return SendResult(success=False, error=str(exc))
+
+ async def _send_raw_message(
+ self,
+ *,
+ chat_id: str,
+ msg_type: str,
+ payload: str,
+ reply_to: Optional[str],
+ metadata: Optional[Dict[str, Any]],
+ ) -> Any:
+ reply_in_thread = bool((metadata or {}).get("thread_id"))
+ if reply_to:
+ body = self._build_reply_message_body(
+ content=payload,
+ msg_type=msg_type,
+ reply_in_thread=reply_in_thread,
+ uuid_value=str(uuid.uuid4()),
+ )
+ request = self._build_reply_message_request(reply_to, body)
+ return await asyncio.to_thread(self._client.im.v1.message.reply, request)
+
+ body = self._build_create_message_body(
+ receive_id=chat_id,
+ msg_type=msg_type,
+ content=payload,
+ uuid_value=str(uuid.uuid4()),
+ )
+ request = self._build_create_message_request("chat_id", body)
+ return await asyncio.to_thread(self._client.im.v1.message.create, request)
+
+ @staticmethod
+ def _response_succeeded(response: Any) -> bool:
+ return bool(response and getattr(response, "success", lambda: False)())
+
+ @staticmethod
+ def _extract_response_field(response: Any, field_name: str) -> Any:
+ if not FeishuAdapter._response_succeeded(response):
+ return None
+ data = getattr(response, "data", None)
+ return getattr(data, field_name, None) if data else None
+
+ def _response_error_result(
+ self,
+ response: Any,
+ *,
+ default_message: str,
+ override_error: Optional[str] = None,
+ ) -> SendResult:
+ if override_error:
+ return SendResult(success=False, error=override_error, raw_response=response)
+ code = getattr(response, "code", "unknown")
+ msg = getattr(response, "msg", default_message)
+ return SendResult(success=False, error=f"[{code}] {msg}", raw_response=response)
+
+ def _finalize_send_result(self, response: Any, default_message: str) -> SendResult:
+ if not self._response_succeeded(response):
+ return self._response_error_result(response, default_message=default_message)
+ return SendResult(
+ success=True,
+ message_id=self._extract_response_field(response, "message_id"),
+ raw_response=response,
+ )
+
+ # =========================================================================
+ # Connection internals — websocket / webhook setup
+ # =========================================================================
+
+ async def _connect_with_retry(self) -> None:
+ for attempt in range(_FEISHU_CONNECT_ATTEMPTS):
+ try:
+ if self._connection_mode == "websocket":
+ await self._connect_websocket()
+ else:
+ await self._connect_webhook()
+ return
+ except Exception as exc:
+ self._running = False
+ self._disable_websocket_auto_reconnect()
+ self._ws_future = None
+ await self._stop_webhook_server()
+ if attempt >= _FEISHU_CONNECT_ATTEMPTS - 1:
+ raise
+ wait_seconds = 2 ** attempt
+ logger.warning(
+ "[Feishu] Connect attempt %d/%d failed; retrying in %ds: %s",
+ attempt + 1,
+ _FEISHU_CONNECT_ATTEMPTS,
+ wait_seconds,
+ exc,
+ )
+ await asyncio.sleep(wait_seconds)
+
+ async def _connect_websocket(self) -> None:
+ if not FEISHU_WEBSOCKET_AVAILABLE:
+ raise RuntimeError("websockets not installed; websocket mode unavailable")
+ domain = FEISHU_DOMAIN if self._domain_name != "lark" else LARK_DOMAIN
+ self._client = self._build_lark_client(domain)
+ await self._hydrate_bot_identity()
+ self._ws_client = FeishuWSClient(
+ app_id=self._app_id,
+ app_secret=self._app_secret,
+ log_level=lark.LogLevel.INFO,
+ event_handler=self._event_handler,
+ domain=domain,
+ )
+ self._ws_future = self._loop.run_in_executor(
+ None,
+ _run_official_feishu_ws_client,
+ self._ws_client,
+ )
+
+ async def _connect_webhook(self) -> None:
+ if not FEISHU_WEBHOOK_AVAILABLE:
+ raise RuntimeError("aiohttp not installed; webhook mode unavailable")
+ domain = FEISHU_DOMAIN if self._domain_name != "lark" else LARK_DOMAIN
+ self._client = self._build_lark_client(domain)
+ await self._hydrate_bot_identity()
+ app = web.Application()
+ app.router.add_post(self._webhook_path, self._handle_webhook_request)
+ self._webhook_runner = web.AppRunner(app)
+ await self._webhook_runner.setup()
+ self._webhook_site = web.TCPSite(self._webhook_runner, self._webhook_host, self._webhook_port)
+ await self._webhook_site.start()
+
+ def _build_lark_client(self, domain: Any) -> Any:
+ return (
+ lark.Client.builder()
+ .app_id(self._app_id)
+ .app_secret(self._app_secret)
+ .domain(domain)
+ .log_level(lark.LogLevel.WARNING)
+ .build()
+ )
+
+ async def _feishu_send_with_retry(
+ self,
+ *,
+ chat_id: str,
+ msg_type: str,
+ payload: str,
+ reply_to: Optional[str],
+ metadata: Optional[Dict[str, Any]],
+ ) -> Any:
+ last_error: Optional[Exception] = None
+ active_reply_to = reply_to
+ for attempt in range(_FEISHU_SEND_ATTEMPTS):
+ try:
+ response = await self._send_raw_message(
+ chat_id=chat_id,
+ msg_type=msg_type,
+ payload=payload,
+ reply_to=active_reply_to,
+ metadata=metadata,
+ )
+ # If replying to a message failed because it was withdrawn or not found,
+ # fall back to posting a new message directly to the chat.
+ if active_reply_to and not self._response_succeeded(response):
+ code = getattr(response, "code", None)
+ if code in _FEISHU_REPLY_FALLBACK_CODES:
+ logger.warning(
+ "[Feishu] Reply to %s failed (code %s — message withdrawn/missing); "
+ "falling back to new message in chat %s",
+ active_reply_to,
+ code,
+ chat_id,
+ )
+ active_reply_to = None
+ response = await self._send_raw_message(
+ chat_id=chat_id,
+ msg_type=msg_type,
+ payload=payload,
+ reply_to=None,
+ metadata=metadata,
+ )
+ return response
+ except Exception as exc:
+ last_error = exc
+ if msg_type == "post" and _POST_CONTENT_INVALID_RE.search(str(exc)):
+ raise
+ if attempt >= _FEISHU_SEND_ATTEMPTS - 1:
+ raise
+ wait_seconds = 2 ** attempt
+ logger.warning(
+ "[Feishu] Send attempt %d/%d failed for chat %s; retrying in %ds: %s",
+ attempt + 1,
+ _FEISHU_SEND_ATTEMPTS,
+ chat_id,
+ wait_seconds,
+ exc,
+ )
+ await asyncio.sleep(wait_seconds)
+ raise last_error or RuntimeError("Feishu send failed")
+
+ async def _release_app_lock(self) -> None:
+ if not self._app_lock_identity:
+ return
+ try:
+ release_scoped_lock(_FEISHU_APP_LOCK_SCOPE, self._app_lock_identity)
+ except Exception as exc:
+ logger.warning("[Feishu] Failed to release app lock: %s", exc, exc_info=True)
+ finally:
+ self._app_lock_identity = None
+
+ # =========================================================================
+ # Lark API request builders
+ # =========================================================================
+
+ @staticmethod
+ def _build_get_chat_request(chat_id: str) -> Any:
+ if "GetChatRequest" in globals():
+ return GetChatRequest.builder().chat_id(chat_id).build()
+ return SimpleNamespace(chat_id=chat_id)
+
+ @staticmethod
+ def _build_get_message_request(message_id: str) -> Any:
+ if "GetMessageRequest" in globals():
+ return GetMessageRequest.builder().message_id(message_id).build()
+ return SimpleNamespace(message_id=message_id)
+
+ @staticmethod
+ def _build_message_resource_request(*, message_id: str, file_key: str, resource_type: str) -> Any:
+ if "GetMessageResourceRequest" in globals():
+ return (
+ GetMessageResourceRequest.builder()
+ .message_id(message_id)
+ .file_key(file_key)
+ .type(resource_type)
+ .build()
+ )
+ return SimpleNamespace(message_id=message_id, file_key=file_key, type=resource_type)
+
+ @staticmethod
+ def _build_get_application_request(*, app_id: str, lang: str) -> Any:
+ if "GetApplicationRequest" in globals():
+ return (
+ GetApplicationRequest.builder()
+ .app_id(app_id)
+ .lang(lang)
+ .build()
+ )
+ return SimpleNamespace(app_id=app_id, lang=lang)
+
+ @staticmethod
+ def _build_reply_message_body(*, content: str, msg_type: str, reply_in_thread: bool, uuid_value: str) -> Any:
+ if "ReplyMessageRequestBody" in globals():
+ return (
+ ReplyMessageRequestBody.builder()
+ .content(content)
+ .msg_type(msg_type)
+ .reply_in_thread(reply_in_thread)
+ .uuid(uuid_value)
+ .build()
+ )
+ return SimpleNamespace(
+ content=content,
+ msg_type=msg_type,
+ reply_in_thread=reply_in_thread,
+ uuid=uuid_value,
+ )
+
+ @staticmethod
+ def _build_reply_message_request(message_id: str, request_body: Any) -> Any:
+ if "ReplyMessageRequest" in globals():
+ return (
+ ReplyMessageRequest.builder()
+ .message_id(message_id)
+ .request_body(request_body)
+ .build()
+ )
+ return SimpleNamespace(message_id=message_id, request_body=request_body)
+
+ @staticmethod
+ def _build_update_message_body(*, msg_type: str, content: str) -> Any:
+ if "UpdateMessageRequestBody" in globals():
+ return (
+ UpdateMessageRequestBody.builder()
+ .msg_type(msg_type)
+ .content(content)
+ .build()
+ )
+ return SimpleNamespace(msg_type=msg_type, content=content)
+
+ @staticmethod
+ def _build_update_message_request(message_id: str, request_body: Any) -> Any:
+ if "UpdateMessageRequest" in globals():
+ return (
+ UpdateMessageRequest.builder()
+ .message_id(message_id)
+ .request_body(request_body)
+ .build()
+ )
+ return SimpleNamespace(message_id=message_id, request_body=request_body)
+
+ @staticmethod
+ def _build_create_message_body(*, receive_id: str, msg_type: str, content: str, uuid_value: str) -> Any:
+ if "CreateMessageRequestBody" in globals():
+ return (
+ CreateMessageRequestBody.builder()
+ .receive_id(receive_id)
+ .msg_type(msg_type)
+ .content(content)
+ .uuid(uuid_value)
+ .build()
+ )
+ return SimpleNamespace(
+ receive_id=receive_id,
+ msg_type=msg_type,
+ content=content,
+ uuid=uuid_value,
+ )
+
+ @staticmethod
+ def _build_create_message_request(receive_id_type: str, request_body: Any) -> Any:
+ if "CreateMessageRequest" in globals():
+ return (
+ CreateMessageRequest.builder()
+ .receive_id_type(receive_id_type)
+ .request_body(request_body)
+ .build()
+ )
+ return SimpleNamespace(receive_id_type=receive_id_type, request_body=request_body)
+
+ @staticmethod
+ def _build_image_upload_body(*, image_type: str, image: Any) -> Any:
+ if "CreateImageRequestBody" in globals():
+ return (
+ CreateImageRequestBody.builder()
+ .image_type(image_type)
+ .image(image)
+ .build()
+ )
+ return SimpleNamespace(image_type=image_type, image=image)
+
+ @staticmethod
+ def _build_image_upload_request(request_body: Any) -> Any:
+ if "CreateImageRequest" in globals():
+ return CreateImageRequest.builder().request_body(request_body).build()
+ return SimpleNamespace(request_body=request_body)
+
+ @staticmethod
+ def _build_file_upload_body(*, file_type: str, file_name: str, file: Any) -> Any:
+ if "CreateFileRequestBody" in globals():
+ return (
+ CreateFileRequestBody.builder()
+ .file_type(file_type)
+ .file_name(file_name)
+ .file(file)
+ .build()
+ )
+ return SimpleNamespace(file_type=file_type, file_name=file_name, file=file)
+
+ @staticmethod
+ def _build_file_upload_request(request_body: Any) -> Any:
+ if "CreateFileRequest" in globals():
+ return CreateFileRequest.builder().request_body(request_body).build()
+ return SimpleNamespace(request_body=request_body)
+
+ def _build_post_payload(self, content: str) -> str:
+ return _build_markdown_post_payload(content)
+
+ def _build_media_post_payload(self, *, caption: str, media_tag: Dict[str, str]) -> str:
+ payload = json.loads(self._build_post_payload(caption))
+ content = payload.setdefault("zh_cn", {}).setdefault("content", [])
+ content.append([media_tag])
+ return json.dumps(payload, ensure_ascii=False)
+
+ @staticmethod
+ def _resolve_outbound_file_routing(
+ *,
+ file_path: str,
+ requested_message_type: str,
+ ) -> tuple[str, str]:
+ ext = Path(file_path).suffix.lower()
+
+ if ext in _FEISHU_OPUS_UPLOAD_EXTENSIONS:
+ return "opus", "audio"
+
+ if ext in _FEISHU_MEDIA_UPLOAD_EXTENSIONS:
+ return "mp4", "media"
+
+ if ext in _FEISHU_DOC_UPLOAD_TYPES:
+ return _FEISHU_DOC_UPLOAD_TYPES[ext], "file"
+
+ if requested_message_type == "file":
+ return _FEISHU_FILE_UPLOAD_TYPE, "file"
+
+ return _FEISHU_FILE_UPLOAD_TYPE, "file"
diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py
index 3d6a90502c6f..d8593a3537b4 100644
--- a/gateway/platforms/matrix.py
+++ b/gateway/platforms/matrix.py
@@ -17,6 +17,8 @@ Environment variables:
from __future__ import annotations
import asyncio
+import io
+import json
import logging
import mimetypes
import os
@@ -512,8 +514,11 @@ class MatrixAdapter(BasePlatformAdapter):
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
- """Upload an audio file as a voice message."""
- return await self._send_local_file(chat_id, audio_path, "m.audio", caption, reply_to, metadata=metadata)
+ """Upload an audio file as a voice message (MSC3245 native voice)."""
+ return await self._send_local_file(
+ chat_id, audio_path, "m.audio", caption, reply_to,
+ metadata=metadata, is_voice=True
+ )
async def send_video(
self,
@@ -546,13 +551,16 @@ class MatrixAdapter(BasePlatformAdapter):
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
+ is_voice: bool = False,
) -> SendResult:
"""Upload bytes to Matrix and send as a media message."""
import nio
# Upload to homeserver.
- resp = await self._client.upload(
- data,
+ # nio expects a DataProvider (callable) or file-like object, not raw bytes.
+ # nio.upload() returns a tuple (UploadResponse|UploadError, Optional[Dict])
+ resp, maybe_encryption_info = await self._client.upload(
+ io.BytesIO(data),
content_type=content_type,
filename=filename,
)
@@ -574,6 +582,10 @@ class MatrixAdapter(BasePlatformAdapter):
},
}
+ # Add MSC3245 voice flag for native voice messages.
+ if is_voice:
+ msg_content["org.matrix.msc3245.voice"] = {}
+
if reply_to:
msg_content["m.relates_to"] = {
"m.in_reply_to": {"event_id": reply_to}
@@ -601,6 +613,7 @@ class MatrixAdapter(BasePlatformAdapter):
reply_to: Optional[str] = None,
file_name: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
+ is_voice: bool = False,
) -> SendResult:
"""Read a local file and upload it."""
p = Path(file_path)
@@ -613,7 +626,7 @@ class MatrixAdapter(BasePlatformAdapter):
ct = mimetypes.guess_type(fname)[0] or "application/octet-stream"
data = p.read_bytes()
- return await self._upload_and_send(room_id, data, fname, ct, msgtype, caption, reply_to, metadata)
+ return await self._upload_and_send(room_id, data, fname, ct, msgtype, caption, reply_to, metadata, is_voice)
# ------------------------------------------------------------------
# Sync loop
@@ -808,11 +821,19 @@ class MatrixAdapter(BasePlatformAdapter):
event_mimetype = (content_info.get("info") or {}).get("mimetype", "")
media_type = "application/octet-stream"
msg_type = MessageType.DOCUMENT
+ is_voice_message = False
+
if isinstance(event, nio.RoomMessageImage):
msg_type = MessageType.PHOTO
media_type = event_mimetype or "image/png"
elif isinstance(event, nio.RoomMessageAudio):
- msg_type = MessageType.AUDIO
+ # Check for MSC3245 voice flag: org.matrix.msc3245.voice: {}
+ source_content = getattr(event, "source", {}).get("content", {})
+ if source_content.get("org.matrix.msc3245.voice") is not None:
+ is_voice_message = True
+ msg_type = MessageType.VOICE
+ else:
+ msg_type = MessageType.AUDIO
media_type = event_mimetype or "audio/ogg"
elif isinstance(event, nio.RoomMessageVideo):
msg_type = MessageType.VIDEO
@@ -850,6 +871,31 @@ class MatrixAdapter(BasePlatformAdapter):
if relates_to.get("rel_type") == "m.thread":
thread_id = relates_to.get("event_id")
+ # For voice messages, cache audio locally for transcription tools.
+ # Use the authenticated nio client to download (Matrix requires auth for media).
+ media_urls = [http_url] if http_url else None
+ media_types = [media_type] if http_url else None
+
+ if is_voice_message and url and url.startswith("mxc://"):
+ try:
+ import nio
+ from gateway.platforms.base import cache_audio_from_bytes
+
+ resp = await self._client.download(mxc=url)
+ if isinstance(resp, nio.MemoryDownloadResponse):
+ # Extract extension from mimetype or default to .ogg
+ ext = ".ogg"
+ if media_type and "/" in media_type:
+ subtype = media_type.split("/")[1]
+ ext = f".{subtype}" if subtype else ".ogg"
+ local_path = cache_audio_from_bytes(resp.body, ext)
+ media_urls = [local_path]
+ logger.debug("Matrix: cached voice message to %s", local_path)
+ else:
+ logger.warning("Matrix: failed to download voice: %s", getattr(resp, "message", resp))
+ except Exception as e:
+ logger.warning("Matrix: failed to cache voice message, using HTTP URL: %s", e)
+
source = self.build_source(
chat_id=room.room_id,
chat_type=chat_type,
diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py
index 2a7e046f85be..88540815e5f1 100644
--- a/gateway/platforms/slack.py
+++ b/gateway/platforms/slack.py
@@ -9,6 +9,7 @@ Uses slack-bolt (Python) with Socket Mode for:
"""
import asyncio
+import json
import logging
import os
import re
@@ -73,6 +74,10 @@ class SlackAdapter(BasePlatformAdapter):
self._bot_user_id: Optional[str] = None
self._user_name_cache: Dict[str, str] = {} # user_id → display name
self._socket_mode_task: Optional[asyncio.Task] = None
+ # Multi-workspace support
+ self._team_clients: Dict[str, AsyncWebClient] = {} # team_id → WebClient
+ self._team_bot_user_ids: Dict[str, str] = {} # team_id → bot_user_id
+ self._channel_team: Dict[str, str] = {} # channel_id → team_id
async def connect(self) -> bool:
"""Connect to Slack via Socket Mode."""
@@ -82,16 +87,34 @@ class SlackAdapter(BasePlatformAdapter):
)
return False
- bot_token = self.config.token
+ raw_token = self.config.token
app_token = os.getenv("SLACK_APP_TOKEN")
- if not bot_token:
+ if not raw_token:
logger.error("[Slack] SLACK_BOT_TOKEN not set")
return False
if not app_token:
logger.error("[Slack] SLACK_APP_TOKEN not set")
return False
+ # Support comma-separated bot tokens for multi-workspace
+ bot_tokens = [t.strip() for t in raw_token.split(",") if t.strip()]
+
+ # Also load tokens from OAuth token file
+ from hermes_constants import get_hermes_home
+ tokens_file = get_hermes_home() / "slack_tokens.json"
+ if tokens_file.exists():
+ try:
+ saved = json.loads(tokens_file.read_text(encoding="utf-8"))
+ for team_id, entry in saved.items():
+ tok = entry.get("token", "") if isinstance(entry, dict) else ""
+ if tok and tok not in bot_tokens:
+ bot_tokens.append(tok)
+ team_label = entry.get("team_name", team_id) if isinstance(entry, dict) else team_id
+ logger.info("[Slack] Loaded saved token for workspace %s", team_label)
+ except Exception as e:
+ logger.warning("[Slack] Failed to read %s: %s", tokens_file, e)
+
try:
# Acquire scoped lock to prevent duplicate app token usage
from gateway.status import acquire_scoped_lock
@@ -104,12 +127,30 @@ class SlackAdapter(BasePlatformAdapter):
self._set_fatal_error('slack_token_lock', message, retryable=False)
return False
- self._app = AsyncApp(token=bot_token)
+ # First token is the primary — used for AsyncApp / Socket Mode
+ primary_token = bot_tokens[0]
+ self._app = AsyncApp(token=primary_token)
- # Get our own bot user ID for mention detection
- auth_response = await self._app.client.auth_test()
- self._bot_user_id = auth_response.get("user_id")
- bot_name = auth_response.get("user", "unknown")
+ # Register each bot token and map team_id → client
+ for token in bot_tokens:
+ client = AsyncWebClient(token=token)
+ auth_response = await client.auth_test()
+ team_id = auth_response.get("team_id", "")
+ bot_user_id = auth_response.get("user_id", "")
+ bot_name = auth_response.get("user", "unknown")
+ team_name = auth_response.get("team", "unknown")
+
+ self._team_clients[team_id] = client
+ self._team_bot_user_ids[team_id] = bot_user_id
+
+ # First token sets the primary bot_user_id (backward compat)
+ if self._bot_user_id is None:
+ self._bot_user_id = bot_user_id
+
+ logger.info(
+ "[Slack] Authenticated as @%s in workspace %s (team: %s)",
+ bot_name, team_name, team_id,
+ )
# Register message event handler
@self._app.event("message")
@@ -134,7 +175,10 @@ class SlackAdapter(BasePlatformAdapter):
self._socket_mode_task = asyncio.create_task(self._handler.start_async())
self._running = True
- logger.info("[Slack] Connected as @%s (Socket Mode)", bot_name)
+ logger.info(
+ "[Slack] Socket Mode connected (%d workspace(s))",
+ len(self._team_clients),
+ )
return True
except Exception as e: # pragma: no cover - defensive logging
@@ -161,6 +205,13 @@ class SlackAdapter(BasePlatformAdapter):
logger.info("[Slack] Disconnected")
+ def _get_client(self, chat_id: str) -> AsyncWebClient:
+ """Return the workspace-specific WebClient for a channel."""
+ team_id = self._channel_team.get(chat_id)
+ if team_id and team_id in self._team_clients:
+ return self._team_clients[team_id]
+ return self._app.client # fallback to primary
+
async def send(
self,
chat_id: str,
@@ -197,7 +248,7 @@ class SlackAdapter(BasePlatformAdapter):
if broadcast and i == 0:
kwargs["reply_broadcast"] = True
- last_result = await self._app.client.chat_postMessage(**kwargs)
+ last_result = await self._get_client(chat_id).chat_postMessage(**kwargs)
return SendResult(
success=True,
@@ -219,7 +270,7 @@ class SlackAdapter(BasePlatformAdapter):
if not self._app:
return SendResult(success=False, error="Not connected")
try:
- await self._app.client.chat_update(
+ await self._get_client(chat_id).chat_update(
channel=chat_id,
ts=message_id,
text=content,
@@ -253,7 +304,7 @@ class SlackAdapter(BasePlatformAdapter):
return # Can only set status in a thread context
try:
- await self._app.client.assistant_threads_setStatus(
+ await self._get_client(chat_id).assistant_threads_setStatus(
channel_id=chat_id,
thread_ts=thread_ts,
status="is thinking...",
@@ -295,7 +346,7 @@ class SlackAdapter(BasePlatformAdapter):
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
- result = await self._app.client.files_upload_v2(
+ result = await self._get_client(chat_id).files_upload_v2(
channel=chat_id,
file=file_path,
filename=os.path.basename(file_path),
@@ -397,7 +448,7 @@ class SlackAdapter(BasePlatformAdapter):
if not self._app:
return False
try:
- await self._app.client.reactions_add(
+ await self._get_client(channel).reactions_add(
channel=channel, timestamp=timestamp, name=emoji
)
return True
@@ -413,7 +464,7 @@ class SlackAdapter(BasePlatformAdapter):
if not self._app:
return False
try:
- await self._app.client.reactions_remove(
+ await self._get_client(channel).reactions_remove(
channel=channel, timestamp=timestamp, name=emoji
)
return True
@@ -423,7 +474,7 @@ class SlackAdapter(BasePlatformAdapter):
# ----- User identity resolution -----
- async def _resolve_user_name(self, user_id: str) -> str:
+ async def _resolve_user_name(self, user_id: str, chat_id: str = "") -> str:
"""Resolve a Slack user ID to a display name, with caching."""
if not user_id:
return ""
@@ -434,7 +485,8 @@ class SlackAdapter(BasePlatformAdapter):
return user_id
try:
- result = await self._app.client.users_info(user=user_id)
+ client = self._get_client(chat_id) if chat_id else self._app.client
+ result = await client.users_info(user=user_id)
user = result.get("user", {})
# Prefer display_name → real_name → user_id
profile = user.get("profile", {})
@@ -498,7 +550,7 @@ class SlackAdapter(BasePlatformAdapter):
response = await client.get(image_url)
response.raise_for_status()
- result = await self._app.client.files_upload_v2(
+ result = await self._get_client(chat_id).files_upload_v2(
channel=chat_id,
content=response.content,
filename="image.png",
@@ -558,7 +610,7 @@ class SlackAdapter(BasePlatformAdapter):
return SendResult(success=False, error=f"Video file not found: {video_path}")
try:
- result = await self._app.client.files_upload_v2(
+ result = await self._get_client(chat_id).files_upload_v2(
channel=chat_id,
file=video_path,
filename=os.path.basename(video_path),
@@ -599,7 +651,7 @@ class SlackAdapter(BasePlatformAdapter):
display_name = file_name or os.path.basename(file_path)
try:
- result = await self._app.client.files_upload_v2(
+ result = await self._get_client(chat_id).files_upload_v2(
channel=chat_id,
file=file_path,
filename=display_name,
@@ -627,7 +679,7 @@ class SlackAdapter(BasePlatformAdapter):
return {"name": chat_id, "type": "unknown"}
try:
- result = await self._app.client.conversations_info(channel=chat_id)
+ result = await self._get_client(chat_id).conversations_info(channel=chat_id)
channel = result.get("channel", {})
is_dm = channel.get("is_im", False)
return {
@@ -660,6 +712,11 @@ class SlackAdapter(BasePlatformAdapter):
user_id = event.get("user", "")
channel_id = event.get("channel", "")
ts = event.get("ts", "")
+ team_id = event.get("team", "")
+
+ # Track which workspace owns this channel
+ if team_id and channel_id:
+ self._channel_team[channel_id] = team_id
# Determine if this is a DM or channel message
channel_type = event.get("channel_type", "")
@@ -676,11 +733,12 @@ class SlackAdapter(BasePlatformAdapter):
thread_ts = event.get("thread_ts") or ts # ts fallback for channels
# In channels, only respond if bot is mentioned
- if not is_dm and self._bot_user_id:
- if f"<@{self._bot_user_id}>" not in text:
+ bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
+ if not is_dm and bot_uid:
+ if f"<@{bot_uid}>" not in text:
return
# Strip the bot mention from the text
- text = text.replace(f"<@{self._bot_user_id}>", "").strip()
+ text = text.replace(f"<@{bot_uid}>", "").strip()
# Determine message type
msg_type = MessageType.TEXT
@@ -700,7 +758,7 @@ class SlackAdapter(BasePlatformAdapter):
if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"):
ext = ".jpg"
# Slack private URLs require the bot token as auth header
- cached = await self._download_slack_file(url, ext)
+ cached = await self._download_slack_file(url, ext, team_id=team_id)
media_urls.append(cached)
media_types.append(mimetype)
msg_type = MessageType.PHOTO
@@ -711,7 +769,7 @@ class SlackAdapter(BasePlatformAdapter):
ext = "." + mimetype.split("/")[-1].split(";")[0]
if ext not in (".ogg", ".mp3", ".wav", ".webm", ".m4a"):
ext = ".ogg"
- cached = await self._download_slack_file(url, ext, audio=True)
+ cached = await self._download_slack_file(url, ext, audio=True, team_id=team_id)
media_urls.append(cached)
media_types.append(mimetype)
msg_type = MessageType.VOICE
@@ -742,7 +800,7 @@ class SlackAdapter(BasePlatformAdapter):
continue
# Download and cache
- raw_bytes = await self._download_slack_file_bytes(url)
+ raw_bytes = await self._download_slack_file_bytes(url, team_id=team_id)
cached_path = cache_document_from_bytes(
raw_bytes, original_filename or f"document{ext}"
)
@@ -771,7 +829,7 @@ class SlackAdapter(BasePlatformAdapter):
logger.warning("[Slack] Failed to cache document from %s: %s", url, e, exc_info=True)
# Resolve user display name (cached after first lookup)
- user_name = await self._resolve_user_name(user_id)
+ user_name = await self._resolve_user_name(user_id, chat_id=channel_id)
# Build source
source = self.build_source(
@@ -808,6 +866,11 @@ class SlackAdapter(BasePlatformAdapter):
text = command.get("text", "").strip()
user_id = command.get("user_id", "")
channel_id = command.get("channel_id", "")
+ team_id = command.get("team_id", "")
+
+ # Track which workspace owns this channel
+ if team_id and channel_id:
+ self._channel_team[channel_id] = team_id
# Map subcommands to gateway commands — derived from central registry.
# Also keep "compact" as a Slack-specific alias for /compress.
@@ -839,12 +902,12 @@ class SlackAdapter(BasePlatformAdapter):
await self.handle_message(event)
- async def _download_slack_file(self, url: str, ext: str, audio: bool = False) -> str:
+ async def _download_slack_file(self, url: str, ext: str, audio: bool = False, team_id: str = "") -> str:
"""Download a Slack file using the bot token for auth, with retry."""
import asyncio
import httpx
- bot_token = self.config.token
+ bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token
last_exc = None
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
@@ -874,12 +937,12 @@ class SlackAdapter(BasePlatformAdapter):
raise
raise last_exc
- async def _download_slack_file_bytes(self, url: str) -> bytes:
+ async def _download_slack_file_bytes(self, url: str, team_id: str = "") -> bytes:
"""Download a Slack file and return raw bytes, with retry."""
import asyncio
import httpx
- bot_token = self.config.token
+ bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token
last_exc = None
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py
index 926ac81d6f34..e17d104a68ae 100644
--- a/gateway/platforms/telegram.py
+++ b/gateway/platforms/telegram.py
@@ -8,6 +8,7 @@ Uses python-telegram-bot library for:
"""
import asyncio
+import json
import logging
import os
import re
@@ -122,6 +123,8 @@ class TelegramAdapter(BasePlatformAdapter):
super().__init__(config, Platform.TELEGRAM)
self._app: Optional[Application] = None
self._bot: Optional[Bot] = None
+ self._webhook_mode: bool = False
+ self._mention_patterns = self._compile_mention_patterns()
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
# Buffer rapid/album photo updates so Telegram image bursts are handled
# as a single MessageEvent instead of self-interrupting multiple turns.
@@ -456,7 +459,19 @@ class TelegramAdapter(BasePlatformAdapter):
self._persist_dm_topic_thread_id(int(chat_id), topic_name, thread_id)
async def connect(self) -> bool:
- """Connect to Telegram and start polling for updates."""
+ """Connect to Telegram via polling or webhook.
+
+ By default, uses long polling (outbound connection to Telegram).
+ If ``TELEGRAM_WEBHOOK_URL`` is set, starts an HTTP webhook server
+ instead. Webhook mode is useful for cloud deployments (Fly.io,
+ Railway) where inbound HTTP can wake a suspended machine.
+
+ Env vars for webhook mode::
+
+ TELEGRAM_WEBHOOK_URL Public HTTPS URL (e.g. https://app.fly.dev/telegram)
+ TELEGRAM_WEBHOOK_PORT Local listen port (default 8443)
+ TELEGRAM_WEBHOOK_SECRET Secret token for update verification
+ """
if not TELEGRAM_AVAILABLE:
logger.error(
"[%s] python-telegram-bot not installed. Run: pip install python-telegram-bot",
@@ -550,27 +565,57 @@ class TelegramAdapter(BasePlatformAdapter):
else:
raise
await self._app.start()
- loop = asyncio.get_running_loop()
- def _polling_error_callback(error: Exception) -> None:
- if self._polling_error_task and not self._polling_error_task.done():
- return
- if self._looks_like_polling_conflict(error):
- self._polling_error_task = loop.create_task(self._handle_polling_conflict(error))
- elif self._looks_like_network_error(error):
- logger.warning("[%s] Telegram network error, scheduling reconnect: %s", self.name, error)
- self._polling_error_task = loop.create_task(self._handle_polling_network_error(error))
- else:
- logger.error("[%s] Telegram polling error: %s", self.name, error, exc_info=True)
+ # Decide between webhook and polling mode
+ webhook_url = os.getenv("TELEGRAM_WEBHOOK_URL", "").strip()
- # Store reference for retry use in _handle_polling_conflict
- self._polling_error_callback_ref = _polling_error_callback
+ if webhook_url:
+ # ── Webhook mode ─────────────────────────────────────
+ # Telegram pushes updates to our HTTP endpoint. This
+ # enables cloud platforms (Fly.io, Railway) to auto-wake
+ # suspended machines on inbound HTTP traffic.
+ webhook_port = int(os.getenv("TELEGRAM_WEBHOOK_PORT", "8443"))
+ webhook_secret = os.getenv("TELEGRAM_WEBHOOK_SECRET", "").strip() or None
+ from urllib.parse import urlparse
+ webhook_path = urlparse(webhook_url).path or "/telegram"
- await self._app.updater.start_polling(
- allowed_updates=Update.ALL_TYPES,
- drop_pending_updates=True,
- error_callback=_polling_error_callback,
- )
+ await self._app.updater.start_webhook(
+ listen="0.0.0.0",
+ port=webhook_port,
+ url_path=webhook_path,
+ webhook_url=webhook_url,
+ secret_token=webhook_secret,
+ allowed_updates=Update.ALL_TYPES,
+ drop_pending_updates=True,
+ )
+ self._webhook_mode = True
+ logger.info(
+ "[%s] Webhook server listening on 0.0.0.0:%d%s",
+ self.name, webhook_port, webhook_path,
+ )
+ else:
+ # ── Polling mode (default) ───────────────────────────
+ loop = asyncio.get_running_loop()
+
+ def _polling_error_callback(error: Exception) -> None:
+ if self._polling_error_task and not self._polling_error_task.done():
+ return
+ if self._looks_like_polling_conflict(error):
+ self._polling_error_task = loop.create_task(self._handle_polling_conflict(error))
+ elif self._looks_like_network_error(error):
+ logger.warning("[%s] Telegram network error, scheduling reconnect: %s", self.name, error)
+ self._polling_error_task = loop.create_task(self._handle_polling_network_error(error))
+ else:
+ logger.error("[%s] Telegram polling error: %s", self.name, error, exc_info=True)
+
+ # Store reference for retry use in _handle_polling_conflict
+ self._polling_error_callback_ref = _polling_error_callback
+
+ await self._app.updater.start_polling(
+ allowed_updates=Update.ALL_TYPES,
+ drop_pending_updates=True,
+ error_callback=_polling_error_callback,
+ )
# Register bot commands so Telegram shows a hint menu when users type /
# List is derived from the central COMMAND_REGISTRY — adding a new
@@ -590,7 +635,8 @@ class TelegramAdapter(BasePlatformAdapter):
)
self._mark_connected()
- logger.info("[%s] Connected and polling for Telegram updates", self.name)
+ mode = "webhook" if self._webhook_mode else "polling"
+ logger.info("[%s] Connected to Telegram (%s mode)", self.name, mode)
# Set up DM topics (Bot API 9.4 — Private Chat Topics)
# Runs after connection is established so the bot can call createForumTopic.
@@ -618,7 +664,7 @@ class TelegramAdapter(BasePlatformAdapter):
return False
async def disconnect(self) -> None:
- """Stop polling, cancel pending album flushes, and disconnect."""
+ """Stop polling/webhook, cancel pending album flushes, and disconnect."""
pending_media_group_tasks = list(self._media_group_tasks.values())
for task in pending_media_group_tasks:
task.cancel()
@@ -762,6 +808,16 @@ class TelegramAdapter(BasePlatformAdapter):
)
effective_thread_id = None
continue
+ if "message to be replied not found" in err_lower and reply_to_id is not None:
+ # Original message was deleted before we
+ # could reply — clear reply target and retry
+ # so the response is still delivered.
+ logger.warning(
+ "[%s] Reply target deleted, retrying without reply_to: %s",
+ self.name, send_err,
+ )
+ reply_to_id = None
+ continue
# Other BadRequest errors are permanent — don't retry
raise
if _send_attempt < 2:
@@ -1315,6 +1371,148 @@ class TelegramAdapter(BasePlatformAdapter):
return text
+ # ── Group mention gating ──────────────────────────────────────────────
+
+ def _telegram_require_mention(self) -> bool:
+ """Return whether group chats should require an explicit bot trigger."""
+ configured = self.config.extra.get("require_mention")
+ if configured is not None:
+ if isinstance(configured, str):
+ return configured.lower() in ("true", "1", "yes", "on")
+ return bool(configured)
+ return os.getenv("TELEGRAM_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on")
+
+ def _telegram_free_response_chats(self) -> set[str]:
+ raw = self.config.extra.get("free_response_chats")
+ if raw is None:
+ raw = os.getenv("TELEGRAM_FREE_RESPONSE_CHATS", "")
+ if isinstance(raw, list):
+ return {str(part).strip() for part in raw if str(part).strip()}
+ return {part.strip() for part in str(raw).split(",") if part.strip()}
+
+ def _compile_mention_patterns(self) -> List[re.Pattern]:
+ """Compile optional regex wake-word patterns for group triggers."""
+ patterns = self.config.extra.get("mention_patterns")
+ if patterns is None:
+ raw = os.getenv("TELEGRAM_MENTION_PATTERNS", "").strip()
+ if raw:
+ try:
+ loaded = json.loads(raw)
+ except Exception:
+ loaded = [part.strip() for part in raw.splitlines() if part.strip()]
+ if not loaded:
+ loaded = [part.strip() for part in raw.split(",") if part.strip()]
+ patterns = loaded
+
+ if patterns is None:
+ return []
+ if isinstance(patterns, str):
+ patterns = [patterns]
+ if not isinstance(patterns, list):
+ logger.warning(
+ "[%s] telegram mention_patterns must be a list or string; got %s",
+ self.name,
+ type(patterns).__name__,
+ )
+ return []
+
+ compiled: List[re.Pattern] = []
+ for pattern in patterns:
+ if not isinstance(pattern, str) or not pattern.strip():
+ continue
+ try:
+ compiled.append(re.compile(pattern, re.IGNORECASE))
+ except re.error as exc:
+ logger.warning("[%s] Invalid Telegram mention pattern %r: %s", self.name, pattern, exc)
+ if compiled:
+ logger.info("[%s] Loaded %d Telegram mention pattern(s)", self.name, len(compiled))
+ return compiled
+
+ def _is_group_chat(self, message: Message) -> bool:
+ chat = getattr(message, "chat", None)
+ if not chat:
+ return False
+ chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower()
+ return chat_type in ("group", "supergroup")
+
+ def _is_reply_to_bot(self, message: Message) -> bool:
+ if not self._bot or not getattr(message, "reply_to_message", None):
+ return False
+ reply_user = getattr(message.reply_to_message, "from_user", None)
+ return bool(reply_user and getattr(reply_user, "id", None) == getattr(self._bot, "id", None))
+
+ def _message_mentions_bot(self, message: Message) -> bool:
+ if not self._bot:
+ return False
+
+ bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower()
+ bot_id = getattr(self._bot, "id", None)
+
+ def _iter_sources():
+ yield getattr(message, "text", None) or "", getattr(message, "entities", None) or []
+ yield getattr(message, "caption", None) or "", getattr(message, "caption_entities", None) or []
+
+ for source_text, entities in _iter_sources():
+ if bot_username and f"@{bot_username}" in source_text.lower():
+ return True
+ for entity in entities:
+ entity_type = str(getattr(entity, "type", "")).split(".")[-1].lower()
+ if entity_type == "mention" and bot_username:
+ offset = int(getattr(entity, "offset", -1))
+ length = int(getattr(entity, "length", 0))
+ if offset < 0 or length <= 0:
+ continue
+ if source_text[offset:offset + length].strip().lower() == f"@{bot_username}":
+ return True
+ elif entity_type == "text_mention":
+ user = getattr(entity, "user", None)
+ if user and getattr(user, "id", None) == bot_id:
+ return True
+ return False
+
+ def _message_matches_mention_patterns(self, message: Message) -> bool:
+ if not self._mention_patterns:
+ return False
+ for candidate in (getattr(message, "text", None), getattr(message, "caption", None)):
+ if not candidate:
+ continue
+ for pattern in self._mention_patterns:
+ if pattern.search(candidate):
+ return True
+ return False
+
+ def _clean_bot_trigger_text(self, text: Optional[str]) -> Optional[str]:
+ if not text or not self._bot or not getattr(self._bot, "username", None):
+ return text
+ username = re.escape(self._bot.username)
+ cleaned = re.sub(rf"(?i)@{username}\b[,:\-]*\s*", "", text).strip()
+ return cleaned or text
+
+ def _should_process_message(self, message: Message, *, is_command: bool = False) -> bool:
+ """Apply Telegram group trigger rules.
+
+ DMs remain unrestricted. Group/supergroup messages are accepted when:
+ - the chat is explicitly allowlisted in ``free_response_chats``
+ - ``require_mention`` is disabled
+ - the message is a command
+ - the message replies to the bot
+ - the bot is @mentioned
+ - the text/caption matches a configured regex wake-word pattern
+ """
+ if not self._is_group_chat(message):
+ return True
+ if str(getattr(getattr(message, "chat", None), "id", "")) in self._telegram_free_response_chats():
+ return True
+ if not self._telegram_require_mention():
+ return True
+ if is_command:
+ return True
+ if self._is_reply_to_bot(message):
+ return True
+ if self._message_mentions_bot(message):
+ return True
+ return self._message_matches_mention_patterns(message)
+
async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle incoming text messages.
@@ -1324,14 +1522,19 @@ class TelegramAdapter(BasePlatformAdapter):
"""
if not update.message or not update.message.text:
return
+ if not self._should_process_message(update.message):
+ return
event = self._build_message_event(update.message, MessageType.TEXT)
+ event.text = self._clean_bot_trigger_text(event.text)
self._enqueue_text_event(event)
async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle incoming command messages."""
if not update.message or not update.message.text:
return
+ if not self._should_process_message(update.message, is_command=True):
+ return
event = self._build_message_event(update.message, MessageType.COMMAND)
await self.handle_message(event)
@@ -1340,6 +1543,8 @@ class TelegramAdapter(BasePlatformAdapter):
"""Handle incoming location/venue pin messages."""
if not update.message:
return
+ if not self._should_process_message(update.message):
+ return
msg = update.message
venue = getattr(msg, "venue", None)
@@ -1483,6 +1688,8 @@ class TelegramAdapter(BasePlatformAdapter):
"""Handle incoming media messages, downloading images to local cache."""
if not update.message:
return
+ if not self._should_process_message(update.message):
+ return
msg = update.message
@@ -1506,7 +1713,7 @@ class TelegramAdapter(BasePlatformAdapter):
# Add caption as text
if msg.caption:
- event.text = msg.caption
+ event.text = self._clean_bot_trigger_text(msg.caption)
# Handle stickers: describe via vision tool with caching
if msg.sticker:
diff --git a/gateway/platforms/wecom.py b/gateway/platforms/wecom.py
new file mode 100644
index 000000000000..d40b651c5bbb
--- /dev/null
+++ b/gateway/platforms/wecom.py
@@ -0,0 +1,1338 @@
+"""
+WeCom (Enterprise WeChat) platform adapter.
+
+Uses the WeCom AI Bot WebSocket gateway for inbound and outbound messages.
+The adapter focuses on the core gateway path:
+
+- authenticate via ``aibot_subscribe``
+- receive inbound ``aibot_msg_callback`` events
+- send outbound markdown messages via ``aibot_send_msg``
+- upload outbound media via ``aibot_upload_media_*`` and send native attachments
+- best-effort download of inbound image/file attachments for agent context
+
+Configuration in config.yaml:
+ platforms:
+ wecom:
+ enabled: true
+ extra:
+ bot_id: "your-bot-id" # or WECOM_BOT_ID env var
+ secret: "your-secret" # or WECOM_SECRET env var
+ websocket_url: "wss://openws.work.weixin.qq.com"
+ dm_policy: "open" # open | allowlist | disabled | pairing
+ allow_from: ["user_id_1"]
+ group_policy: "open" # open | allowlist | disabled
+ group_allow_from: ["group_id_1"]
+ groups:
+ group_id_1:
+ allow_from: ["user_id_1"]
+"""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+import logging
+import mimetypes
+import os
+import re
+import time
+import uuid
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+from urllib.parse import unquote, urlparse
+
+try:
+ import aiohttp
+ AIOHTTP_AVAILABLE = True
+except ImportError:
+ AIOHTTP_AVAILABLE = False
+ aiohttp = None # type: ignore[assignment]
+
+try:
+ import httpx
+ HTTPX_AVAILABLE = True
+except ImportError:
+ HTTPX_AVAILABLE = False
+ httpx = None # type: ignore[assignment]
+
+from gateway.config import Platform, PlatformConfig
+from gateway.platforms.base import (
+ BasePlatformAdapter,
+ MessageEvent,
+ MessageType,
+ SendResult,
+ cache_document_from_bytes,
+ cache_image_from_bytes,
+)
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_WS_URL = "wss://openws.work.weixin.qq.com"
+
+APP_CMD_SUBSCRIBE = "aibot_subscribe"
+APP_CMD_CALLBACK = "aibot_msg_callback"
+APP_CMD_LEGACY_CALLBACK = "aibot_callback"
+APP_CMD_EVENT_CALLBACK = "aibot_event_callback"
+APP_CMD_SEND = "aibot_send_msg"
+APP_CMD_RESPONSE = "aibot_respond_msg"
+APP_CMD_PING = "ping"
+APP_CMD_UPLOAD_MEDIA_INIT = "aibot_upload_media_init"
+APP_CMD_UPLOAD_MEDIA_CHUNK = "aibot_upload_media_chunk"
+APP_CMD_UPLOAD_MEDIA_FINISH = "aibot_upload_media_finish"
+
+CALLBACK_COMMANDS = {APP_CMD_CALLBACK, APP_CMD_LEGACY_CALLBACK}
+NON_RESPONSE_COMMANDS = CALLBACK_COMMANDS | {APP_CMD_EVENT_CALLBACK}
+
+MAX_MESSAGE_LENGTH = 4000
+CONNECT_TIMEOUT_SECONDS = 20.0
+REQUEST_TIMEOUT_SECONDS = 15.0
+HEARTBEAT_INTERVAL_SECONDS = 30.0
+RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
+
+DEDUP_WINDOW_SECONDS = 300
+DEDUP_MAX_SIZE = 1000
+
+IMAGE_MAX_BYTES = 10 * 1024 * 1024
+VIDEO_MAX_BYTES = 10 * 1024 * 1024
+VOICE_MAX_BYTES = 2 * 1024 * 1024
+FILE_MAX_BYTES = 20 * 1024 * 1024
+ABSOLUTE_MAX_BYTES = FILE_MAX_BYTES
+UPLOAD_CHUNK_SIZE = 512 * 1024
+MAX_UPLOAD_CHUNKS = 100
+VOICE_SUPPORTED_MIMES = {"audio/amr"}
+
+
+def check_wecom_requirements() -> bool:
+ """Check if WeCom runtime dependencies are available."""
+ return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE
+
+
+def _coerce_list(value: Any) -> List[str]:
+ """Coerce config values into a trimmed string list."""
+ if value is None:
+ return []
+ if isinstance(value, str):
+ return [item.strip() for item in value.split(",") if item.strip()]
+ if isinstance(value, (list, tuple, set)):
+ return [str(item).strip() for item in value if str(item).strip()]
+ return [str(value).strip()] if str(value).strip() else []
+
+
+def _normalize_entry(raw: str) -> str:
+ """Normalize allowlist entries such as ``wecom:user:foo``."""
+ value = str(raw).strip()
+ value = re.sub(r"^wecom:", "", value, flags=re.IGNORECASE)
+ value = re.sub(r"^(user|group):", "", value, flags=re.IGNORECASE)
+ return value.strip()
+
+
+def _entry_matches(entries: List[str], target: str) -> bool:
+ """Case-insensitive allowlist match with ``*`` support."""
+ normalized_target = str(target).strip().lower()
+ for entry in entries:
+ normalized = _normalize_entry(entry).lower()
+ if normalized == "*" or normalized == normalized_target:
+ return True
+ return False
+
+
+class WeComAdapter(BasePlatformAdapter):
+ """WeCom AI Bot adapter backed by a persistent WebSocket connection."""
+
+ MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
+
+ def __init__(self, config: PlatformConfig):
+ super().__init__(config, Platform.WECOM)
+
+ extra = config.extra or {}
+ self._bot_id = str(extra.get("bot_id") or os.getenv("WECOM_BOT_ID", "")).strip()
+ self._secret = str(extra.get("secret") or os.getenv("WECOM_SECRET", "")).strip()
+ self._ws_url = str(
+ extra.get("websocket_url")
+ or extra.get("websocketUrl")
+ or os.getenv("WECOM_WEBSOCKET_URL", DEFAULT_WS_URL)
+ ).strip() or DEFAULT_WS_URL
+
+ self._dm_policy = str(extra.get("dm_policy") or os.getenv("WECOM_DM_POLICY", "open")).strip().lower()
+ self._allow_from = _coerce_list(extra.get("allow_from") or extra.get("allowFrom"))
+
+ self._group_policy = str(extra.get("group_policy") or os.getenv("WECOM_GROUP_POLICY", "open")).strip().lower()
+ self._group_allow_from = _coerce_list(extra.get("group_allow_from") or extra.get("groupAllowFrom"))
+ self._groups = extra.get("groups") if isinstance(extra.get("groups"), dict) else {}
+
+ self._session: Optional["aiohttp.ClientSession"] = None
+ self._ws: Optional["aiohttp.ClientWebSocketResponse"] = None
+ self._http_client: Optional["httpx.AsyncClient"] = None
+ self._listen_task: Optional[asyncio.Task] = None
+ self._heartbeat_task: Optional[asyncio.Task] = None
+ self._pending_responses: Dict[str, asyncio.Future] = {}
+ self._seen_messages: Dict[str, float] = {}
+ self._reply_req_ids: Dict[str, str] = {}
+
+ # ------------------------------------------------------------------
+ # Connection lifecycle
+ # ------------------------------------------------------------------
+
+ async def connect(self) -> bool:
+ """Connect to the WeCom AI Bot gateway."""
+ if not AIOHTTP_AVAILABLE:
+ message = "WeCom startup failed: aiohttp not installed"
+ self._set_fatal_error("wecom_missing_dependency", message, retryable=True)
+ logger.warning("[%s] %s. Run: pip install aiohttp", self.name, message)
+ return False
+ if not HTTPX_AVAILABLE:
+ message = "WeCom startup failed: httpx not installed"
+ self._set_fatal_error("wecom_missing_dependency", message, retryable=True)
+ logger.warning("[%s] %s. Run: pip install httpx", self.name, message)
+ return False
+ if not self._bot_id or not self._secret:
+ message = "WeCom startup failed: WECOM_BOT_ID and WECOM_SECRET are required"
+ self._set_fatal_error("wecom_missing_credentials", message, retryable=True)
+ logger.warning("[%s] %s", self.name, message)
+ return False
+
+ try:
+ self._http_client = httpx.AsyncClient(timeout=30.0, follow_redirects=True)
+ await self._open_connection()
+ self._mark_connected()
+ self._listen_task = asyncio.create_task(self._listen_loop())
+ self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
+ logger.info("[%s] Connected to %s", self.name, self._ws_url)
+ return True
+ except Exception as exc:
+ message = f"WeCom startup failed: {exc}"
+ self._set_fatal_error("wecom_connect_error", message, retryable=True)
+ logger.error("[%s] Failed to connect: %s", self.name, exc, exc_info=True)
+ await self._cleanup_ws()
+ if self._http_client:
+ await self._http_client.aclose()
+ self._http_client = None
+ return False
+
+ async def disconnect(self) -> None:
+ """Disconnect from WeCom."""
+ self._running = False
+ self._mark_disconnected()
+
+ if self._listen_task:
+ self._listen_task.cancel()
+ try:
+ await self._listen_task
+ except asyncio.CancelledError:
+ pass
+ self._listen_task = None
+
+ if self._heartbeat_task:
+ self._heartbeat_task.cancel()
+ try:
+ await self._heartbeat_task
+ except asyncio.CancelledError:
+ pass
+ self._heartbeat_task = None
+
+ self._fail_pending_responses(RuntimeError("WeCom adapter disconnected"))
+ await self._cleanup_ws()
+
+ if self._http_client:
+ await self._http_client.aclose()
+ self._http_client = None
+
+ self._seen_messages.clear()
+ logger.info("[%s] Disconnected", self.name)
+
+ async def _cleanup_ws(self) -> None:
+ """Close the live websocket/session, if any."""
+ if self._ws and not self._ws.closed:
+ await self._ws.close()
+ self._ws = None
+
+ if self._session and not self._session.closed:
+ await self._session.close()
+ self._session = None
+
+ async def _open_connection(self) -> None:
+ """Open and authenticate a websocket connection."""
+ await self._cleanup_ws()
+ self._session = aiohttp.ClientSession()
+ self._ws = await self._session.ws_connect(
+ self._ws_url,
+ heartbeat=HEARTBEAT_INTERVAL_SECONDS * 2,
+ timeout=CONNECT_TIMEOUT_SECONDS,
+ )
+
+ req_id = self._new_req_id("subscribe")
+ await self._send_json(
+ {
+ "cmd": APP_CMD_SUBSCRIBE,
+ "headers": {"req_id": req_id},
+ "body": {"bot_id": self._bot_id, "secret": self._secret},
+ }
+ )
+
+ auth_payload = await self._wait_for_handshake(req_id)
+ errcode = auth_payload.get("errcode", 0)
+ if errcode not in (0, None):
+ errmsg = auth_payload.get("errmsg", "authentication failed")
+ raise RuntimeError(f"{errmsg} (errcode={errcode})")
+
+ async def _wait_for_handshake(self, req_id: str) -> Dict[str, Any]:
+ """Wait for the subscribe acknowledgement."""
+ if not self._ws:
+ raise RuntimeError("WebSocket not initialized")
+
+ deadline = asyncio.get_running_loop().time() + CONNECT_TIMEOUT_SECONDS
+ while True:
+ remaining = deadline - asyncio.get_running_loop().time()
+ if remaining <= 0:
+ raise TimeoutError("Timed out waiting for WeCom subscribe acknowledgement")
+
+ msg = await asyncio.wait_for(self._ws.receive(), timeout=remaining)
+ if msg.type == aiohttp.WSMsgType.TEXT:
+ payload = self._parse_json(msg.data)
+ if not payload:
+ continue
+ if payload.get("cmd") == APP_CMD_PING:
+ continue
+ if self._payload_req_id(payload) == req_id:
+ return payload
+ logger.debug("[%s] Ignoring pre-auth payload: %s", self.name, payload.get("cmd"))
+ elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR):
+ raise RuntimeError("WeCom websocket closed during authentication")
+
+ async def _listen_loop(self) -> None:
+ """Read websocket events forever, reconnecting on errors."""
+ backoff_idx = 0
+ while self._running:
+ try:
+ await self._read_events()
+ backoff_idx = 0
+ except asyncio.CancelledError:
+ return
+ except Exception as exc:
+ if not self._running:
+ return
+ logger.warning("[%s] WebSocket error: %s", self.name, exc)
+ self._fail_pending_responses(RuntimeError("WeCom connection interrupted"))
+
+ delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)]
+ backoff_idx += 1
+ await asyncio.sleep(delay)
+
+ try:
+ await self._open_connection()
+ backoff_idx = 0
+ logger.info("[%s] Reconnected", self.name)
+ except Exception as reconnect_exc:
+ logger.warning("[%s] Reconnect failed: %s", self.name, reconnect_exc)
+
+ async def _read_events(self) -> None:
+ """Read websocket frames until the connection closes."""
+ if not self._ws:
+ raise RuntimeError("WebSocket not connected")
+
+ while self._running and self._ws and not self._ws.closed:
+ msg = await self._ws.receive()
+ if msg.type == aiohttp.WSMsgType.TEXT:
+ payload = self._parse_json(msg.data)
+ if payload:
+ await self._dispatch_payload(payload)
+ elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
+ raise RuntimeError("WeCom websocket closed")
+
+ async def _heartbeat_loop(self) -> None:
+ """Send lightweight application-level pings."""
+ try:
+ while self._running:
+ await asyncio.sleep(HEARTBEAT_INTERVAL_SECONDS)
+ if not self._ws or self._ws.closed:
+ continue
+ try:
+ await self._send_json(
+ {
+ "cmd": APP_CMD_PING,
+ "headers": {"req_id": self._new_req_id("ping")},
+ "body": {},
+ }
+ )
+ except Exception as exc:
+ logger.debug("[%s] Heartbeat send failed: %s", self.name, exc)
+ except asyncio.CancelledError:
+ pass
+
+ async def _dispatch_payload(self, payload: Dict[str, Any]) -> None:
+ """Route inbound websocket payloads."""
+ req_id = self._payload_req_id(payload)
+ cmd = str(payload.get("cmd") or "")
+
+ if req_id and req_id in self._pending_responses and cmd not in NON_RESPONSE_COMMANDS:
+ future = self._pending_responses.get(req_id)
+ if future and not future.done():
+ future.set_result(payload)
+ return
+
+ if cmd in CALLBACK_COMMANDS:
+ await self._on_message(payload)
+ return
+ if cmd in {APP_CMD_PING, APP_CMD_EVENT_CALLBACK}:
+ return
+
+ logger.debug("[%s] Ignoring websocket payload: %s", self.name, cmd or payload)
+
+ def _fail_pending_responses(self, exc: Exception) -> None:
+ """Fail all outstanding request futures."""
+ for req_id, future in list(self._pending_responses.items()):
+ if not future.done():
+ future.set_exception(exc)
+ self._pending_responses.pop(req_id, None)
+
+ async def _send_json(self, payload: Dict[str, Any]) -> None:
+ """Send a raw JSON frame over the active websocket."""
+ if not self._ws or self._ws.closed:
+ raise RuntimeError("WeCom websocket is not connected")
+ await self._ws.send_json(payload)
+
+ async def _send_request(self, cmd: str, body: Dict[str, Any], timeout: float = REQUEST_TIMEOUT_SECONDS) -> Dict[str, Any]:
+ """Send a JSON request and await the correlated response."""
+ if not self._ws or self._ws.closed:
+ raise RuntimeError("WeCom websocket is not connected")
+
+ req_id = self._new_req_id(cmd)
+ future = asyncio.get_running_loop().create_future()
+ self._pending_responses[req_id] = future
+ try:
+ await self._send_json({"cmd": cmd, "headers": {"req_id": req_id}, "body": body})
+ response = await asyncio.wait_for(future, timeout=timeout)
+ return response
+ finally:
+ self._pending_responses.pop(req_id, None)
+
+ async def _send_reply_request(
+ self,
+ reply_req_id: str,
+ body: Dict[str, Any],
+ cmd: str = APP_CMD_RESPONSE,
+ timeout: float = REQUEST_TIMEOUT_SECONDS,
+ ) -> Dict[str, Any]:
+ """Send a reply frame correlated to an inbound callback req_id."""
+ if not self._ws or self._ws.closed:
+ raise RuntimeError("WeCom websocket is not connected")
+
+ normalized_req_id = str(reply_req_id or "").strip()
+ if not normalized_req_id:
+ raise ValueError("reply_req_id is required")
+
+ future = asyncio.get_running_loop().create_future()
+ self._pending_responses[normalized_req_id] = future
+ try:
+ await self._send_json(
+ {"cmd": cmd, "headers": {"req_id": normalized_req_id}, "body": body}
+ )
+ response = await asyncio.wait_for(future, timeout=timeout)
+ return response
+ finally:
+ self._pending_responses.pop(normalized_req_id, None)
+
+ @staticmethod
+ def _new_req_id(prefix: str) -> str:
+ return f"{prefix}-{uuid.uuid4().hex}"
+
+ @staticmethod
+ def _payload_req_id(payload: Dict[str, Any]) -> str:
+ headers = payload.get("headers")
+ if isinstance(headers, dict):
+ return str(headers.get("req_id") or "")
+ return ""
+
+ @staticmethod
+ def _parse_json(raw: Any) -> Optional[Dict[str, Any]]:
+ try:
+ payload = json.loads(raw)
+ except Exception:
+ logger.debug("Failed to parse WeCom payload: %r", raw)
+ return None
+ return payload if isinstance(payload, dict) else None
+
+ # ------------------------------------------------------------------
+ # Inbound message parsing
+ # ------------------------------------------------------------------
+
+ async def _on_message(self, payload: Dict[str, Any]) -> None:
+ """Process an inbound WeCom message callback event."""
+ body = payload.get("body")
+ if not isinstance(body, dict):
+ return
+
+ msg_id = str(body.get("msgid") or self._payload_req_id(payload) or uuid.uuid4().hex)
+ if self._is_duplicate(msg_id):
+ logger.debug("[%s] Duplicate message %s ignored", self.name, msg_id)
+ return
+ self._remember_reply_req_id(msg_id, self._payload_req_id(payload))
+
+ sender = body.get("from") if isinstance(body.get("from"), dict) else {}
+ sender_id = str(sender.get("userid") or "").strip()
+ chat_id = str(body.get("chatid") or sender_id).strip()
+ if not chat_id:
+ logger.debug("[%s] Missing chat id, skipping message", self.name)
+ return
+
+ is_group = str(body.get("chattype") or "").lower() == "group"
+ if is_group:
+ if not self._is_group_allowed(chat_id, sender_id):
+ logger.debug("[%s] Group %s / sender %s blocked by policy", self.name, chat_id, sender_id)
+ return
+ elif not self._is_dm_allowed(sender_id):
+ logger.debug("[%s] DM sender %s blocked by policy", self.name, sender_id)
+ return
+
+ text, reply_text = self._extract_text(body)
+ media_urls, media_types = await self._extract_media(body)
+ message_type = self._derive_message_type(body, text, media_types)
+ has_reply_context = bool(reply_text and (text or media_urls))
+
+ if not text and reply_text and not media_urls:
+ text = reply_text
+
+ if not text and not media_urls:
+ logger.debug("[%s] Empty WeCom message skipped", self.name)
+ return
+
+ source = self.build_source(
+ chat_id=chat_id,
+ chat_type="group" if is_group else "dm",
+ user_id=sender_id or None,
+ user_name=sender_id or None,
+ )
+
+ event = MessageEvent(
+ text=text,
+ message_type=message_type,
+ source=source,
+ raw_message=payload,
+ message_id=msg_id,
+ media_urls=media_urls,
+ media_types=media_types,
+ reply_to_message_id=f"quote:{msg_id}" if has_reply_context else None,
+ reply_to_text=reply_text if has_reply_context else None,
+ timestamp=datetime.now(tz=timezone.utc),
+ )
+
+ await self.handle_message(event)
+
+ @staticmethod
+ def _extract_text(body: Dict[str, Any]) -> Tuple[str, Optional[str]]:
+ """Extract plain text and quoted text from a callback payload."""
+ text_parts: List[str] = []
+ reply_text: Optional[str] = None
+ msgtype = str(body.get("msgtype") or "").lower()
+
+ if msgtype == "mixed":
+ mixed = body.get("mixed") if isinstance(body.get("mixed"), dict) else {}
+ items = mixed.get("msg_item") if isinstance(mixed.get("msg_item"), list) else []
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ if str(item.get("msgtype") or "").lower() == "text":
+ text_block = item.get("text") if isinstance(item.get("text"), dict) else {}
+ content = str(text_block.get("content") or "").strip()
+ if content:
+ text_parts.append(content)
+ else:
+ text_block = body.get("text") if isinstance(body.get("text"), dict) else {}
+ content = str(text_block.get("content") or "").strip()
+ if content:
+ text_parts.append(content)
+
+ if msgtype == "voice":
+ voice_block = body.get("voice") if isinstance(body.get("voice"), dict) else {}
+ voice_text = str(voice_block.get("content") or "").strip()
+ if voice_text:
+ text_parts.append(voice_text)
+
+ quote = body.get("quote") if isinstance(body.get("quote"), dict) else {}
+ quote_type = str(quote.get("msgtype") or "").lower()
+ if quote_type == "text":
+ quote_text = quote.get("text") if isinstance(quote.get("text"), dict) else {}
+ reply_text = str(quote_text.get("content") or "").strip() or None
+ elif quote_type == "voice":
+ quote_voice = quote.get("voice") if isinstance(quote.get("voice"), dict) else {}
+ reply_text = str(quote_voice.get("content") or "").strip() or None
+
+ return "\n".join(part for part in text_parts if part).strip(), reply_text
+
+ async def _extract_media(self, body: Dict[str, Any]) -> Tuple[List[str], List[str]]:
+ """Best-effort extraction of inbound media to local cache paths."""
+ media_paths: List[str] = []
+ media_types: List[str] = []
+ refs: List[Tuple[str, Dict[str, Any]]] = []
+ msgtype = str(body.get("msgtype") or "").lower()
+
+ if msgtype == "mixed":
+ mixed = body.get("mixed") if isinstance(body.get("mixed"), dict) else {}
+ items = mixed.get("msg_item") if isinstance(mixed.get("msg_item"), list) else []
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ item_type = str(item.get("msgtype") or "").lower()
+ if item_type == "image" and isinstance(item.get("image"), dict):
+ refs.append(("image", item["image"]))
+ else:
+ if isinstance(body.get("image"), dict):
+ refs.append(("image", body["image"]))
+ if msgtype == "file" and isinstance(body.get("file"), dict):
+ refs.append(("file", body["file"]))
+
+ quote = body.get("quote") if isinstance(body.get("quote"), dict) else {}
+ quote_type = str(quote.get("msgtype") or "").lower()
+ if quote_type == "image" and isinstance(quote.get("image"), dict):
+ refs.append(("image", quote["image"]))
+ elif quote_type == "file" and isinstance(quote.get("file"), dict):
+ refs.append(("file", quote["file"]))
+
+ for kind, ref in refs:
+ cached = await self._cache_media(kind, ref)
+ if cached:
+ path, content_type = cached
+ media_paths.append(path)
+ media_types.append(content_type)
+
+ return media_paths, media_types
+
+ async def _cache_media(self, kind: str, media: Dict[str, Any]) -> Optional[Tuple[str, str]]:
+ """Cache an inbound image/file/media reference to local storage."""
+ if "base64" in media and media.get("base64"):
+ try:
+ raw = self._decode_base64(media["base64"])
+ except Exception as exc:
+ logger.debug("[%s] Failed to decode %s base64 media: %s", self.name, kind, exc)
+ return None
+
+ if kind == "image":
+ ext = self._detect_image_ext(raw)
+ return cache_image_from_bytes(raw, ext), self._mime_for_ext(ext, fallback="image/jpeg")
+
+ filename = str(media.get("filename") or media.get("name") or "wecom_file")
+ return cache_document_from_bytes(raw, filename), mimetypes.guess_type(filename)[0] or "application/octet-stream"
+
+ url = str(media.get("url") or "").strip()
+ if not url:
+ return None
+
+ try:
+ raw, headers = await self._download_remote_bytes(url, max_bytes=ABSOLUTE_MAX_BYTES)
+ except Exception as exc:
+ logger.debug("[%s] Failed to download %s from %s: %s", self.name, kind, url, exc)
+ return None
+
+ aes_key = str(media.get("aeskey") or "").strip()
+ if aes_key:
+ try:
+ raw = self._decrypt_file_bytes(raw, aes_key)
+ except Exception as exc:
+ logger.debug("[%s] Failed to decrypt %s from %s: %s", self.name, kind, url, exc)
+ return None
+
+ content_type = str(headers.get("content-type") or "").split(";", 1)[0].strip() or "application/octet-stream"
+ if kind == "image":
+ ext = self._guess_extension(url, content_type, fallback=self._detect_image_ext(raw))
+ return cache_image_from_bytes(raw, ext), content_type or self._mime_for_ext(ext, fallback="image/jpeg")
+
+ filename = self._guess_filename(url, headers.get("content-disposition"), content_type)
+ return cache_document_from_bytes(raw, filename), content_type
+
+ @staticmethod
+ def _decode_base64(data: str) -> bytes:
+ payload = data.split(",", 1)[-1].strip()
+ return base64.b64decode(payload)
+
+ @staticmethod
+ def _detect_image_ext(data: bytes) -> str:
+ if data.startswith(b"\x89PNG\r\n\x1a\n"):
+ return ".png"
+ if data.startswith(b"\xff\xd8\xff"):
+ return ".jpg"
+ if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
+ return ".gif"
+ if data.startswith(b"RIFF") and data[8:12] == b"WEBP":
+ return ".webp"
+ return ".jpg"
+
+ @staticmethod
+ def _mime_for_ext(ext: str, fallback: str = "application/octet-stream") -> str:
+ return mimetypes.types_map.get(ext.lower(), fallback)
+
+ @staticmethod
+ def _guess_extension(url: str, content_type: str, fallback: str) -> str:
+ ext = mimetypes.guess_extension(content_type) if content_type else None
+ if ext:
+ return ext
+ path_ext = Path(urlparse(url).path).suffix
+ if path_ext:
+ return path_ext
+ return fallback
+
+ @staticmethod
+ def _guess_filename(url: str, content_disposition: Optional[str], content_type: str) -> str:
+ if content_disposition:
+ match = re.search(r'filename="?([^";]+)"?', content_disposition)
+ if match:
+ return match.group(1)
+
+ name = Path(urlparse(url).path).name or "document"
+ if "." not in name:
+ ext = mimetypes.guess_extension(content_type) or ".bin"
+ name = f"{name}{ext}"
+ return name
+
+ @staticmethod
+ def _derive_message_type(body: Dict[str, Any], text: str, media_types: List[str]) -> MessageType:
+ """Choose the normalized inbound message type."""
+ if any(mtype.startswith("application/") or mtype.startswith("text/") for mtype in media_types):
+ return MessageType.DOCUMENT
+ if any(mtype.startswith("image/") for mtype in media_types):
+ return MessageType.TEXT if text else MessageType.PHOTO
+ if str(body.get("msgtype") or "").lower() == "voice":
+ return MessageType.VOICE
+ return MessageType.TEXT
+
+ # ------------------------------------------------------------------
+ # Policy helpers
+ # ------------------------------------------------------------------
+
+ def _is_dm_allowed(self, sender_id: str) -> bool:
+ if self._dm_policy == "disabled":
+ return False
+ if self._dm_policy == "allowlist":
+ return _entry_matches(self._allow_from, sender_id)
+ return True
+
+ def _is_group_allowed(self, chat_id: str, sender_id: str) -> bool:
+ if self._group_policy == "disabled":
+ return False
+ if self._group_policy == "allowlist" and not _entry_matches(self._group_allow_from, chat_id):
+ return False
+
+ group_cfg = self._resolve_group_cfg(chat_id)
+ sender_allow = _coerce_list(group_cfg.get("allow_from") or group_cfg.get("allowFrom"))
+ if sender_allow:
+ return _entry_matches(sender_allow, sender_id)
+ return True
+
+ def _resolve_group_cfg(self, chat_id: str) -> Dict[str, Any]:
+ if not isinstance(self._groups, dict):
+ return {}
+ if chat_id in self._groups and isinstance(self._groups[chat_id], dict):
+ return self._groups[chat_id]
+ lowered = chat_id.lower()
+ for key, value in self._groups.items():
+ if isinstance(key, str) and key.lower() == lowered and isinstance(value, dict):
+ return value
+ wildcard = self._groups.get("*")
+ return wildcard if isinstance(wildcard, dict) else {}
+
+ def _is_duplicate(self, msg_id: str) -> bool:
+ now = time.time()
+ if len(self._seen_messages) > DEDUP_MAX_SIZE:
+ cutoff = now - DEDUP_WINDOW_SECONDS
+ self._seen_messages = {
+ key: ts for key, ts in self._seen_messages.items() if ts > cutoff
+ }
+ if self._reply_req_ids:
+ self._reply_req_ids = {
+ key: value for key, value in self._reply_req_ids.items() if key in self._seen_messages
+ }
+
+ if msg_id in self._seen_messages:
+ return True
+
+ self._seen_messages[msg_id] = now
+ return False
+
+ def _remember_reply_req_id(self, message_id: str, req_id: str) -> None:
+ normalized_message_id = str(message_id or "").strip()
+ normalized_req_id = str(req_id or "").strip()
+ if not normalized_message_id or not normalized_req_id:
+ return
+ self._reply_req_ids[normalized_message_id] = normalized_req_id
+ while len(self._reply_req_ids) > DEDUP_MAX_SIZE:
+ self._reply_req_ids.pop(next(iter(self._reply_req_ids)))
+
+ def _reply_req_id_for_message(self, reply_to: Optional[str]) -> Optional[str]:
+ normalized = str(reply_to or "").strip()
+ if not normalized or normalized.startswith("quote:"):
+ return None
+ return self._reply_req_ids.get(normalized)
+
+ # ------------------------------------------------------------------
+ # Outbound messaging
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _guess_mime_type(filename: str) -> str:
+ mime_type = mimetypes.guess_type(filename)[0]
+ if mime_type:
+ return mime_type
+ if Path(filename).suffix.lower() == ".amr":
+ return "audio/amr"
+ return "application/octet-stream"
+
+ @staticmethod
+ def _normalize_content_type(content_type: str, filename: str) -> str:
+ normalized = str(content_type or "").split(";", 1)[0].strip().lower()
+ guessed = WeComAdapter._guess_mime_type(filename)
+ if not normalized:
+ return guessed
+ if normalized in {"application/octet-stream", "text/plain"}:
+ return guessed
+ return normalized
+
+ @staticmethod
+ def _detect_wecom_media_type(content_type: str) -> str:
+ mime_type = str(content_type or "").strip().lower()
+ if mime_type.startswith("image/"):
+ return "image"
+ if mime_type.startswith("video/"):
+ return "video"
+ if mime_type.startswith("audio/") or mime_type == "application/ogg":
+ return "voice"
+ return "file"
+
+ @staticmethod
+ def _apply_file_size_limits(file_size: int, detected_type: str, content_type: Optional[str] = None) -> Dict[str, Any]:
+ file_size_mb = file_size / (1024 * 1024)
+ normalized_type = str(detected_type or "file").lower()
+ normalized_content_type = str(content_type or "").strip().lower()
+
+ if file_size > ABSOLUTE_MAX_BYTES:
+ return {
+ "final_type": normalized_type,
+ "rejected": True,
+ "reject_reason": (
+ f"文件大小 {file_size_mb:.2f}MB 超过了企业微信允许的最大限制 20MB,无法发送。"
+ "请尝试压缩文件或减小文件大小。"
+ ),
+ "downgraded": False,
+ "downgrade_note": None,
+ }
+
+ if normalized_type == "image" and file_size > IMAGE_MAX_BYTES:
+ return {
+ "final_type": "file",
+ "rejected": False,
+ "reject_reason": None,
+ "downgraded": True,
+ "downgrade_note": f"图片大小 {file_size_mb:.2f}MB 超过 10MB 限制,已转为文件格式发送",
+ }
+
+ if normalized_type == "video" and file_size > VIDEO_MAX_BYTES:
+ return {
+ "final_type": "file",
+ "rejected": False,
+ "reject_reason": None,
+ "downgraded": True,
+ "downgrade_note": f"视频大小 {file_size_mb:.2f}MB 超过 10MB 限制,已转为文件格式发送",
+ }
+
+ if normalized_type == "voice":
+ if normalized_content_type and normalized_content_type not in VOICE_SUPPORTED_MIMES:
+ return {
+ "final_type": "file",
+ "rejected": False,
+ "reject_reason": None,
+ "downgraded": True,
+ "downgrade_note": (
+ f"语音格式 {normalized_content_type} 不支持,企微仅支持 AMR 格式,已转为文件格式发送"
+ ),
+ }
+ if file_size > VOICE_MAX_BYTES:
+ return {
+ "final_type": "file",
+ "rejected": False,
+ "reject_reason": None,
+ "downgraded": True,
+ "downgrade_note": f"语音大小 {file_size_mb:.2f}MB 超过 2MB 限制,已转为文件格式发送",
+ }
+
+ return {
+ "final_type": normalized_type,
+ "rejected": False,
+ "reject_reason": None,
+ "downgraded": False,
+ "downgrade_note": None,
+ }
+
+ @staticmethod
+ def _response_error(response: Dict[str, Any]) -> Optional[str]:
+ errcode = response.get("errcode", 0)
+ if errcode in (0, None):
+ return None
+ errmsg = str(response.get("errmsg") or "unknown error")
+ return f"WeCom errcode {errcode}: {errmsg}"
+
+ @classmethod
+ def _raise_for_wecom_error(cls, response: Dict[str, Any], operation: str) -> None:
+ error = cls._response_error(response)
+ if error:
+ raise RuntimeError(f"{operation} failed: {error}")
+
+ @staticmethod
+ def _decrypt_file_bytes(encrypted_data: bytes, aes_key: str) -> bytes:
+ if not encrypted_data:
+ raise ValueError("encrypted_data is empty")
+ if not aes_key:
+ raise ValueError("aes_key is required")
+
+ key = base64.b64decode(aes_key)
+ if len(key) != 32:
+ raise ValueError(f"Invalid WeCom AES key length: expected 32 bytes, got {len(key)}")
+
+ try:
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+ except ImportError as exc: # pragma: no cover - dependency is environment-specific
+ raise RuntimeError("cryptography is required for WeCom media decryption") from exc
+
+ cipher = Cipher(algorithms.AES(key), modes.CBC(key[:16]))
+ decryptor = cipher.decryptor()
+ decrypted = decryptor.update(encrypted_data) + decryptor.finalize()
+
+ pad_len = decrypted[-1]
+ if pad_len < 1 or pad_len > 32 or pad_len > len(decrypted):
+ raise ValueError(f"Invalid PKCS#7 padding value: {pad_len}")
+ if any(byte != pad_len for byte in decrypted[-pad_len:]):
+ raise ValueError("Invalid PKCS#7 padding: padding bytes mismatch")
+
+ return decrypted[:-pad_len]
+
+ async def _download_remote_bytes(
+ self,
+ url: str,
+ max_bytes: int,
+ ) -> Tuple[bytes, Dict[str, str]]:
+ if not HTTPX_AVAILABLE:
+ raise RuntimeError("httpx is required for WeCom media download")
+
+ client = self._http_client or httpx.AsyncClient(timeout=30.0, follow_redirects=True)
+ created_client = client is not self._http_client
+ try:
+ async with client.stream(
+ "GET",
+ url,
+ headers={
+ "User-Agent": "HermesAgent/1.0",
+ "Accept": "*/*",
+ },
+ ) as response:
+ response.raise_for_status()
+ headers = {key.lower(): value for key, value in response.headers.items()}
+ content_length = headers.get("content-length")
+ if content_length and content_length.isdigit() and int(content_length) > max_bytes:
+ raise ValueError(
+ f"Remote media exceeds WeCom limit: {int(content_length)} bytes > {max_bytes} bytes"
+ )
+
+ data = bytearray()
+ async for chunk in response.aiter_bytes():
+ data.extend(chunk)
+ if len(data) > max_bytes:
+ raise ValueError(
+ f"Remote media exceeds WeCom limit while downloading: {len(data)} bytes > {max_bytes} bytes"
+ )
+
+ return bytes(data), headers
+ finally:
+ if created_client:
+ await client.aclose()
+
+ @staticmethod
+ def _looks_like_url(media_source: str) -> bool:
+ parsed = urlparse(str(media_source or ""))
+ return parsed.scheme in {"http", "https"}
+
+ async def _load_outbound_media(
+ self,
+ media_source: str,
+ file_name: Optional[str] = None,
+ ) -> Tuple[bytes, str, str]:
+ source = str(media_source or "").strip()
+ if not source:
+ raise ValueError("media source is required")
+ if re.fullmatch(r"<[^>\n]+>", source):
+ raise ValueError(f"Media placeholder was not replaced with a real file path: {source}")
+
+ parsed = urlparse(source)
+ if parsed.scheme in {"http", "https"}:
+ data, headers = await self._download_remote_bytes(source, max_bytes=ABSOLUTE_MAX_BYTES)
+ content_disposition = headers.get("content-disposition")
+ resolved_name = file_name or self._guess_filename(source, content_disposition, headers.get("content-type", ""))
+ content_type = self._normalize_content_type(headers.get("content-type", ""), resolved_name)
+ return data, content_type, resolved_name
+
+ if parsed.scheme == "file":
+ local_path = Path(unquote(parsed.path)).expanduser()
+ else:
+ local_path = Path(source).expanduser()
+
+ if not local_path.is_absolute():
+ local_path = (Path.cwd() / local_path).resolve()
+
+ if not local_path.exists() or not local_path.is_file():
+ raise FileNotFoundError(f"Media file not found: {local_path}")
+
+ data = local_path.read_bytes()
+ resolved_name = file_name or local_path.name
+ content_type = self._normalize_content_type("", resolved_name)
+ return data, content_type, resolved_name
+
+ async def _prepare_outbound_media(
+ self,
+ media_source: str,
+ file_name: Optional[str] = None,
+ ) -> Dict[str, Any]:
+ data, content_type, resolved_name = await self._load_outbound_media(media_source, file_name=file_name)
+ detected_type = self._detect_wecom_media_type(content_type)
+ size_check = self._apply_file_size_limits(len(data), detected_type, content_type)
+ return {
+ "data": data,
+ "content_type": content_type,
+ "file_name": resolved_name,
+ "detected_type": detected_type,
+ **size_check,
+ }
+
+ async def _upload_media_bytes(self, data: bytes, media_type: str, filename: str) -> Dict[str, Any]:
+ if not data:
+ raise ValueError("Cannot upload empty media")
+
+ total_size = len(data)
+ total_chunks = (total_size + UPLOAD_CHUNK_SIZE - 1) // UPLOAD_CHUNK_SIZE
+ if total_chunks > MAX_UPLOAD_CHUNKS:
+ raise ValueError(
+ f"File too large: {total_chunks} chunks exceeds maximum of {MAX_UPLOAD_CHUNKS} chunks"
+ )
+
+ init_response = await self._send_request(
+ APP_CMD_UPLOAD_MEDIA_INIT,
+ {
+ "type": media_type,
+ "filename": filename,
+ "total_size": total_size,
+ "total_chunks": total_chunks,
+ "md5": hashlib.md5(data).hexdigest(),
+ },
+ )
+ self._raise_for_wecom_error(init_response, "media upload init")
+
+ init_body = init_response.get("body") if isinstance(init_response.get("body"), dict) else {}
+ upload_id = str(init_body.get("upload_id") or "").strip()
+ if not upload_id:
+ raise RuntimeError(f"media upload init failed: missing upload_id in response {init_response}")
+
+ for chunk_index, start in enumerate(range(0, total_size, UPLOAD_CHUNK_SIZE)):
+ chunk = data[start : start + UPLOAD_CHUNK_SIZE]
+ chunk_response = await self._send_request(
+ APP_CMD_UPLOAD_MEDIA_CHUNK,
+ {
+ "upload_id": upload_id,
+ # Match the official SDK implementation, which currently uses 0-based chunk indexes.
+ "chunk_index": chunk_index,
+ "base64_data": base64.b64encode(chunk).decode("ascii"),
+ },
+ )
+ self._raise_for_wecom_error(chunk_response, f"media upload chunk {chunk_index}")
+
+ finish_response = await self._send_request(
+ APP_CMD_UPLOAD_MEDIA_FINISH,
+ {"upload_id": upload_id},
+ )
+ self._raise_for_wecom_error(finish_response, "media upload finish")
+
+ finish_body = finish_response.get("body") if isinstance(finish_response.get("body"), dict) else {}
+ media_id = str(finish_body.get("media_id") or "").strip()
+ if not media_id:
+ raise RuntimeError(f"media upload finish failed: missing media_id in response {finish_response}")
+
+ return {
+ "type": str(finish_body.get("type") or media_type),
+ "media_id": media_id,
+ "created_at": finish_body.get("created_at"),
+ }
+
+ async def _send_media_message(self, chat_id: str, media_type: str, media_id: str) -> Dict[str, Any]:
+ response = await self._send_request(
+ APP_CMD_SEND,
+ {
+ "chatid": chat_id,
+ "msgtype": media_type,
+ media_type: {"media_id": media_id},
+ },
+ )
+ self._raise_for_wecom_error(response, "send media message")
+ return response
+
+ async def _send_reply_stream(self, reply_req_id: str, content: str) -> Dict[str, Any]:
+ response = await self._send_reply_request(
+ reply_req_id,
+ {
+ "msgtype": "stream",
+ "stream": {
+ "id": self._new_req_id("stream"),
+ "finish": True,
+ "content": content[:self.MAX_MESSAGE_LENGTH],
+ },
+ },
+ )
+ self._raise_for_wecom_error(response, "send reply stream")
+ return response
+
+ async def _send_reply_media_message(
+ self,
+ reply_req_id: str,
+ media_type: str,
+ media_id: str,
+ ) -> Dict[str, Any]:
+ response = await self._send_reply_request(
+ reply_req_id,
+ {
+ "msgtype": media_type,
+ media_type: {"media_id": media_id},
+ },
+ )
+ self._raise_for_wecom_error(response, "send reply media message")
+ return response
+
+ async def _send_followup_markdown(
+ self,
+ chat_id: str,
+ content: str,
+ reply_to: Optional[str] = None,
+ ) -> Optional[SendResult]:
+ if not content:
+ return None
+ result = await self.send(chat_id=chat_id, content=content, reply_to=reply_to)
+ if not result.success:
+ logger.warning("[%s] Follow-up markdown send failed: %s", self.name, result.error)
+ return result
+
+ async def _send_media_source(
+ self,
+ chat_id: str,
+ media_source: str,
+ caption: Optional[str] = None,
+ file_name: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ ) -> SendResult:
+ if not chat_id:
+ return SendResult(success=False, error="chat_id is required")
+
+ try:
+ prepared = await self._prepare_outbound_media(media_source, file_name=file_name)
+ except FileNotFoundError as exc:
+ return SendResult(success=False, error=str(exc))
+ except Exception as exc:
+ logger.error("[%s] Failed to prepare outbound media %s: %s", self.name, media_source, exc)
+ return SendResult(success=False, error=str(exc))
+
+ if prepared["rejected"]:
+ await self._send_followup_markdown(
+ chat_id,
+ f"⚠️ {prepared['reject_reason']}",
+ reply_to=reply_to,
+ )
+ return SendResult(success=False, error=prepared["reject_reason"])
+
+ reply_req_id = self._reply_req_id_for_message(reply_to)
+ try:
+ upload_result = await self._upload_media_bytes(
+ prepared["data"],
+ prepared["final_type"],
+ prepared["file_name"],
+ )
+ if reply_req_id:
+ media_response = await self._send_reply_media_message(
+ reply_req_id,
+ prepared["final_type"],
+ upload_result["media_id"],
+ )
+ else:
+ media_response = await self._send_media_message(
+ chat_id,
+ prepared["final_type"],
+ upload_result["media_id"],
+ )
+ except asyncio.TimeoutError:
+ return SendResult(success=False, error="Timeout sending media to WeCom")
+ except Exception as exc:
+ logger.error("[%s] Failed to send media %s: %s", self.name, media_source, exc)
+ return SendResult(success=False, error=str(exc))
+
+ caption_result = None
+ downgrade_result = None
+ if caption:
+ caption_result = await self._send_followup_markdown(
+ chat_id,
+ caption,
+ reply_to=reply_to,
+ )
+ if prepared["downgraded"] and prepared["downgrade_note"]:
+ downgrade_result = await self._send_followup_markdown(
+ chat_id,
+ f"ℹ️ {prepared['downgrade_note']}",
+ reply_to=reply_to,
+ )
+
+ return SendResult(
+ success=True,
+ message_id=self._payload_req_id(media_response) or uuid.uuid4().hex[:12],
+ raw_response={
+ "upload": upload_result,
+ "media": media_response,
+ "caption": caption_result.raw_response if caption_result else None,
+ "caption_error": caption_result.error if caption_result and not caption_result.success else None,
+ "downgrade": downgrade_result.raw_response if downgrade_result else None,
+ "downgrade_error": downgrade_result.error if downgrade_result and not downgrade_result.success else None,
+ },
+ )
+
+ async def send(
+ self,
+ chat_id: str,
+ content: str,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Send markdown to a WeCom chat via proactive ``aibot_send_msg``."""
+ del metadata
+
+ if not chat_id:
+ return SendResult(success=False, error="chat_id is required")
+
+ try:
+ reply_req_id = self._reply_req_id_for_message(reply_to)
+ if reply_req_id:
+ response = await self._send_reply_stream(reply_req_id, content)
+ else:
+ response = await self._send_request(
+ APP_CMD_SEND,
+ {
+ "chatid": chat_id,
+ "msgtype": "markdown",
+ "markdown": {"content": content[:self.MAX_MESSAGE_LENGTH]},
+ },
+ )
+ except asyncio.TimeoutError:
+ return SendResult(success=False, error="Timeout sending message to WeCom")
+ except Exception as exc:
+ logger.error("[%s] Send failed: %s", self.name, exc)
+ return SendResult(success=False, error=str(exc))
+
+ error = self._response_error(response)
+ if error:
+ return SendResult(success=False, error=error)
+
+ return SendResult(
+ success=True,
+ message_id=self._payload_req_id(response) or uuid.uuid4().hex[:12],
+ raw_response=response,
+ )
+
+ async def send_image(
+ self,
+ chat_id: str,
+ image_url: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ del metadata
+
+ result = await self._send_media_source(
+ chat_id=chat_id,
+ media_source=image_url,
+ caption=caption,
+ reply_to=reply_to,
+ )
+ if result.success or not self._looks_like_url(image_url):
+ return result
+
+ logger.warning("[%s] Falling back to text send for image URL %s: %s", self.name, image_url, result.error)
+ fallback_text = f"{caption}\n{image_url}" if caption else image_url
+ return await self.send(chat_id=chat_id, content=fallback_text, reply_to=reply_to)
+
+ async def send_image_file(
+ self,
+ chat_id: str,
+ image_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ **kwargs,
+ ) -> SendResult:
+ del kwargs
+ return await self._send_media_source(
+ chat_id=chat_id,
+ media_source=image_path,
+ caption=caption,
+ reply_to=reply_to,
+ )
+
+ async def send_document(
+ self,
+ chat_id: str,
+ file_path: str,
+ caption: Optional[str] = None,
+ file_name: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ **kwargs,
+ ) -> SendResult:
+ del kwargs
+ return await self._send_media_source(
+ chat_id=chat_id,
+ media_source=file_path,
+ caption=caption,
+ file_name=file_name,
+ reply_to=reply_to,
+ )
+
+ async def send_voice(
+ self,
+ chat_id: str,
+ audio_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ **kwargs,
+ ) -> SendResult:
+ del kwargs
+ return await self._send_media_source(
+ chat_id=chat_id,
+ media_source=audio_path,
+ caption=caption,
+ reply_to=reply_to,
+ )
+
+ async def send_video(
+ self,
+ chat_id: str,
+ video_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ **kwargs,
+ ) -> SendResult:
+ del kwargs
+ return await self._send_media_source(
+ chat_id=chat_id,
+ media_source=video_path,
+ caption=caption,
+ reply_to=reply_to,
+ )
+
+ async def send_typing(self, chat_id: str, metadata=None) -> None:
+ """WeCom does not expose typing indicators in this adapter."""
+ del chat_id, metadata
+
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
+ """Return minimal chat info."""
+ return {
+ "name": chat_id,
+ "type": "group" if chat_id and chat_id.lower().startswith("group") else "dm",
+ }
diff --git a/gateway/run.py b/gateway/run.py
index 07eaa84d1fac..3b51930421fd 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -225,6 +225,49 @@ from gateway.session import (
from gateway.delivery import DeliveryRouter
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType
+
+def _normalize_whatsapp_identifier(value: str) -> str:
+ """Strip WhatsApp JID/LID syntax down to its stable numeric identifier."""
+ return (
+ str(value or "")
+ .strip()
+ .replace("+", "", 1)
+ .split(":", 1)[0]
+ .split("@", 1)[0]
+ )
+
+
+def _expand_whatsapp_auth_aliases(identifier: str) -> set:
+ """Resolve WhatsApp phone/LID aliases using bridge session mapping files."""
+ normalized = _normalize_whatsapp_identifier(identifier)
+ if not normalized:
+ return set()
+
+ session_dir = _hermes_home / "whatsapp" / "session"
+ resolved = set()
+ queue = [normalized]
+
+ while queue:
+ current = queue.pop(0)
+ if not current or current in resolved:
+ continue
+
+ resolved.add(current)
+ for suffix in ("", "_reverse"):
+ mapping_path = session_dir / f"lid-mapping-{current}{suffix}.json"
+ if not mapping_path.exists():
+ continue
+ try:
+ mapped = _normalize_whatsapp_identifier(
+ json.loads(mapping_path.read_text(encoding="utf-8"))
+ )
+ except Exception:
+ continue
+ if mapped and mapped not in resolved:
+ queue.append(mapped)
+
+ return resolved
+
logger = logging.getLogger(__name__)
# Sentinel placed into _running_agents immediately when a session starts
@@ -280,10 +323,10 @@ def _resolve_gateway_model(config: dict | None = None) -> str:
"""Read model from env/config — mirrors the resolution in _run_agent_sync.
Without this, temporary AIAgent instances (memory flush, /compress) fall
- back to the hardcoded default ("anthropic/claude-opus-4.6") which fails
- when the active provider is openai-codex.
+ back to the hardcoded default which fails when the active provider is
+ openai-codex.
"""
- model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6"
+ model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or ""
cfg = config if config is not None else _load_gateway_config()
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, str):
@@ -983,6 +1026,8 @@ class GatewayRunner:
"EMAIL_ALLOWED_USERS",
"SMS_ALLOWED_USERS", "MATTERMOST_ALLOWED_USERS",
"MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS",
+ "FEISHU_ALLOWED_USERS",
+ "WECOM_ALLOWED_USERS",
"GATEWAY_ALLOWED_USERS")
)
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any(
@@ -991,7 +1036,9 @@ class GatewayRunner:
"WHATSAPP_ALLOW_ALL_USERS", "SLACK_ALLOW_ALL_USERS",
"SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS",
"SMS_ALLOW_ALL_USERS", "MATTERMOST_ALLOW_ALL_USERS",
- "MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS")
+ "MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS",
+ "FEISHU_ALLOW_ALL_USERS",
+ "WECOM_ALLOW_ALL_USERS")
)
if not _any_allowlist and not _allow_all:
logger.warning(
@@ -1434,6 +1481,20 @@ class GatewayRunner:
return None
return DingTalkAdapter(config)
+ elif platform == Platform.FEISHU:
+ from gateway.platforms.feishu import FeishuAdapter, check_feishu_requirements
+ if not check_feishu_requirements():
+ logger.warning("Feishu: lark-oapi not installed or FEISHU_APP_ID/SECRET not set")
+ return None
+ return FeishuAdapter(config)
+
+ elif platform == Platform.WECOM:
+ from gateway.platforms.wecom import WeComAdapter, check_wecom_requirements
+ if not check_wecom_requirements():
+ logger.warning("WeCom: aiohttp not installed or WECOM_BOT_ID/SECRET not set")
+ return None
+ return WeComAdapter(config)
+
elif platform == Platform.MATTERMOST:
from gateway.platforms.mattermost import MattermostAdapter, check_mattermost_requirements
if not check_mattermost_requirements():
@@ -1500,6 +1561,8 @@ class GatewayRunner:
Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS",
Platform.MATRIX: "MATRIX_ALLOWED_USERS",
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
+ Platform.FEISHU: "FEISHU_ALLOWED_USERS",
+ Platform.WECOM: "WECOM_ALLOWED_USERS",
}
platform_allow_all_map = {
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
@@ -1512,6 +1575,8 @@ class GatewayRunner:
Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS",
Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS",
Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS",
+ Platform.FEISHU: "FEISHU_ALLOW_ALL_USERS",
+ Platform.WECOM: "WECOM_ALLOW_ALL_USERS",
}
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
@@ -1539,10 +1604,23 @@ class GatewayRunner:
if global_allowlist:
allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip())
- # WhatsApp JIDs have @s.whatsapp.net suffix — strip it for comparison
check_ids = {user_id}
if "@" in user_id:
check_ids.add(user_id.split("@")[0])
+
+ # WhatsApp: resolve phone↔LID aliases from bridge session mapping files
+ if source.platform == Platform.WHATSAPP:
+ normalized_allowed_ids = set()
+ for allowed_id in allowed_ids:
+ normalized_allowed_ids.update(_expand_whatsapp_auth_aliases(allowed_id))
+ if normalized_allowed_ids:
+ allowed_ids = normalized_allowed_ids
+
+ check_ids.update(_expand_whatsapp_auth_aliases(user_id))
+ normalized_user_id = _normalize_whatsapp_identifier(user_id)
+ if normalized_user_id:
+ check_ids.add(normalized_user_id)
+
return bool(check_ids & allowed_ids)
def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str:
@@ -3813,7 +3891,7 @@ class GatewayRunner:
# Send media files
for media_path in (media_files or []):
try:
- await adapter.send_file(
+ await adapter.send_document(
chat_id=source.chat_id,
file_path=media_path,
)
@@ -4941,6 +5019,14 @@ class GatewayRunner:
from hermes_cli.tools_config import _get_platform_tools
enabled_toolsets = sorted(_get_platform_tools(user_config, platform_key))
+ # Apply tool preview length config (0 = no limit)
+ try:
+ from agent.display import set_tool_preview_max_len
+ _tpl = user_config.get("display", {}).get("tool_preview_length", 0)
+ set_tool_preview_max_len(int(_tpl) if _tpl else 0)
+ except Exception:
+ pass
+
# Tool progress mode from config.yaml: "all", "new", "verbose", "off"
# Falls back to env vars for backward compatibility.
# YAML 1.1 parses bare `off` as boolean False — normalise before
@@ -4986,9 +5072,11 @@ class GatewayRunner:
return
if preview:
- # Truncate preview to keep messages clean
- if len(preview) > 80:
- preview = preview[:77] + "..."
+ # Truncate preview unless config says unlimited
+ from agent.display import get_tool_preview_max_len
+ _pl = get_tool_preview_max_len()
+ if _pl > 0 and len(preview) > _pl:
+ preview = preview[:_pl - 3] + "..."
msg = f"{emoji} {tool_name}: \"{preview}\""
else:
msg = f"{emoji} {tool_name}..."
diff --git a/hermes b/hermes
index f0feeb2bad80..3172ca91ca2b 100755
--- a/hermes
+++ b/hermes
@@ -1,12 +1,11 @@
#!/usr/bin/env python3
"""
-Hermes Agent CLI Launcher
+Hermes Agent CLI launcher.
-This is a convenience wrapper to launch the Hermes CLI.
-Usage: ./hermes [options]
+This wrapper should behave like the installed `hermes` command, including
+subcommands such as `gateway`, `cron`, and `doctor`.
"""
if __name__ == "__main__":
- from cli import main
- import fire
- fire.Fire(main)
+ from hermes_cli.main import main
+ main()
diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py
index 6b41ac6e5495..940a155645f4 100644
--- a/hermes_cli/auth.py
+++ b/hermes_cli/auth.py
@@ -696,6 +696,10 @@ def resolve_provider(
"hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
"go": "opencode-go", "opencode-go-sub": "opencode-go",
"kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode",
+ # Local server aliases — route through the generic custom provider
+ "lmstudio": "custom", "lm-studio": "custom", "lm_studio": "custom",
+ "ollama": "custom", "vllm": "custom", "llamacpp": "custom",
+ "llama.cpp": "custom", "llama-cpp": "custom",
}
normalized = _PROVIDER_ALIASES.get(normalized, normalized)
@@ -742,7 +746,12 @@ def resolve_provider(
if has_usable_secret(os.getenv(env_var, "")):
return pid
- return "openrouter"
+ raise AuthError(
+ "No inference provider configured. Run 'hermes model' to choose a "
+ "provider and model, or set an API key (OPENROUTER_API_KEY, "
+ "OPENAI_API_KEY, etc.) in ~/.hermes/.env.",
+ code="no_provider_configured",
+ )
# =============================================================================
@@ -2301,21 +2310,20 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
raise AuthError("No runtime API key available to fetch models",
provider="nous", code="invalid_token")
- model_ids = fetch_nous_models(
- inference_base_url=runtime_base_url,
- api_key=runtime_key,
- timeout_seconds=timeout_seconds,
- verify=verify,
- )
+ # Use curated model list (same as OpenRouter defaults) instead
+ # of the full /models dump which returns hundreds of models.
+ from hermes_cli.models import _PROVIDER_MODELS
+ model_ids = _PROVIDER_MODELS.get("nous", [])
print()
if model_ids:
+ print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.")
selected_model = _prompt_model_selection(model_ids)
if selected_model:
_save_model_choice(selected_model)
print(f"Default model set to: {selected_model}")
else:
- print("No models were returned by the inference API.")
+ print("No curated models available for Nous Portal.")
except Exception as exc:
message = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc)
print()
diff --git a/hermes_cli/callbacks.py b/hermes_cli/callbacks.py
index fa51ee1576aa..87f86b84dc1d 100644
--- a/hermes_cli/callbacks.py
+++ b/hermes_cli/callbacks.py
@@ -241,7 +241,8 @@ def approval_callback(cli, command: str, description: str) -> str:
lock = cli._approval_lock
with lock:
- timeout = 60
+ from cli import CLI_CONFIG
+ timeout = CLI_CONFIG.get("approvals", {}).get("timeout", 60)
response_queue = queue.Queue()
choices = ["once", "session", "always", "deny"]
if len(command) > 70:
diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py
index 97e1acc406d7..014a2abeb4f2 100644
--- a/hermes_cli/claw.py
+++ b/hermes_cli/claw.py
@@ -88,7 +88,19 @@ def claw_command(args):
def _cmd_migrate(args):
"""Run the OpenClaw → Hermes migration."""
- source_dir = Path(getattr(args, "source", None) or Path.home() / ".openclaw")
+ # Check current and legacy OpenClaw directories
+ explicit_source = getattr(args, "source", None)
+ if explicit_source:
+ source_dir = Path(explicit_source)
+ else:
+ source_dir = Path.home() / ".openclaw"
+ if not source_dir.is_dir():
+ # Try legacy directory names
+ for legacy in (".clawdbot", ".moldbot"):
+ candidate = Path.home() / legacy
+ if candidate.is_dir():
+ source_dir = candidate
+ break
dry_run = getattr(args, "dry_run", False)
preset = getattr(args, "preset", "full")
overwrite = getattr(args, "overwrite", False)
diff --git a/hermes_cli/codex_models.py b/hermes_cli/codex_models.py
index 169c63e8aced..f5616b68d680 100644
--- a/hermes_cli/codex_models.py
+++ b/hermes_cli/codex_models.py
@@ -12,6 +12,8 @@ import os
logger = logging.getLogger(__name__)
DEFAULT_CODEX_MODELS: List[str] = [
+ "gpt-5.4-mini",
+ "gpt-5.4",
"gpt-5.3-codex",
"gpt-5.2-codex",
"gpt-5.1-codex-max",
@@ -19,8 +21,9 @@ DEFAULT_CODEX_MODELS: List[str] = [
]
_FORWARD_COMPAT_TEMPLATE_MODELS: List[tuple[str, tuple[str, ...]]] = [
- ("gpt-5.3-codex", ("gpt-5.2-codex",)),
+ ("gpt-5.4-mini", ("gpt-5.3-codex", "gpt-5.2-codex")),
("gpt-5.4", ("gpt-5.3-codex", "gpt-5.2-codex")),
+ ("gpt-5.3-codex", ("gpt-5.2-codex",)),
("gpt-5.3-codex-spark", ("gpt-5.3-codex", "gpt-5.2-codex")),
]
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index e39067e35671..ad09766aca48 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -34,6 +34,8 @@ _EXTRA_ENV_KEYS = frozenset({
"SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL",
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
"DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET",
+ "FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN",
+ "WECOM_BOT_ID", "WECOM_SECRET",
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
@@ -221,7 +223,8 @@ DEFAULT_CONFIG = {
"model": "", # e.g. "google/gemini-2.5-flash", "gpt-4o"
"base_url": "", # direct OpenAI-compatible endpoint (takes precedence over provider)
"api_key": "", # API key for base_url (falls back to OPENAI_API_KEY)
- "timeout": 30, # seconds — increase for slow local vision models
+ "timeout": 30, # seconds — LLM API call timeout; increase for slow local vision models
+ "download_timeout": 30, # seconds — image HTTP download timeout; increase for slow connections
},
"web_extract": {
"provider": "auto",
@@ -285,6 +288,7 @@ DEFAULT_CONFIG = {
"show_cost": False, # Show $ cost in the status bar (off by default)
"skin": "default",
"tool_progress_command": False, # Enable /verbose command in messaging gateway
+ "tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
},
# Privacy settings
@@ -404,6 +408,7 @@ DEFAULT_CONFIG = {
# off — skip all approval prompts (equivalent to --yolo)
"approvals": {
"mode": "manual",
+ "timeout": 60,
},
# Permanently allowed dangerous command patterns (added via "always" approval)
diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py
index f7bfad370e00..ba2922771a07 100644
--- a/hermes_cli/gateway.py
+++ b/hermes_cli/gateway.py
@@ -1322,6 +1322,59 @@ _PLATFORMS = [
"help": "The AppSecret from your DingTalk application credentials."},
],
},
+ {
+ "key": "feishu",
+ "label": "Feishu / Lark",
+ "emoji": "🪽",
+ "token_var": "FEISHU_APP_ID",
+ "setup_instructions": [
+ "1. Go to https://open.feishu.cn/ (or https://open.larksuite.com/ for Lark)",
+ "2. Create an app and copy the App ID and App Secret",
+ "3. Enable the Bot capability for the app",
+ "4. Choose WebSocket (recommended) or Webhook connection mode",
+ "5. Add the bot to a group chat or message it directly",
+ "6. Restrict access with FEISHU_ALLOWED_USERS for production use",
+ ],
+ "vars": [
+ {"name": "FEISHU_APP_ID", "prompt": "App ID", "password": False,
+ "help": "The App ID from your Feishu/Lark application."},
+ {"name": "FEISHU_APP_SECRET", "prompt": "App Secret", "password": True,
+ "help": "The App Secret from your Feishu/Lark application."},
+ {"name": "FEISHU_DOMAIN", "prompt": "Domain — feishu or lark (default: feishu)", "password": False,
+ "help": "Use 'feishu' for Feishu China, or 'lark' for Lark international."},
+ {"name": "FEISHU_CONNECTION_MODE", "prompt": "Connection mode — websocket or webhook (default: websocket)", "password": False,
+ "help": "websocket is recommended unless you specifically need webhook mode."},
+ {"name": "FEISHU_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, or empty)", "password": False,
+ "is_allowlist": True,
+ "help": "Restrict which Feishu/Lark users can interact with the bot."},
+ {"name": "FEISHU_HOME_CHANNEL", "prompt": "Home chat ID (optional, for cron/notifications)", "password": False,
+ "help": "Chat ID for scheduled results and notifications."},
+ ],
+ },
+ {
+ "key": "wecom",
+ "label": "WeCom (Enterprise WeChat)",
+ "emoji": "💬",
+ "token_var": "WECOM_BOT_ID",
+ "setup_instructions": [
+ "1. Go to WeCom Admin Console → Applications → Create AI Bot",
+ "2. Copy the Bot ID and Secret from the bot's credentials page",
+ "3. The bot connects via WebSocket — no public endpoint needed",
+ "4. Add the bot to a group chat or message it directly in WeCom",
+ "5. Restrict access with WECOM_ALLOWED_USERS for production use",
+ ],
+ "vars": [
+ {"name": "WECOM_BOT_ID", "prompt": "Bot ID", "password": False,
+ "help": "The Bot ID from your WeCom AI Bot."},
+ {"name": "WECOM_SECRET", "prompt": "Secret", "password": True,
+ "help": "The secret from your WeCom AI Bot."},
+ {"name": "WECOM_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, or empty)", "password": False,
+ "is_allowlist": True,
+ "help": "Restrict which WeCom users can interact with the bot."},
+ {"name": "WECOM_HOME_CHANNEL", "prompt": "Home chat ID (optional, for cron/notifications)", "password": False,
+ "help": "Chat ID for scheduled results and notifications."},
+ ],
+ },
]
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index 5bf1aec94490..c6db6f3bdae7 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -1085,14 +1085,20 @@ def _model_flow_nous(config, current_model=""):
# login_nous already handles model selection + config update
return
- # Already logged in — fetch models and select
- print("Fetching models from Nous Portal...")
+ # Already logged in — use curated model list (same as OpenRouter defaults).
+ # The live /models endpoint returns hundreds of models; the curated list
+ # shows only agentic models users recognize from OpenRouter.
+ from hermes_cli.models import _PROVIDER_MODELS
+ model_ids = _PROVIDER_MODELS.get("nous", [])
+ if not model_ids:
+ print("No curated models available for Nous Portal.")
+ return
+
+ print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.")
+
+ # Verify credentials are still valid (catches expired sessions early)
try:
creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=5 * 60)
- model_ids = fetch_nous_models(
- inference_base_url=creds.get("base_url", ""),
- api_key=creds.get("api_key", ""),
- )
except Exception as exc:
relogin = isinstance(exc, AuthError) and exc.relogin_required
msg = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc)
@@ -1109,11 +1115,7 @@ def _model_flow_nous(config, current_model=""):
except Exception as login_exc:
print(f"Re-login failed: {login_exc}")
return
- print(f"Could not fetch models: {msg}")
- return
-
- if not model_ids:
- print("No models returned by the inference API.")
+ print(f"Could not verify credentials: {msg}")
return
selected = _prompt_model_selection(model_ids, current_model=current_model)
@@ -1270,7 +1272,7 @@ def _model_flow_custom(config):
cfg["model"] = model
model["provider"] = "custom"
model["base_url"] = effective_url
- model["api_mode"] = "chat_completions"
+ model.pop("api_mode", None) # let runtime auto-detect from URL
save_config(cfg)
deactivate_provider()
@@ -2052,7 +2054,7 @@ def _model_flow_kimi(config, current_model=""):
cfg["model"] = model
model["provider"] = provider_id
model["base_url"] = effective_base
- model["api_mode"] = "chat_completions"
+ model.pop("api_mode", None) # let runtime auto-detect from URL
save_config(cfg)
deactivate_provider()
@@ -2126,7 +2128,7 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else "")
live_models = fetch_api_models(api_key_for_probe, effective_base)
- if live_models:
+ if live_models and len(live_models) >= len(curated):
model_list = live_models
print(f" Found {len(model_list)} model(s) from {pconfig.name} API")
else:
@@ -2159,7 +2161,7 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
cfg["model"] = model
model["provider"] = provider_id
model["base_url"] = effective_base
- model["api_mode"] = "chat_completions"
+ model.pop("api_mode", None) # let runtime auto-detect from URL
save_config(cfg)
deactivate_provider()
diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py
index c72bc59e7c00..0146014f3e80 100644
--- a/hermes_cli/plugins.py
+++ b/hermes_cli/plugins.py
@@ -152,6 +152,34 @@ class PluginContext:
self._manager._plugin_tool_names.add(name)
logger.debug("Plugin %s registered tool: %s", self.manifest.name, name)
+ # -- message injection --------------------------------------------------
+
+ def inject_message(self, content: str, role: str = "user") -> bool:
+ """Inject a message into the active conversation.
+
+ If the agent is idle (waiting for user input), this starts a new turn.
+ If the agent is running, this interrupts and injects the message.
+
+ This enables plugins (e.g. remote control viewers, messaging bridges)
+ to send messages into the conversation from external sources.
+
+ Returns True if the message was queued successfully.
+ """
+ cli = self._manager._cli_ref
+ if cli is None:
+ logger.warning("inject_message: no CLI reference (not available in gateway mode)")
+ return False
+
+ msg = content if role == "user" else f"[{role}] {content}"
+
+ if getattr(cli, "_agent_running", False):
+ # Agent is mid-turn — interrupt with the message
+ cli._interrupt_queue.put(msg)
+ else:
+ # Agent is idle — queue as next input
+ cli._pending_input.put(msg)
+ return True
+
# -- hook registration --------------------------------------------------
def register_hook(self, hook_name: str, callback: Callable) -> None:
@@ -184,6 +212,7 @@ class PluginManager:
self._hooks: Dict[str, List[Callable]] = {}
self._plugin_tool_names: Set[str] = set()
self._discovered: bool = False
+ self._cli_ref = None # Set by CLI after plugin discovery
# -----------------------------------------------------------------------
# Public
diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py
index e00aaa4b9724..35695144d3e8 100644
--- a/hermes_cli/setup.py
+++ b/hermes_cli/setup.py
@@ -1002,10 +1002,9 @@ def setup_model_provider(config: dict):
min_key_ttl_seconds=5 * 60,
timeout_seconds=15.0,
)
- nous_models = fetch_nous_models(
- inference_base_url=creds.get("base_url", ""),
- api_key=creds.get("api_key", ""),
- )
+ # Use curated model list instead of full /models dump
+ from hermes_cli.models import _PROVIDER_MODELS
+ nous_models = _PROVIDER_MODELS.get("nous", [])
except Exception as e:
logger.debug("Could not fetch Nous models after login: %s", e)
@@ -2710,10 +2709,38 @@ def setup_gateway(config: dict):
if token or get_env_value("MATRIX_PASSWORD"):
# E2EE
print()
- if prompt_yes_no("Enable end-to-end encryption (E2EE)?", False):
+ want_e2ee = prompt_yes_no("Enable end-to-end encryption (E2EE)?", False)
+ if want_e2ee:
save_env_value("MATRIX_ENCRYPTION", "true")
print_success("E2EE enabled")
- print_info(" Requires: pip install 'matrix-nio[e2e]'")
+
+ # Auto-install matrix-nio
+ matrix_pkg = "matrix-nio[e2e]" if want_e2ee else "matrix-nio"
+ try:
+ __import__("nio")
+ except ImportError:
+ print_info(f"Installing {matrix_pkg}...")
+ import subprocess
+
+ uv_bin = shutil.which("uv")
+ if uv_bin:
+ result = subprocess.run(
+ [uv_bin, "pip", "install", "--python", sys.executable, matrix_pkg],
+ capture_output=True,
+ text=True,
+ )
+ else:
+ result = subprocess.run(
+ [sys.executable, "-m", "pip", "install", matrix_pkg],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode == 0:
+ print_success(f"{matrix_pkg} installed")
+ else:
+ print_warning(f"Install failed — run manually: pip install '{matrix_pkg}'")
+ if result.stderr:
+ print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
# Allowed users
print()
diff --git a/hermes_cli/skills_config.py b/hermes_cli/skills_config.py
index df08a815a40e..07ccd0af91f8 100644
--- a/hermes_cli/skills_config.py
+++ b/hermes_cli/skills_config.py
@@ -28,6 +28,8 @@ PLATFORMS = {
"mattermost": "💬 Mattermost",
"matrix": "💬 Matrix",
"dingtalk": "💬 DingTalk",
+ "feishu": "🪽 Feishu",
+ "wecom": "💬 WeCom",
}
# ─── Config Helpers ───────────────────────────────────────────────────────────
diff --git a/hermes_cli/status.py b/hermes_cli/status.py
index c622fca7b2a6..3a03aabb163f 100644
--- a/hermes_cli/status.py
+++ b/hermes_cli/status.py
@@ -254,6 +254,9 @@ def show_status(args):
"Slack": ("SLACK_BOT_TOKEN", None),
"Email": ("EMAIL_ADDRESS", "EMAIL_HOME_ADDRESS"),
"SMS": ("TWILIO_ACCOUNT_SID", "SMS_HOME_CHANNEL"),
+ "DingTalk": ("DINGTALK_CLIENT_ID", None),
+ "Feishu": ("FEISHU_APP_ID", "FEISHU_HOME_CHANNEL"),
+ "WeCom": ("WECOM_BOT_ID", "WECOM_HOME_CHANNEL"),
}
for name, (token_var, home_var) in platforms.items():
diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py
index 7c76232f3a58..91496d45dbaf 100644
--- a/hermes_cli/tools_config.py
+++ b/hermes_cli/tools_config.py
@@ -140,7 +140,9 @@ PLATFORMS = {
"homeassistant": {"label": "🏠 Home Assistant", "default_toolset": "hermes-homeassistant"},
"email": {"label": "📧 Email", "default_toolset": "hermes-email"},
"matrix": {"label": "💬 Matrix", "default_toolset": "hermes-matrix"},
- "dingtalk": {"label": "💬 DingTalk", "default_toolset": "hermes-dingtalk"},
+ "dingtalk": {"label": "💬 DingTalk", "default_toolset": "hermes-dingtalk"},
+ "feishu": {"label": "🪽 Feishu", "default_toolset": "hermes-feishu"},
+ "wecom": {"label": "💬 WeCom", "default_toolset": "hermes-wecom"},
"api_server": {"label": "🌐 API Server", "default_toolset": "hermes-api-server"},
"mattermost": {"label": "💬 Mattermost", "default_toolset": "hermes-mattermost"},
}
diff --git a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py
index f607ee56ba8c..ac99e2a6fdb4 100644
--- a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py
+++ b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py
@@ -304,6 +304,29 @@ def ensure_parent(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
+def resolve_secret_input(value: Any, env: Optional[Dict[str, str]] = None) -> Optional[str]:
+ """Resolve an OpenClaw SecretInput value to a plain string.
+
+ SecretInput can be:
+ - A plain string: "sk-..."
+ - An env template: "${OPENROUTER_API_KEY}"
+ - A SecretRef object: {"source": "env", "id": "OPENROUTER_API_KEY"}
+ """
+ if isinstance(value, str):
+ # Check for env template: "${VAR_NAME}"
+ m = re.match(r"^\$\{(\w+)\}$", value.strip())
+ if m and env:
+ return env.get(m.group(1), "").strip() or None
+ return value.strip() or None
+ if isinstance(value, dict):
+ source = value.get("source", "")
+ ref_id = value.get("id", "")
+ if source == "env" and ref_id and env:
+ return env.get(ref_id, "").strip() or None
+ # File/exec sources can't be resolved here — return None
+ return None
+
+
def load_yaml_file(path: Path) -> Dict[str, Any]:
if yaml is None or not path.exists():
return {}
@@ -890,14 +913,20 @@ class Migrator:
self.record("command-allowlist", source, destination, "migrated", "Would merge patterns", added_patterns=added)
def load_openclaw_config(self) -> Dict[str, Any]:
- config_path = self.source_root / "openclaw.json"
- if not config_path.exists():
- return {}
- try:
- data = json.loads(config_path.read_text(encoding="utf-8"))
- return data if isinstance(data, dict) else {}
- except json.JSONDecodeError:
- return {}
+ # Check current name and legacy config filenames
+ for name in ("openclaw.json", "clawdbot.json", "moldbot.json"):
+ config_path = self.source_root / name
+ if config_path.exists():
+ try:
+ data = json.loads(config_path.read_text(encoding="utf-8"))
+ return data if isinstance(data, dict) else {}
+ except json.JSONDecodeError:
+ continue
+ return {}
+
+ def load_openclaw_env(self) -> Dict[str, str]:
+ """Load the OpenClaw .env file for secrets that live there instead of config."""
+ return parse_env_file(self.source_root / ".env")
def merge_env_values(self, additions: Dict[str, str], kind: str, source: Path) -> None:
destination = self.target_root / ".env"
@@ -1024,6 +1053,10 @@ class Migrator:
supported_targets=sorted(SUPPORTED_SECRET_TARGETS),
)
+ def _resolve_channel_secret(self, value: Any) -> Optional[str]:
+ """Resolve a channel config value that may be a SecretRef."""
+ return resolve_secret_input(value, self.load_openclaw_env())
+
def migrate_discord_settings(self, config: Optional[Dict[str, Any]] = None) -> None:
config = config or self.load_openclaw_config()
additions: Dict[str, str] = {}
@@ -1118,15 +1151,17 @@ class Migrator:
secret_additions: Dict[str, str] = {}
# Extract provider API keys from models.providers
+ # Note: apiKey values can be strings, env templates, or SecretRef objects
+ openclaw_env = self.load_openclaw_env()
providers = config.get("models", {}).get("providers", {})
if isinstance(providers, dict):
for provider_name, provider_cfg in providers.items():
if not isinstance(provider_cfg, dict):
continue
- api_key = provider_cfg.get("apiKey")
- if not isinstance(api_key, str) or not api_key.strip():
+ raw_key = provider_cfg.get("apiKey")
+ api_key = resolve_secret_input(raw_key, openclaw_env)
+ if not api_key:
continue
- api_key = api_key.strip()
base_url = provider_cfg.get("baseUrl", "")
api_type = provider_cfg.get("api", "")
@@ -1170,6 +1205,50 @@ class Migrator:
if isinstance(oai_key, str) and oai_key.strip():
secret_additions["VOICE_TOOLS_OPENAI_KEY"] = oai_key.strip()
+ # Also check the OpenClaw .env file — many users store keys there
+ # instead of inline in openclaw.json
+ openclaw_env = self.load_openclaw_env()
+ env_key_mapping = {
+ "OPENROUTER_API_KEY": "OPENROUTER_API_KEY",
+ "OPENAI_API_KEY": "OPENAI_API_KEY",
+ "ANTHROPIC_API_KEY": "ANTHROPIC_API_KEY",
+ "ELEVENLABS_API_KEY": "ELEVENLABS_API_KEY",
+ "TELEGRAM_BOT_TOKEN": "TELEGRAM_BOT_TOKEN",
+ "DEEPSEEK_API_KEY": "DEEPSEEK_API_KEY",
+ "GEMINI_API_KEY": "GEMINI_API_KEY",
+ "ZAI_API_KEY": "ZAI_API_KEY",
+ "MINIMAX_API_KEY": "MINIMAX_API_KEY",
+ }
+ for oc_key, hermes_key in env_key_mapping.items():
+ val = openclaw_env.get(oc_key, "").strip()
+ if val and hermes_key not in secret_additions:
+ secret_additions[hermes_key] = val
+
+ # Check per-agent auth-profiles.json for additional credentials
+ auth_profiles_path = self.source_root / "agents" / "main" / "agent" / "auth-profiles.json"
+ if auth_profiles_path.exists():
+ try:
+ profiles = json.loads(auth_profiles_path.read_text(encoding="utf-8"))
+ if isinstance(profiles, dict):
+ # auth-profiles.json wraps profiles in a "profiles" key
+ profile_entries = profiles.get("profiles", profiles) if isinstance(profiles.get("profiles"), dict) else profiles
+ for profile_name, profile_data in profile_entries.items():
+ if not isinstance(profile_data, dict):
+ continue
+ # Canonical field is "key", "apiKey" is accepted as alias
+ api_key = profile_data.get("key", "") or profile_data.get("apiKey", "")
+ if not isinstance(api_key, str) or not api_key.strip():
+ continue
+ name_lower = profile_name.lower()
+ if "openrouter" in name_lower and "OPENROUTER_API_KEY" not in secret_additions:
+ secret_additions["OPENROUTER_API_KEY"] = api_key.strip()
+ elif "openai" in name_lower and "OPENAI_API_KEY" not in secret_additions:
+ secret_additions["OPENAI_API_KEY"] = api_key.strip()
+ elif "anthropic" in name_lower and "ANTHROPIC_API_KEY" not in secret_additions:
+ secret_additions["ANTHROPIC_API_KEY"] = api_key.strip()
+ except (json.JSONDecodeError, OSError):
+ pass
+
if secret_additions:
self.merge_env_values(secret_additions, "provider-keys", self.source_root / "openclaw.json")
else:
@@ -1218,7 +1297,11 @@ class Migrator:
if self.execute:
backup_path = self.maybe_backup(destination)
- hermes_config["model"] = model_str
+ existing_model = hermes_config.get("model")
+ if isinstance(existing_model, dict):
+ existing_model["default"] = model_str
+ else:
+ hermes_config["model"] = {"default": model_str}
dump_yaml_file(destination, hermes_config)
self.record("model-config", source_path, destination, "migrated", backup=str(backup_path) if backup_path else "", model=model_str)
else:
@@ -1244,22 +1327,44 @@ class Migrator:
if isinstance(provider, str) and provider in ("elevenlabs", "openai", "edge"):
tts_data["provider"] = provider
- elevenlabs = tts.get("elevenlabs", {})
+ # TTS provider settings live under messages.tts.providers.{provider}
+ # in OpenClaw (not messages.tts.elevenlabs directly)
+ providers = tts.get("providers") or {}
+
+ # Also check the top-level "talk" config which has provider settings too
+ talk_cfg = (config or self.load_openclaw_config()).get("talk") or {}
+ talk_providers = talk_cfg.get("providers") or {}
+
+ # Merge: messages.tts.providers takes priority, then talk.providers,
+ # then legacy flat keys (messages.tts.elevenlabs, etc.)
+ elevenlabs = (
+ (providers.get("elevenlabs") or {})
+ if isinstance(providers.get("elevenlabs"), dict) else
+ (talk_providers.get("elevenlabs") or {})
+ if isinstance(talk_providers.get("elevenlabs"), dict) else
+ (tts.get("elevenlabs") or {})
+ )
if isinstance(elevenlabs, dict):
el_settings: Dict[str, str] = {}
- voice_id = elevenlabs.get("voiceId")
+ voice_id = elevenlabs.get("voiceId") or talk_cfg.get("voiceId")
if isinstance(voice_id, str) and voice_id.strip():
el_settings["voice_id"] = voice_id.strip()
- model_id = elevenlabs.get("modelId")
+ model_id = elevenlabs.get("modelId") or talk_cfg.get("modelId")
if isinstance(model_id, str) and model_id.strip():
el_settings["model_id"] = model_id.strip()
if el_settings:
tts_data["elevenlabs"] = el_settings
- openai_tts = tts.get("openai", {})
+ openai_tts = (
+ (providers.get("openai") or {})
+ if isinstance(providers.get("openai"), dict) else
+ (talk_providers.get("openai") or {})
+ if isinstance(talk_providers.get("openai"), dict) else
+ (tts.get("openai") or {})
+ )
if isinstance(openai_tts, dict):
oai_settings: Dict[str, str] = {}
- oai_model = openai_tts.get("model")
+ oai_model = openai_tts.get("model") or openai_tts.get("modelId")
if isinstance(oai_model, str) and oai_model.strip():
oai_settings["model"] = oai_model.strip()
oai_voice = openai_tts.get("voice")
@@ -1268,7 +1373,11 @@ class Migrator:
if oai_settings:
tts_data["openai"] = oai_settings
- edge_tts = tts.get("edge", {})
+ edge_tts = (
+ (providers.get("edge") or {})
+ if isinstance(providers.get("edge"), dict) else
+ (tts.get("edge") or {})
+ )
if isinstance(edge_tts, dict):
edge_voice = edge_tts.get("voice")
if isinstance(edge_voice, str) and edge_voice.strip():
@@ -1298,15 +1407,29 @@ class Migrator:
self.record("tts-config", source_path, destination, "migrated", "Would set TTS config", settings=list(tts_data.keys()))
def migrate_shared_skills(self) -> None:
- source_root = self.source_root / "skills"
+ # Check all OpenClaw skill sources: managed, personal, project-level
+ skill_sources = [
+ (self.source_root / "skills", "shared-skills", "managed skills"),
+ (Path.home() / ".agents" / "skills", "personal-skills", "personal cross-project skills"),
+ (self.source_root / "workspace" / ".agents" / "skills", "project-skills", "project-level shared skills"),
+ (self.source_root / "workspace.default" / ".agents" / "skills", "project-skills", "project-level shared skills"),
+ ]
+ found_any = False
+ for source_root, kind_label, desc in skill_sources:
+ if source_root.exists():
+ found_any = True
+ self._import_skill_directory(source_root, kind_label, desc)
+ if not found_any:
+ destination_root = self.target_root / "skills" / SKILL_CATEGORY_DIRNAME
+ self.record("shared-skills", None, destination_root, "skipped", "No shared OpenClaw skills directories found")
+
+ def _import_skill_directory(self, source_root: Path, kind_label: str, desc: str) -> None:
+ """Import skills from a single source directory into openclaw-imports."""
destination_root = self.target_root / "skills" / SKILL_CATEGORY_DIRNAME
- if not source_root.exists():
- self.record("shared-skills", None, destination_root, "skipped", "No shared OpenClaw skills directory found")
- return
skill_dirs = [p for p in sorted(source_root.iterdir()) if p.is_dir() and (p / "SKILL.md").exists()]
if not skill_dirs:
- self.record("shared-skills", source_root, destination_root, "skipped", "No shared skills with SKILL.md found")
+ self.record(kind_label, source_root, destination_root, "skipped", f"No skills with SKILL.md found in {desc}")
return
for skill_dir in skill_dirs:
@@ -1314,7 +1437,7 @@ class Migrator:
final_destination = destination
if destination.exists():
if self.skill_conflict_mode == "skip":
- self.record("shared-skill", skill_dir, destination, "conflict", "Destination skill already exists")
+ self.record(kind_label, skill_dir, destination, "conflict", "Destination skill already exists")
continue
if self.skill_conflict_mode == "rename":
final_destination = self.resolve_skill_destination(destination)
@@ -1329,19 +1452,19 @@ class Migrator:
details: Dict[str, Any] = {"backup": str(backup_path) if backup_path else ""}
if final_destination != destination:
details["renamed_from"] = str(destination)
- self.record("shared-skill", skill_dir, final_destination, "migrated", **details)
+ self.record(kind_label, skill_dir, final_destination, "migrated", **details)
else:
if final_destination != destination:
self.record(
- "shared-skill",
+ kind_label,
skill_dir,
final_destination,
"migrated",
- "Would copy shared skill directory under a renamed folder",
+ f"Would copy {desc} directory under a renamed folder",
renamed_from=str(destination),
)
else:
- self.record("shared-skill", skill_dir, final_destination, "migrated", "Would copy shared skill directory")
+ self.record(kind_label, skill_dir, final_destination, "migrated", f"Would copy {desc} directory")
desc_path = destination_root / "DESCRIPTION.md"
if self.execute:
@@ -1518,6 +1641,7 @@ class Migrator:
self.source_candidate("workspace/IDENTITY.md", "workspace.default/IDENTITY.md"),
self.source_candidate("workspace/TOOLS.md", "workspace.default/TOOLS.md"),
self.source_candidate("workspace/HEARTBEAT.md", "workspace.default/HEARTBEAT.md"),
+ self.source_candidate("workspace/BOOTSTRAP.md", "workspace.default/BOOTSTRAP.md"),
]
for candidate in candidates:
if candidate:
@@ -1789,8 +1913,9 @@ class Migrator:
human_delay = defaults.get("humanDelay") or {}
if human_delay:
hd = hermes_cfg.get("human_delay") or {}
- if human_delay.get("enabled"):
- hd["mode"] = "natural"
+ hd_mode = human_delay.get("mode") or ("natural" if human_delay.get("enabled") else None)
+ if hd_mode and hd_mode != "off":
+ hd["mode"] = hd_mode
if human_delay.get("minMs"):
hd["min_ms"] = human_delay["minMs"]
if human_delay.get("maxMs"):
@@ -1804,11 +1929,11 @@ class Migrator:
changes = True
# Map terminal/exec settings
- exec_cfg = defaults.get("exec") or (config.get("tools") or {}).get("exec") or {}
+ exec_cfg = (config.get("tools") or {}).get("exec") or {}
if exec_cfg:
terminal_cfg = hermes_cfg.get("terminal") or {}
- if exec_cfg.get("timeout"):
- terminal_cfg["timeout"] = exec_cfg["timeout"]
+ if exec_cfg.get("timeoutSec") or exec_cfg.get("timeout"):
+ terminal_cfg["timeout"] = exec_cfg.get("timeoutSec") or exec_cfg.get("timeout")
changes = True
hermes_cfg["terminal"] = terminal_cfg
@@ -1883,24 +2008,34 @@ class Migrator:
sr = hermes_cfg.get("session_reset") or {}
changes = False
- reset_triggers = session.get("resetTriggers") or session.get("reset_triggers") or {}
- if reset_triggers:
- daily = reset_triggers.get("daily") or {}
- idle = reset_triggers.get("idle") or {}
+ # OpenClaw uses session.reset (structured) and session.resetTriggers (string array)
+ reset = session.get("reset") or {}
+ reset_triggers = session.get("resetTriggers") or session.get("reset_triggers") or []
- if daily.get("enabled") and idle.get("enabled"):
- sr["mode"] = "both"
- elif daily.get("enabled"):
+ if reset:
+ # Structured reset config: has mode, atHour, idleMinutes
+ mode = reset.get("mode", "")
+ if mode == "daily":
sr["mode"] = "daily"
- elif idle.get("enabled"):
+ elif mode == "idle":
sr["mode"] = "idle"
else:
- sr["mode"] = "none"
-
- if daily.get("hour") is not None:
- sr["at_hour"] = daily["hour"]
- if idle.get("minutes") or idle.get("timeoutMinutes"):
- sr["idle_minutes"] = idle.get("minutes") or idle.get("timeoutMinutes")
+ sr["mode"] = mode or "none"
+ if reset.get("atHour") is not None:
+ sr["at_hour"] = reset["atHour"]
+ if reset.get("idleMinutes"):
+ sr["idle_minutes"] = reset["idleMinutes"]
+ changes = True
+ elif isinstance(reset_triggers, list) and reset_triggers:
+ # Simple string triggers: ["daily", "idle"]
+ has_daily = "daily" in reset_triggers
+ has_idle = "idle" in reset_triggers
+ if has_daily and has_idle:
+ sr["mode"] = "both"
+ elif has_daily:
+ sr["mode"] = "daily"
+ elif has_idle:
+ sr["mode"] = "idle"
changes = True
if changes:
@@ -2092,11 +2227,12 @@ class Migrator:
browser_hermes = hermes_cfg.get("browser") or {}
changed = False
- if browser.get("inactivityTimeoutMs"):
- browser_hermes["inactivity_timeout"] = browser["inactivityTimeoutMs"] // 1000
+ # Map fields that have Hermes equivalents
+ if browser.get("cdpUrl"):
+ browser_hermes["cdp_url"] = browser["cdpUrl"]
changed = True
- if browser.get("commandTimeoutMs"):
- browser_hermes["command_timeout"] = browser["commandTimeoutMs"] // 1000
+ if browser.get("headless") is not None:
+ browser_hermes["headless"] = browser["headless"]
changed = True
if changed:
@@ -2107,9 +2243,9 @@ class Migrator:
self.record("browser-config", "openclaw.json browser.*", "config.yaml browser",
"migrated")
- # Archive advanced browser settings
+ # Archive remaining browser settings
advanced = {k: v for k, v in browser.items()
- if k not in ("inactivityTimeoutMs", "commandTimeoutMs") and v}
+ if k not in ("cdpUrl", "headless") and v}
if advanced and self.archive_dir:
if self.execute:
self.archive_dir.mkdir(parents=True, exist_ok=True)
@@ -2130,18 +2266,22 @@ class Migrator:
hermes_cfg = load_yaml_file(hermes_cfg_path)
changed = False
- # Map exec timeout -> terminal timeout
+ # Map exec timeout -> terminal timeout (field is timeoutSec in OpenClaw)
exec_cfg = tools.get("exec") or {}
- if exec_cfg.get("timeout"):
+ timeout_val = exec_cfg.get("timeoutSec") or exec_cfg.get("timeout")
+ if timeout_val:
terminal_cfg = hermes_cfg.get("terminal") or {}
- terminal_cfg["timeout"] = exec_cfg["timeout"]
+ terminal_cfg["timeout"] = timeout_val
hermes_cfg["terminal"] = terminal_cfg
changed = True
- # Map web search API key
- web_cfg = tools.get("webSearch") or tools.get("web") or {}
- if web_cfg.get("braveApiKey") and self.migrate_secrets:
- self._set_env_var("BRAVE_API_KEY", web_cfg["braveApiKey"], "tools.webSearch.braveApiKey")
+ # Map web search API key (path: tools.web.search.brave.apiKey in OpenClaw)
+ web_cfg = tools.get("web") or tools.get("webSearch") or {}
+ search_cfg = web_cfg.get("search") or web_cfg if not web_cfg.get("search") else web_cfg["search"]
+ brave_cfg = search_cfg.get("brave") or {}
+ brave_key = brave_cfg.get("apiKey") or search_cfg.get("braveApiKey") or web_cfg.get("braveApiKey")
+ if brave_key and isinstance(brave_key, str) and self.migrate_secrets:
+ self._set_env_var("BRAVE_API_KEY", brave_key, "tools.web.search.brave.apiKey")
if changed and self.execute:
self.maybe_backup(hermes_cfg_path)
@@ -2169,8 +2309,9 @@ class Migrator:
hermes_cfg_path = self.target_root / "config.yaml"
hermes_cfg = load_yaml_file(hermes_cfg_path)
- # Map approval mode
- mode = approvals.get("mode") or approvals.get("defaultMode")
+ # Map approval mode (nested under approvals.exec.mode in OpenClaw)
+ exec_approvals = approvals.get("exec") or {}
+ mode = (exec_approvals.get("mode") if isinstance(exec_approvals, dict) else None) or approvals.get("mode") or approvals.get("defaultMode")
if mode:
mode_map = {"auto": "off", "always": "manual", "smart": "smart", "manual": "manual"}
hermes_mode = mode_map.get(mode, "manual")
diff --git a/pyproject.toml b/pyproject.toml
index 23bd360ba748..0d381470c175 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -58,6 +58,7 @@ homeassistant = ["aiohttp>=3.9.0,<4"]
sms = ["aiohttp>=3.9.0,<4"]
acp = ["agent-client-protocol>=0.8.1,<0.9"]
dingtalk = ["dingtalk-stream>=0.1.0,<1"]
+feishu = ["lark-oapi>=1.5.3,<2"]
rl = [
"atroposlib @ git+https://github.com/NousResearch/atropos.git",
"tinker @ git+https://github.com/thinking-machines-lab/tinker.git",
@@ -71,6 +72,7 @@ all = [
"hermes-agent[modal]",
"hermes-agent[daytona]",
"hermes-agent[messaging]",
+ "hermes-agent[matrix]",
"hermes-agent[cron]",
"hermes-agent[cli]",
"hermes-agent[dev]",
@@ -85,6 +87,7 @@ all = [
"hermes-agent[voice]",
"hermes-agent[dingtalk]",
"hermes-agent[web]",
+ "hermes-agent[feishu]",
]
[project.scripts]
diff --git a/run_agent.py b/run_agent.py
index 66f1ff4c8179..30453c01ce85 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -1285,7 +1285,7 @@ class AIAgent:
try:
fn = self._print_fn or print
fn(*args, **kwargs)
- except OSError:
+ except (OSError, ValueError):
pass
def _vprint(self, *args, force: bool = False, **kwargs):
@@ -5183,6 +5183,8 @@ class AIAgent:
self._session_db.end_session(self.session_id, "compression")
old_session_id = self.session_id
self.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
+ # Update session_log_file to point to the new session's JSON file
+ self.session_log_file = self.logs_dir / f"session_{self.session_id}.json"
self._session_db.create_session(
session_id=self.session_id,
source=self.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
@@ -5660,8 +5662,6 @@ class AIAgent:
face = random.choice(KawaiiSpinner.KAWAII_WAITING)
emoji = _get_tool_emoji(function_name)
preview = _build_tool_preview(function_name, function_args) or function_name
- if len(preview) > 30:
- preview = preview[:27] + "..."
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=self._print_fn)
spinner.start()
_spinner_result = None
@@ -7909,7 +7909,7 @@ class AIAgent:
error_msg = f"Error during OpenAI-compatible API call #{api_call_count}: {str(e)}"
try:
print(f"❌ {error_msg}")
- except OSError:
+ except (OSError, ValueError):
logger.error(error_msg)
if self.verbose_logging:
diff --git a/scripts/whatsapp-bridge/allowlist.js b/scripts/whatsapp-bridge/allowlist.js
new file mode 100644
index 000000000000..760e413f2b8e
--- /dev/null
+++ b/scripts/whatsapp-bridge/allowlist.js
@@ -0,0 +1,79 @@
+import path from 'path';
+import { existsSync, readFileSync } from 'fs';
+
+export function normalizeWhatsAppIdentifier(value) {
+ return String(value || '')
+ .trim()
+ .replace(/:.*@/, '@')
+ .replace(/@.*/, '')
+ .replace(/^\+/, '');
+}
+
+export function parseAllowedUsers(rawValue) {
+ return new Set(
+ String(rawValue || '')
+ .split(',')
+ .map((value) => normalizeWhatsAppIdentifier(value))
+ .filter(Boolean)
+ );
+}
+
+function readMappingFile(sessionDir, identifier, suffix = '') {
+ const filePath = path.join(sessionDir, `lid-mapping-${identifier}${suffix}.json`);
+ if (!existsSync(filePath)) {
+ return null;
+ }
+
+ try {
+ const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
+ const normalized = normalizeWhatsAppIdentifier(parsed);
+ return normalized || null;
+ } catch {
+ return null;
+ }
+}
+
+export function expandWhatsAppIdentifiers(identifier, sessionDir) {
+ const normalized = normalizeWhatsAppIdentifier(identifier);
+ if (!normalized) {
+ return new Set();
+ }
+
+ // Walk both phone->LID and LID->phone mapping files so allowlists can use
+ // either form transparently in bot mode.
+ const resolved = new Set();
+ const queue = [normalized];
+
+ while (queue.length > 0) {
+ const current = queue.shift();
+ if (!current || resolved.has(current)) {
+ continue;
+ }
+
+ resolved.add(current);
+
+ for (const suffix of ['', '_reverse']) {
+ const mapped = readMappingFile(sessionDir, current, suffix);
+ if (mapped && !resolved.has(mapped)) {
+ queue.push(mapped);
+ }
+ }
+ }
+
+ return resolved;
+}
+
+export function matchesAllowedUser(senderId, allowedUsers, sessionDir) {
+ if (!allowedUsers || allowedUsers.size === 0) {
+ return true;
+ }
+
+ const aliases = expandWhatsAppIdentifiers(senderId, sessionDir);
+ for (const alias of aliases) {
+ if (allowedUsers.has(alias)) {
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/scripts/whatsapp-bridge/allowlist.test.mjs b/scripts/whatsapp-bridge/allowlist.test.mjs
new file mode 100644
index 000000000000..7eea7399cd59
--- /dev/null
+++ b/scripts/whatsapp-bridge/allowlist.test.mjs
@@ -0,0 +1,47 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import os from 'node:os';
+import path from 'node:path';
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+
+import {
+ expandWhatsAppIdentifiers,
+ matchesAllowedUser,
+ normalizeWhatsAppIdentifier,
+ parseAllowedUsers,
+} from './allowlist.js';
+
+test('normalizeWhatsAppIdentifier strips jid syntax and plus prefix', () => {
+ assert.equal(normalizeWhatsAppIdentifier('+19175395595@s.whatsapp.net'), '19175395595');
+ assert.equal(normalizeWhatsAppIdentifier('267383306489914@lid'), '267383306489914');
+ assert.equal(normalizeWhatsAppIdentifier('19175395595:12@s.whatsapp.net'), '19175395595');
+});
+
+test('expandWhatsAppIdentifiers resolves phone and lid aliases from session files', () => {
+ const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
+
+ try {
+ writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914'));
+ writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595'));
+
+ const aliases = expandWhatsAppIdentifiers('267383306489914@lid', sessionDir);
+ assert.deepEqual([...aliases].sort(), ['19175395595', '267383306489914']);
+ } finally {
+ rmSync(sessionDir, { recursive: true, force: true });
+ }
+});
+
+test('matchesAllowedUser accepts mapped lid sender when allowlist only contains phone number', () => {
+ const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
+
+ try {
+ writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914'));
+ writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595'));
+
+ const allowedUsers = parseAllowedUsers('+19175395595');
+ assert.equal(matchesAllowedUser('267383306489914@lid', allowedUsers, sessionDir), true);
+ assert.equal(matchesAllowedUser('188012763865257@lid', allowedUsers, sessionDir), false);
+ } finally {
+ rmSync(sessionDir, { recursive: true, force: true });
+ }
+});
diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js
index 0dff8c2e2413..5f0cb729f693 100644
--- a/scripts/whatsapp-bridge/bridge.js
+++ b/scripts/whatsapp-bridge/bridge.js
@@ -26,6 +26,7 @@ import path from 'path';
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
import { randomBytes } from 'crypto';
import qrcode from 'qrcode-terminal';
+import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js';
// Parse CLI args
const args = process.argv.slice(2);
@@ -47,13 +48,17 @@ const DOCUMENT_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'docume
const AUDIO_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'audio_cache');
const PAIR_ONLY = args.includes('--pair-only');
const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // "bot" or "self-chat"
-const ALLOWED_USERS = (process.env.WHATSAPP_ALLOWED_USERS || '').split(',').map(s => s.trim()).filter(Boolean);
+const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || '');
const DEFAULT_REPLY_PREFIX = '⚕ *Hermes Agent*\n────────────\n';
const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined
? DEFAULT_REPLY_PREFIX
: process.env.WHATSAPP_REPLY_PREFIX.replace(/\\n/g, '\n');
function formatOutgoingMessage(message) {
+ // In bot mode, messages come from a different number so the prefix is
+ // redundant — the sender identity is already clear. Only prepend in
+ // self-chat mode where bot and user share the same number.
+ if (WHATSAPP_MODE !== 'self-chat') return message;
return REPLY_PREFIX ? `${REPLY_PREFIX}${message}` : message;
}
@@ -190,10 +195,9 @@ async function startSocket() {
if (!isSelfChat) continue;
}
- // Check allowlist for messages from others (resolve LID → phone if needed)
- if (!msg.key.fromMe && ALLOWED_USERS.length > 0) {
- const resolvedNumber = lidToPhone[senderNumber] || senderNumber;
- if (!ALLOWED_USERS.includes(resolvedNumber)) continue;
+ // Check allowlist for messages from others (resolve LID ↔ phone aliases)
+ if (!msg.key.fromMe && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
+ continue;
}
// Extract message body
@@ -515,8 +519,8 @@ if (PAIR_ONLY) {
app.listen(PORT, '127.0.0.1', () => {
console.log(`🌉 WhatsApp bridge listening on port ${PORT} (mode: ${WHATSAPP_MODE})`);
console.log(`📁 Session stored in: ${SESSION_DIR}`);
- if (ALLOWED_USERS.length > 0) {
- console.log(`🔒 Allowed users: ${ALLOWED_USERS.join(', ')}`);
+ if (ALLOWED_USERS.size > 0) {
+ console.log(`🔒 Allowed users: ${Array.from(ALLOWED_USERS).join(', ')}`);
} else {
console.log(`⚠️ No WHATSAPP_ALLOWED_USERS set — all messages will be processed`);
}
diff --git a/skills/creative/songwriting-and-ai-music/SKILL.md b/skills/creative/songwriting-and-ai-music/SKILL.md
new file mode 100644
index 000000000000..2f1fc72825fc
--- /dev/null
+++ b/skills/creative/songwriting-and-ai-music/SKILL.md
@@ -0,0 +1,289 @@
+---
+name: songwriting-and-ai-music
+description: >
+ Songwriting craft, AI music generation prompts (Suno focus), parody/adaptation
+ techniques, phonetic tricks, and lessons learned. These are tools and ideas,
+ not rules. Break any of them when the art calls for it.
+tags: [songwriting, music, suno, parody, lyrics, creative]
+triggers:
+ - writing a song
+ - song lyrics
+ - music prompt
+ - suno prompt
+ - parody song
+ - adapting a song
+ - AI music generation
+---
+
+# Songwriting & AI Music Generation
+
+Everything here is a GUIDELINE, not a rule. Art breaks rules on purpose.
+Use what serves the song. Ignore what doesn't.
+
+---
+
+## 1. Song Structure (Pick One or Invent Your Own)
+
+Common skeletons — mix, modify, or throw out as needed:
+
+```
+ABABCB Verse/Chorus/Verse/Chorus/Bridge/Chorus (most pop/rock)
+AABA Verse/Verse/Bridge/Verse (refrain-based) (jazz standards, ballads)
+ABAB Verse/Chorus alternating (simple, direct)
+AAA Verse/Verse/Verse (strophic, no chorus) (folk, storytelling)
+```
+
+The six building blocks:
+- Intro — set the mood, pull the listener in
+- Verse — the story, the details, the world-building
+- Pre-Chorus — optional tension ramp before the payoff
+- Chorus — the emotional core, the part people remember
+- Bridge — a detour, a shift in perspective or key
+- Outro — the farewell, can echo or subvert the rest
+
+You don't need all of these. Some great songs are just one section
+that evolves. Structure serves the emotion, not the other way around.
+
+---
+
+## 2. Rhyme, Meter, and Sound
+
+RHYME TYPES (from tight to loose):
+- Perfect: lean/mean
+- Family: crate/braid
+- Assonance: had/glass (same vowels, different endings)
+- Consonance: scene/when (different vowels, similar endings)
+- Near/slant: enough to suggest connection without locking it down
+
+Mix them. All perfect rhymes can sound like a nursery rhyme.
+All slant rhymes can sound lazy. The blend is where it lives.
+
+INTERNAL RHYME: Rhyming within a line, not just at the ends.
+ "We pruned the lies from bleeding trees / Distilled the storm
+ from entropy" — "lies/flies," "trees/entropy" create internal echoes.
+
+METER: The rhythm of stressed vs unstressed syllables.
+- Matching syllable counts between parallel lines helps singability
+- The STRESSED syllables matter more than total count
+- Say it out loud. If you stumble, the meter needs work.
+- Intentionally breaking meter can create emphasis or surprise
+
+---
+
+## 3. Emotional Arc and Dynamics
+
+Think of a song as a journey, not a flat road.
+
+ENERGY MAPPING (rough idea, not prescription):
+ Intro: 2-3 | Verse: 5-6 | Pre-Chorus: 7
+ Chorus: 8-9 | Bridge: varies | Final Chorus: 9-10
+
+The most powerful dynamic trick: CONTRAST.
+- Whisper before a scream hits harder than just screaming
+- Sparse before dense. Slow before fast. Low before high.
+- The drop only works because of the buildup
+- Silence is an instrument
+
+"Whisper to roar to whisper" — start intimate, build to full power,
+strip back to vulnerability. Works for ballads, epics, anthems.
+
+---
+
+## 4. Writing Lyrics That Work
+
+SHOW, DON'T TELL (usually):
+- "I was sad" = flat
+- "Your hoodie's still on the hook by the door" = alive
+- But sometimes "I give my life" said plainly IS the power
+
+THE HOOK:
+- The line people remember, hum, repeat
+- Usually the title or core phrase
+- Works best when melody + lyric + emotion all align
+- Place it where it lands hardest (often first/last line of chorus)
+
+PROSODY — lyrics and music supporting each other:
+- Stable feelings (resolution, peace) pair with settled melodies,
+ perfect rhymes, resolved chords
+- Unstable feelings (longing, doubt) pair with wandering melodies,
+ near-rhymes, unresolved chords
+- Verse melody typically sits lower, chorus goes higher
+- But flip this if it serves the song
+
+AVOID (unless you're doing it on purpose):
+- Cliches on autopilot ("heart of gold" without earning it)
+- Forcing word order to hit a rhyme ("Yoda-speak")
+- Same energy in every section (flat dynamics)
+- Treating your first draft as sacred — revision is creation
+
+---
+
+## 5. Parody and Adaptation
+
+When rewriting an existing song with new lyrics:
+
+THE SKELETON: Map the original's structure first.
+- Count syllables per line
+- Mark the rhyme scheme (ABAB, AABB, etc.)
+- Identify which syllables are STRESSED
+- Note where held/sustained notes fall
+
+FITTING NEW WORDS:
+- Match stressed syllables to the same beats as the original
+- Total syllable count can flex by 1-2 unstressed syllables
+- On long held notes, try to match the VOWEL SOUND of the original
+ (if original holds "LOOOVE" with an "oo" vowel, "FOOOD" fits
+ better than "LIFE")
+- Monosyllabic swaps in key spots keep rhythm intact
+ (Crime -> Code, Snake -> Noose)
+- Sing your new words over the original — if you stumble, revise
+
+CONCEPT:
+- Pick a concept strong enough to sustain the whole song
+- Start from the title/hook and build outward
+- Generate lots of raw material (puns, phrases, images) FIRST,
+ then fit the best ones into the structure
+- If you need a specific line somewhere, reverse-engineer the
+ rhyme scheme backward to set it up
+
+KEEP SOME ORIGINALS: Leaving a few original lines or structures
+intact adds recognizability and lets the audience feel the connection.
+
+---
+
+## 6. Suno AI Prompt Engineering
+
+### Style/Genre Description Field
+
+FORMULA (adapt as needed):
+ Genre + Mood + Era + Instruments + Vocal Style + Production + Dynamics
+
+```
+BAD: "sad rock song"
+GOOD: "Cinematic orchestral spy thriller, 1960s Cold War era, smoky
+ sultry female vocalist, big band jazz, brass section with
+ trumpets and french horns, sweeping strings, minor key,
+ vintage analog warmth"
+```
+
+DESCRIBE THE JOURNEY, not just the genre:
+```
+"Begins as a haunting whisper over sparse piano. Gradually layers
+ in muted brass. Builds through the chorus with full orchestra.
+ Second verse erupts with raw belting intensity. Outro strips back
+ to a lone piano and a fragile whisper fading to silence."
+```
+
+TIPS:
+- V4.5+ supports up to 1,000 chars in Style field — use them
+- NO artist names or trademarks. Describe the sound instead.
+ "1960s Cold War spy thriller brass" not "James Bond style"
+ "90s grunge" not "Nirvana-style"
+- Specify BPM and key when you have a preference
+- Use Exclude Styles field for what you DON'T want
+- Unexpected genre combos can be gold: "bossa nova trap",
+ "Appalachian gothic", "chiptune jazz"
+- Build a vocal PERSONA, not just a gender:
+ "A weathered torch singer with a smoky alto, slight rasp,
+ who starts vulnerable and builds to devastating power"
+
+### Metatags (place in [brackets] inside lyrics field)
+
+STRUCTURE:
+ [Intro] [Verse] [Verse 1] [Pre-Chorus] [Chorus]
+ [Post-Chorus] [Hook] [Bridge] [Interlude]
+ [Instrumental] [Instrumental Break] [Guitar Solo]
+ [Breakdown] [Build-up] [Outro] [Silence] [End]
+
+VOCAL PERFORMANCE:
+ [Whispered] [Spoken Word] [Belted] [Falsetto] [Powerful]
+ [Soulful] [Raspy] [Breathy] [Smooth] [Gritty]
+ [Staccato] [Legato] [Vibrato] [Melismatic]
+ [Harmonies] [Choir] [Harmonized Chorus]
+
+DYNAMICS:
+ [High Energy] [Low Energy] [Building Energy] [Explosive]
+ [Emotional Climax] [Gradual swell] [Orchestral swell]
+ [Quiet arrangement] [Falling tension] [Slow Down]
+
+GENDER:
+ [Female Vocals] [Male Vocals]
+
+ATMOSPHERE:
+ [Melancholic] [Euphoric] [Nostalgic] [Aggressive]
+ [Dreamy] [Intimate] [Dark Atmosphere]
+
+SFX:
+ [Vinyl Crackle] [Rain] [Applause] [Static] [Thunder]
+
+Put tags in BOTH style field AND lyrics for reinforcement.
+Keep to 5-8 tags per section max — too many confuses the AI.
+Don't contradict yourself ([Calm] + [Aggressive] in same section).
+
+### Custom Mode
+- Always use Custom Mode for serious work (separate Style + Lyrics)
+- Lyrics field limit: ~3,000 chars (~40-60 lines)
+- Always add structural tags — without them Suno defaults to
+ flat verse/chorus/verse with no emotional arc
+
+---
+
+## 7. Phonetic Tricks for AI Singers
+
+AI vocalists don't read — they pronounce. Help them:
+
+PHONETIC RESPELLING:
+- Spell words as they SOUND: "through" -> "thru"
+- Proper nouns are highest failure rate — test early
+- "Nous" -> "Noose" (forces correct pronunciation)
+- Hyphenate to guide syllables: "Re-search", "bio-engineering"
+
+DELIVERY CONTROL:
+- ALL CAPS = louder, more intense
+- Vowel extension: "lo-o-o-ove" = sustained/melisma
+- Ellipses: "I... need... you" = dramatic pauses
+- Hyphenated stretch: "ne-e-ed" = emotional stretch
+
+ALWAYS:
+- Spell out numbers: "24/7" -> "twenty four seven"
+- Space acronyms: "AI" -> "A I" or "A-I"
+- Test proper nouns/unusual words in a short 30-second clip first
+- Once generated, pronunciation is baked in — fix in lyrics BEFORE
+
+---
+
+## 8. Workflow
+
+1. Write the concept/hook first — what's the emotional core?
+2. If adapting, map the original structure (syllables, rhyme, stress)
+3. Generate raw material — brainstorm freely before structuring
+4. Draft lyrics into the structure
+5. Read/sing aloud — catch stumbles, fix meter
+6. Build the Suno style description — paint the dynamic journey
+7. Add metatags to lyrics for performance direction
+8. Generate 3-5 variations minimum — treat them like recording takes
+9. Pick the best, use Extend/Continue to build on promising sections
+10. If something great happens by accident, keep it
+
+EXPECT: ~3-5 generations per 1 good result. Revision is normal.
+Style can drift in extensions — restate genre/mood when extending.
+
+---
+
+## 9. Lessons Learned
+
+- Describing the dynamic ARC in the style field matters way more
+ than just listing genres. "Whisper to roar to whisper" gives
+ Suno a performance map.
+- Keeping some original lines intact in a parody adds recognizability
+ and emotional weight — the audience feels the ghost of the original.
+- The bridge slot in a song is where you can transform imagery.
+ Swap the original's specific references for your theme's metaphors
+ while keeping the emotional function (reflection, shift, revelation).
+- Monosyllabic word swaps in hooks/tags are the cleanest way to
+ maintain rhythm while changing meaning.
+- A strong vocal persona description in the style field makes a
+ bigger difference than any single metatag.
+- Don't be precious about rules. If a line breaks meter but hits
+ harder, keep it. The feeling is what matters. Craft serves art,
+ not the other way around.
diff --git a/skills/music-creation/DESCRIPTION.md b/skills/music-creation/DESCRIPTION.md
deleted file mode 100644
index 04ad703c9e44..000000000000
--- a/skills/music-creation/DESCRIPTION.md
+++ /dev/null
@@ -1,3 +0,0 @@
----
-description: Skills for generating, editing, and processing music and audio using AI models and audio tools.
----
diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py
index 08ed9bc8d5ec..35dcee7ad655 100644
--- a/tests/agent/test_auxiliary_client.py
+++ b/tests/agent/test_auxiliary_client.py
@@ -491,15 +491,17 @@ class TestGetTextAuxiliaryClient:
assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:2345/v1"
assert mock_openai.call_args.kwargs["api_key"] == "task-key"
- def test_task_direct_endpoint_without_openai_key_does_not_fall_back(self, monkeypatch):
+ def test_task_direct_endpoint_without_openai_key_uses_placeholder(self, monkeypatch):
+ """Local endpoints without an API key should use 'no-key-required' placeholder."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_BASE_URL", "http://localhost:2345/v1")
monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_MODEL", "task-model")
with patch("agent.auxiliary_client.OpenAI") as mock_openai:
client, model = get_text_auxiliary_client("web_extract")
- assert client is None
- assert model is None
- mock_openai.assert_not_called()
+ assert client is not None
+ assert model == "task-model"
+ assert mock_openai.call_args.kwargs["api_key"] == "no-key-required"
+ assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:2345/v1"
def test_custom_endpoint_uses_config_saved_base_url(self, monkeypatch):
config = {
@@ -696,15 +698,16 @@ class TestVisionClientFallback:
assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:4567/v1"
assert mock_openai.call_args.kwargs["api_key"] == "vision-key"
- def test_vision_direct_endpoint_requires_openai_api_key(self, monkeypatch):
+ def test_vision_direct_endpoint_without_key_uses_placeholder(self, monkeypatch):
+ """Vision endpoint without API key should use 'no-key-required' placeholder."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
monkeypatch.setenv("AUXILIARY_VISION_BASE_URL", "http://localhost:4567/v1")
monkeypatch.setenv("AUXILIARY_VISION_MODEL", "vision-model")
with patch("agent.auxiliary_client.OpenAI") as mock_openai:
client, model = get_vision_auxiliary_client()
- assert client is None
- assert model is None
- mock_openai.assert_not_called()
+ assert client is not None
+ assert model == "vision-model"
+ assert mock_openai.call_args.kwargs["api_key"] == "no-key-required"
def test_vision_uses_openrouter_when_available(self, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py
index 12bd804367dc..afec21ce797c 100644
--- a/tests/cron/test_scheduler.py
+++ b/tests/cron/test_scheduler.py
@@ -84,6 +84,48 @@ class TestResolveDeliveryTarget:
"thread_id": None,
}
+ def test_human_friendly_label_resolved_via_channel_directory(self):
+ """deliver: 'whatsapp:Alice (dm)' resolves to the real JID."""
+ job = {"deliver": "whatsapp:Alice (dm)"}
+ with patch(
+ "gateway.channel_directory.resolve_channel_name",
+ return_value="12345678901234@lid",
+ ):
+ result = _resolve_delivery_target(job)
+ assert result == {
+ "platform": "whatsapp",
+ "chat_id": "12345678901234@lid",
+ "thread_id": None,
+ }
+
+ def test_human_friendly_label_without_suffix_resolved(self):
+ """deliver: 'telegram:My Group' resolves without display suffix."""
+ job = {"deliver": "telegram:My Group"}
+ with patch(
+ "gateway.channel_directory.resolve_channel_name",
+ return_value="-1009999",
+ ):
+ result = _resolve_delivery_target(job)
+ assert result == {
+ "platform": "telegram",
+ "chat_id": "-1009999",
+ "thread_id": None,
+ }
+
+ def test_raw_id_not_mangled_when_directory_returns_none(self):
+ """deliver: 'whatsapp:12345@lid' passes through when directory has no match."""
+ job = {"deliver": "whatsapp:12345@lid"}
+ with patch(
+ "gateway.channel_directory.resolve_channel_name",
+ return_value=None,
+ ):
+ result = _resolve_delivery_target(job)
+ assert result == {
+ "platform": "whatsapp",
+ "chat_id": "12345@lid",
+ "thread_id": None,
+ }
+
def test_bare_platform_uses_matching_origin_chat(self):
job = {
"deliver": "telegram",
diff --git a/tests/gateway/test_allowlist_startup_check.py b/tests/gateway/test_allowlist_startup_check.py
index cd259e5a2207..96441c052135 100644
--- a/tests/gateway/test_allowlist_startup_check.py
+++ b/tests/gateway/test_allowlist_startup_check.py
@@ -13,7 +13,7 @@ def _would_warn():
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
"EMAIL_ALLOWED_USERS",
"SMS_ALLOWED_USERS", "MATTERMOST_ALLOWED_USERS",
- "MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS",
+ "MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS", "WECOM_ALLOWED_USERS",
"GATEWAY_ALLOWED_USERS")
)
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any(
@@ -22,7 +22,7 @@ def _would_warn():
"WHATSAPP_ALLOW_ALL_USERS", "SLACK_ALLOW_ALL_USERS",
"SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS",
"SMS_ALLOW_ALL_USERS", "MATTERMOST_ALLOW_ALL_USERS",
- "MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS")
+ "MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS", "FEISHU_ALLOW_ALL_USERS", "WECOM_ALLOW_ALL_USERS")
)
return not _any_allowlist and not _allow_all
diff --git a/tests/gateway/test_base_topic_sessions.py b/tests/gateway/test_base_topic_sessions.py
index e3ca7ae722f3..37e00b279d9e 100644
--- a/tests/gateway/test_base_topic_sessions.py
+++ b/tests/gateway/test_base_topic_sessions.py
@@ -15,6 +15,7 @@ class DummyTelegramAdapter(BasePlatformAdapter):
super().__init__(PlatformConfig(enabled=True, token="fake-token"), Platform.TELEGRAM)
self.sent = []
self.typing = []
+ self.processing_hooks = []
async def connect(self) -> bool:
return True
@@ -40,6 +41,12 @@ class DummyTelegramAdapter(BasePlatformAdapter):
async def get_chat_info(self, chat_id: str):
return {"id": chat_id}
+ async def on_processing_start(self, event: MessageEvent) -> None:
+ self.processing_hooks.append(("start", event.message_id))
+
+ async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
+ self.processing_hooks.append(("complete", event.message_id, success))
+
def _make_event(chat_id: str, thread_id: str, message_id: str = "1") -> MessageEvent:
return MessageEvent(
@@ -133,3 +140,83 @@ class TestBasePlatformTopicSessions:
"metadata": {"thread_id": "17585"},
}
]
+ assert adapter.processing_hooks == [
+ ("start", "1"),
+ ("complete", "1", True),
+ ]
+
+ @pytest.mark.asyncio
+ async def test_process_message_background_marks_total_send_failure_unsuccessful(self):
+ adapter = DummyTelegramAdapter()
+
+ async def handler(_event):
+ await asyncio.sleep(0)
+ return "ack"
+
+ async def failing_send(*_args, **_kwargs):
+ return SendResult(success=False, error="send failed")
+
+ async def hold_typing(_chat_id, interval=2.0, metadata=None):
+ await asyncio.Event().wait()
+
+ adapter.set_message_handler(handler)
+ adapter.send = failing_send
+ adapter._keep_typing = hold_typing
+
+ event = _make_event("-1001", "17585")
+ await adapter._process_message_background(event, build_session_key(event.source))
+
+ assert adapter.processing_hooks == [
+ ("start", "1"),
+ ("complete", "1", False),
+ ]
+
+ @pytest.mark.asyncio
+ async def test_process_message_background_marks_exception_unsuccessful(self):
+ adapter = DummyTelegramAdapter()
+
+ async def handler(_event):
+ await asyncio.sleep(0)
+ raise RuntimeError("boom")
+
+ async def hold_typing(_chat_id, interval=2.0, metadata=None):
+ await asyncio.Event().wait()
+
+ adapter.set_message_handler(handler)
+ adapter._keep_typing = hold_typing
+
+ event = _make_event("-1001", "17585")
+ await adapter._process_message_background(event, build_session_key(event.source))
+
+ assert adapter.processing_hooks == [
+ ("start", "1"),
+ ("complete", "1", False),
+ ]
+
+ @pytest.mark.asyncio
+ async def test_process_message_background_marks_cancellation_unsuccessful(self):
+ adapter = DummyTelegramAdapter()
+ release = asyncio.Event()
+
+ async def handler(_event):
+ await release.wait()
+ return "ack"
+
+ async def hold_typing(_chat_id, interval=2.0, metadata=None):
+ await asyncio.Event().wait()
+
+ adapter.set_message_handler(handler)
+ adapter._keep_typing = hold_typing
+
+ event = _make_event("-1001", "17585")
+ task = asyncio.create_task(adapter._process_message_background(event, build_session_key(event.source)))
+ await asyncio.sleep(0)
+ task.cancel()
+
+ with pytest.raises(asyncio.CancelledError):
+ await task
+
+ assert adapter.processing_hooks == [
+ ("start", "1"),
+ ("complete", "1", False),
+ ]
diff --git a/tests/gateway/test_discord_reactions.py b/tests/gateway/test_discord_reactions.py
new file mode 100644
index 000000000000..c19913a4cfe0
--- /dev/null
+++ b/tests/gateway/test_discord_reactions.py
@@ -0,0 +1,170 @@
+"""Tests for Discord message reactions tied to processing lifecycle hooks."""
+
+import asyncio
+import sys
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from gateway.config import Platform, PlatformConfig
+from gateway.platforms.base import MessageEvent, MessageType, SendResult
+from gateway.session import SessionSource, build_session_key
+
+
+def _ensure_discord_mock():
+ if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
+ return
+
+ discord_mod = MagicMock()
+ discord_mod.Intents.default.return_value = MagicMock()
+ discord_mod.DMChannel = type("DMChannel", (), {})
+ discord_mod.Thread = type("Thread", (), {})
+ discord_mod.ForumChannel = type("ForumChannel", (), {})
+ discord_mod.Interaction = object
+ discord_mod.app_commands = SimpleNamespace(
+ describe=lambda **kwargs: (lambda fn: fn),
+ choices=lambda **kwargs: (lambda fn: fn),
+ Choice=lambda **kwargs: SimpleNamespace(**kwargs),
+ )
+
+ ext_mod = MagicMock()
+ commands_mod = MagicMock()
+ commands_mod.Bot = MagicMock
+ ext_mod.commands = commands_mod
+
+ sys.modules.setdefault("discord", discord_mod)
+ sys.modules.setdefault("discord.ext", ext_mod)
+ sys.modules.setdefault("discord.ext.commands", commands_mod)
+
+
+_ensure_discord_mock()
+
+from gateway.platforms.discord import DiscordAdapter # noqa: E402
+
+
+class FakeTree:
+ def __init__(self):
+ self.commands = {}
+
+ def command(self, *, name, description):
+ def decorator(fn):
+ self.commands[name] = fn
+ return fn
+
+ return decorator
+
+
+@pytest.fixture
+def adapter():
+ config = PlatformConfig(enabled=True, token="***")
+ adapter = DiscordAdapter(config)
+ adapter._client = SimpleNamespace(
+ tree=FakeTree(),
+ get_channel=lambda _id: None,
+ fetch_channel=AsyncMock(),
+ user=SimpleNamespace(id=99999, name="HermesBot"),
+ )
+ return adapter
+
+
+def _make_event(message_id: str, raw_message) -> MessageEvent:
+ return MessageEvent(
+ text="hello",
+ message_type=MessageType.TEXT,
+ source=SessionSource(
+ platform=Platform.DISCORD,
+ chat_id="123",
+ chat_type="dm",
+ user_id="42",
+ user_name="Jezza",
+ ),
+ raw_message=raw_message,
+ message_id=message_id,
+ )
+
+
+@pytest.mark.asyncio
+async def test_process_message_background_adds_and_swaps_reactions(adapter):
+ raw_message = SimpleNamespace(
+ add_reaction=AsyncMock(),
+ remove_reaction=AsyncMock(),
+ )
+
+ async def handler(_event):
+ await asyncio.sleep(0)
+ return "ack"
+
+ async def hold_typing(_chat_id, interval=2.0, metadata=None):
+ await asyncio.Event().wait()
+
+ adapter.set_message_handler(handler)
+ adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="999"))
+ adapter._keep_typing = hold_typing
+
+ event = _make_event("1", raw_message)
+ await adapter._process_message_background(event, build_session_key(event.source))
+
+ assert raw_message.add_reaction.await_args_list[0].args == ("👀",)
+ assert raw_message.remove_reaction.await_args_list[0].args == ("👀", adapter._client.user)
+ assert raw_message.add_reaction.await_args_list[1].args == ("✅",)
+
+
+@pytest.mark.asyncio
+async def test_interaction_backed_events_do_not_attempt_reactions(adapter):
+ interaction = SimpleNamespace(guild_id=123456789)
+
+ async def handler(_event):
+ await asyncio.sleep(0)
+ return None
+
+ async def hold_typing(_chat_id, interval=2.0, metadata=None):
+ await asyncio.Event().wait()
+
+ adapter.set_message_handler(handler)
+ adapter._add_reaction = AsyncMock()
+ adapter._remove_reaction = AsyncMock()
+ adapter._keep_typing = hold_typing
+
+ event = MessageEvent(
+ text="/status",
+ message_type=MessageType.COMMAND,
+ source=SessionSource(
+ platform=Platform.DISCORD,
+ chat_id="123",
+ chat_type="dm",
+ user_id="42",
+ user_name="Jezza",
+ ),
+ raw_message=interaction,
+ message_id="2",
+ )
+
+ await adapter._process_message_background(event, build_session_key(event.source))
+
+ adapter._add_reaction.assert_not_awaited()
+ adapter._remove_reaction.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_reaction_helper_failures_do_not_break_message_flow(adapter):
+ raw_message = SimpleNamespace(
+ add_reaction=AsyncMock(side_effect=[RuntimeError("no perms"), RuntimeError("no perms")]),
+ remove_reaction=AsyncMock(side_effect=RuntimeError("no perms")),
+ )
+
+ async def handler(_event):
+ await asyncio.sleep(0)
+ return "ack"
+
+ async def hold_typing(_chat_id, interval=2.0, metadata=None):
+ await asyncio.Event().wait()
+
+ adapter.set_message_handler(handler)
+ adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="999"))
+ adapter._keep_typing = hold_typing
+
+ event = _make_event("3", raw_message)
+ await adapter._process_message_background(event, build_session_key(event.source))
+
+ adapter.send.assert_awaited_once()
diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py
new file mode 100644
index 000000000000..5344cda52af0
--- /dev/null
+++ b/tests/gateway/test_feishu.py
@@ -0,0 +1,2580 @@
+"""Tests for the Feishu gateway integration."""
+
+import asyncio
+import json
+import os
+import tempfile
+import time
+import unittest
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+try:
+ import lark_oapi
+ _HAS_LARK_OAPI = True
+except ImportError:
+ _HAS_LARK_OAPI = False
+
+
+class TestPlatformEnum(unittest.TestCase):
+ def test_feishu_in_platform_enum(self):
+ from gateway.config import Platform
+
+ self.assertEqual(Platform.FEISHU.value, "feishu")
+
+
+class TestConfigEnvOverrides(unittest.TestCase):
+ @patch.dict(os.environ, {
+ "FEISHU_APP_ID": "cli_xxx",
+ "FEISHU_APP_SECRET": "secret_xxx",
+ "FEISHU_CONNECTION_MODE": "websocket",
+ "FEISHU_DOMAIN": "feishu",
+ }, clear=False)
+ def test_feishu_config_loaded_from_env(self):
+ from gateway.config import GatewayConfig, Platform, _apply_env_overrides
+
+ config = GatewayConfig()
+ _apply_env_overrides(config)
+
+ self.assertIn(Platform.FEISHU, config.platforms)
+ self.assertTrue(config.platforms[Platform.FEISHU].enabled)
+ self.assertEqual(config.platforms[Platform.FEISHU].extra["app_id"], "cli_xxx")
+ self.assertEqual(config.platforms[Platform.FEISHU].extra["connection_mode"], "websocket")
+
+ @patch.dict(os.environ, {
+ "FEISHU_APP_ID": "cli_xxx",
+ "FEISHU_APP_SECRET": "secret_xxx",
+ "FEISHU_HOME_CHANNEL": "oc_xxx",
+ }, clear=False)
+ def test_feishu_home_channel_loaded(self):
+ from gateway.config import GatewayConfig, Platform, _apply_env_overrides
+
+ config = GatewayConfig()
+ _apply_env_overrides(config)
+
+ home = config.platforms[Platform.FEISHU].home_channel
+ self.assertIsNotNone(home)
+ self.assertEqual(home.chat_id, "oc_xxx")
+
+ @patch.dict(os.environ, {
+ "FEISHU_APP_ID": "cli_xxx",
+ "FEISHU_APP_SECRET": "secret_xxx",
+ }, clear=False)
+ def test_feishu_in_connected_platforms(self):
+ from gateway.config import GatewayConfig, Platform, _apply_env_overrides
+
+ config = GatewayConfig()
+ _apply_env_overrides(config)
+
+ self.assertIn(Platform.FEISHU, config.get_connected_platforms())
+
+
+class TestGatewayIntegration(unittest.TestCase):
+ def test_feishu_in_adapter_factory(self):
+ source = Path("gateway/run.py").read_text(encoding="utf-8")
+ self.assertIn("Platform.FEISHU", source)
+ self.assertIn("FeishuAdapter", source)
+
+ def test_feishu_in_authorization_maps(self):
+ source = Path("gateway/run.py").read_text(encoding="utf-8")
+ self.assertIn("FEISHU_ALLOWED_USERS", source)
+ self.assertIn("FEISHU_ALLOW_ALL_USERS", source)
+
+ def test_feishu_toolset_exists(self):
+ from toolsets import TOOLSETS
+
+ self.assertIn("hermes-feishu", TOOLSETS)
+ self.assertIn("hermes-feishu", TOOLSETS["hermes-gateway"]["includes"])
+
+
+class TestFeishuPostParsing(unittest.TestCase):
+ def test_parse_post_content_extracts_text_mentions_and_media_refs(self):
+ from gateway.platforms.feishu import parse_feishu_post_content
+
+ result = parse_feishu_post_content(
+ json.dumps(
+ {
+ "en_us": {
+ "title": "Rich message",
+ "content": [
+ [{"tag": "img", "image_key": "img_1", "alt": "diagram"}],
+ [{"tag": "at", "user_name": "Alice", "open_id": "ou_alice"}],
+ [{"tag": "media", "file_key": "file_1", "file_name": "spec.pdf"}],
+ ],
+ }
+ }
+ )
+ )
+
+ self.assertEqual(result.text_content, "Rich message\n[Image: diagram]\n@Alice\n[Attachment: spec.pdf]")
+ self.assertEqual(result.image_keys, ["img_1"])
+ self.assertEqual(result.mentioned_ids, ["ou_alice"])
+ self.assertEqual(len(result.media_refs), 1)
+ self.assertEqual(result.media_refs[0].file_key, "file_1")
+ self.assertEqual(result.media_refs[0].file_name, "spec.pdf")
+ self.assertEqual(result.media_refs[0].resource_type, "file")
+
+ def test_parse_post_content_uses_fallback_when_invalid(self):
+ from gateway.platforms.feishu import FALLBACK_POST_TEXT, parse_feishu_post_content
+
+ result = parse_feishu_post_content("not-json")
+
+ self.assertEqual(result.text_content, FALLBACK_POST_TEXT)
+ self.assertEqual(result.image_keys, [])
+ self.assertEqual(result.media_refs, [])
+ self.assertEqual(result.mentioned_ids, [])
+
+ def test_parse_post_content_preserves_rich_text_semantics(self):
+ from gateway.platforms.feishu import parse_feishu_post_content
+
+ result = parse_feishu_post_content(
+ json.dumps(
+ {
+ "en_us": {
+ "title": "Plan *v2*",
+ "content": [
+ [
+ {"tag": "text", "text": "Bold", "style": {"bold": True}},
+ {"tag": "text", "text": " "},
+ {"tag": "text", "text": "Italic", "style": {"italic": True}},
+ {"tag": "text", "text": " "},
+ {"tag": "text", "text": "Code", "style": {"code": True}},
+ ],
+ [{"tag": "text", "text": "line1"}, {"tag": "br"}, {"tag": "text", "text": "line2"}],
+ [{"tag": "hr"}],
+ [{"tag": "code_block", "language": "python", "text": "print('hi')"}],
+ ],
+ }
+ }
+ )
+ )
+
+ self.assertEqual(
+ result.text_content,
+ "Plan *v2*\n**Bold** *Italic* `Code`\nline1\nline2\n---\n```python\nprint('hi')\n```",
+ )
+
+
+class TestFeishuMessageNormalization(unittest.TestCase):
+ def test_normalize_merge_forward_preserves_summary_lines(self):
+ from gateway.platforms.feishu import normalize_feishu_message
+
+ normalized = normalize_feishu_message(
+ message_type="merge_forward",
+ raw_content=json.dumps(
+ {
+ "title": "Sprint recap",
+ "messages": [
+ {"sender_name": "Alice", "text": "Please review PR-128"},
+ {
+ "sender_name": "Bob",
+ "message_type": "post",
+ "content": {
+ "en_us": {
+ "content": [[{"tag": "text", "text": "Ship it"}]],
+ }
+ },
+ },
+ ],
+ }
+ ),
+ )
+
+ self.assertEqual(normalized.relation_kind, "merge_forward")
+ self.assertEqual(
+ normalized.text_content,
+ "Sprint recap\n- Alice: Please review PR-128\n- Bob: Ship it",
+ )
+
+ def test_normalize_share_chat_exposes_summary_and_metadata(self):
+ from gateway.platforms.feishu import normalize_feishu_message
+
+ normalized = normalize_feishu_message(
+ message_type="share_chat",
+ raw_content=json.dumps(
+ {
+ "chat_id": "oc_chat_shared",
+ "chat_name": "Backend Guild",
+ }
+ ),
+ )
+
+ self.assertEqual(normalized.relation_kind, "share_chat")
+ self.assertEqual(normalized.text_content, "Shared chat: Backend Guild\nChat ID: oc_chat_shared")
+ self.assertEqual(normalized.metadata["chat_id"], "oc_chat_shared")
+ self.assertEqual(normalized.metadata["chat_name"], "Backend Guild")
+
+ def test_normalize_interactive_card_preserves_title_body_and_actions(self):
+ from gateway.platforms.feishu import normalize_feishu_message
+
+ normalized = normalize_feishu_message(
+ message_type="interactive",
+ raw_content=json.dumps(
+ {
+ "card": {
+ "header": {"title": {"tag": "plain_text", "content": "Build Failed"}},
+ "elements": [
+ {"tag": "div", "text": {"tag": "lark_md", "content": "Service: payments-api"}},
+ {"tag": "div", "text": {"tag": "plain_text", "content": "Branch: main"}},
+ {
+ "tag": "action",
+ "actions": [
+ {"tag": "button", "text": {"tag": "plain_text", "content": "View Logs"}},
+ {"tag": "button", "text": {"tag": "plain_text", "content": "Retry"}},
+ ],
+ },
+ ],
+ }
+ }
+ ),
+ )
+
+ self.assertEqual(normalized.relation_kind, "interactive")
+ self.assertEqual(
+ normalized.text_content,
+ "Build Failed\nService: payments-api\nBranch: main\nView Logs\nRetry\nActions: View Logs, Retry",
+ )
+
+
+class TestFeishuAdapterMessaging(unittest.TestCase):
+ @patch.dict(os.environ, {
+ "FEISHU_APP_ID": "cli_app",
+ "FEISHU_APP_SECRET": "secret_app",
+ "FEISHU_CONNECTION_MODE": "webhook",
+ "FEISHU_WEBHOOK_HOST": "127.0.0.1",
+ "FEISHU_WEBHOOK_PORT": "9001",
+ "FEISHU_WEBHOOK_PATH": "/hook",
+ }, clear=True)
+ def test_connect_webhook_mode_starts_local_server(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ runner = AsyncMock()
+ site = AsyncMock()
+ web_module = SimpleNamespace(
+ Application=lambda: SimpleNamespace(router=SimpleNamespace(add_post=lambda *_args, **_kwargs: None)),
+ AppRunner=lambda _app: runner,
+ TCPSite=lambda _runner, host, port: SimpleNamespace(start=site.start, host=host, port=port),
+ )
+
+ with (
+ patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True),
+ patch("gateway.platforms.feishu.FEISHU_WEBHOOK_AVAILABLE", True),
+ patch("gateway.platforms.feishu.acquire_scoped_lock", return_value=(True, None)),
+ patch("gateway.platforms.feishu.release_scoped_lock"),
+ patch.object(adapter, "_hydrate_bot_identity", new=AsyncMock()),
+ patch.object(adapter, "_build_lark_client", return_value=SimpleNamespace()),
+ patch("gateway.platforms.feishu.web", web_module),
+ ):
+ connected = asyncio.run(adapter.connect())
+
+ self.assertTrue(connected)
+ runner.setup.assert_awaited_once()
+ site.start.assert_awaited_once()
+
+ @patch.dict(os.environ, {
+ "FEISHU_APP_ID": "cli_app",
+ "FEISHU_APP_SECRET": "secret_app",
+ }, clear=True)
+ def test_connect_acquires_scoped_lock_and_disconnect_releases_it(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ ws_client = object()
+
+ with (
+ patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True),
+ patch("gateway.platforms.feishu.FEISHU_WEBSOCKET_AVAILABLE", True),
+ patch("gateway.platforms.feishu.lark", SimpleNamespace(LogLevel=SimpleNamespace(INFO="INFO", WARNING="WARNING"))),
+ patch("gateway.platforms.feishu.EventDispatcherHandler", object()),
+ patch("gateway.platforms.feishu.FeishuWSClient", return_value=ws_client),
+ patch("gateway.platforms.feishu._run_official_feishu_ws_client"),
+ patch("gateway.platforms.feishu.acquire_scoped_lock", return_value=(True, None)) as acquire_lock,
+ patch("gateway.platforms.feishu.release_scoped_lock") as release_lock,
+ patch.object(adapter, "_hydrate_bot_identity", new=AsyncMock()),
+ patch.object(adapter, "_build_lark_client", return_value=SimpleNamespace()),
+ ):
+ loop = asyncio.new_event_loop()
+ future = loop.create_future()
+ future.set_result(None)
+
+ class _Loop:
+ def run_in_executor(self, *_args, **_kwargs):
+ return future
+
+ try:
+ with patch("gateway.platforms.feishu.asyncio.get_running_loop", return_value=_Loop()):
+ connected = asyncio.run(adapter.connect())
+ asyncio.run(adapter.disconnect())
+ finally:
+ loop.close()
+
+ self.assertTrue(connected)
+ acquire_lock.assert_called_once_with(
+ "feishu-app-id",
+ "cli_app",
+ metadata={"platform": "feishu"},
+ )
+ release_lock.assert_called_once_with("feishu-app-id", "cli_app")
+
+ @patch.dict(os.environ, {
+ "FEISHU_APP_ID": "cli_app",
+ "FEISHU_APP_SECRET": "secret_app",
+ }, clear=True)
+ def test_connect_rejects_existing_app_lock(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+
+ with (
+ patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True),
+ patch("gateway.platforms.feishu.FEISHU_WEBSOCKET_AVAILABLE", True),
+ patch(
+ "gateway.platforms.feishu.acquire_scoped_lock",
+ return_value=(False, {"pid": 4321}),
+ ),
+ ):
+ connected = asyncio.run(adapter.connect())
+
+ self.assertFalse(connected)
+ self.assertEqual(adapter.fatal_error_code, "feishu_app_lock")
+ self.assertFalse(adapter.fatal_error_retryable)
+ self.assertIn("PID 4321", adapter.fatal_error_message)
+
+ @patch.dict(os.environ, {
+ "FEISHU_APP_ID": "cli_app",
+ "FEISHU_APP_SECRET": "secret_app",
+ }, clear=True)
+ def test_connect_retries_transient_startup_failure(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ ws_client = object()
+ sleeps = []
+
+ with (
+ patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True),
+ patch("gateway.platforms.feishu.FEISHU_WEBSOCKET_AVAILABLE", True),
+ patch("gateway.platforms.feishu.lark", SimpleNamespace(LogLevel=SimpleNamespace(INFO="INFO", WARNING="WARNING"))),
+ patch("gateway.platforms.feishu.EventDispatcherHandler", object()),
+ patch("gateway.platforms.feishu.FeishuWSClient", return_value=ws_client),
+ patch("gateway.platforms.feishu.acquire_scoped_lock", return_value=(True, None)),
+ patch("gateway.platforms.feishu.release_scoped_lock"),
+ patch.object(adapter, "_hydrate_bot_identity", new=AsyncMock()),
+ patch("gateway.platforms.feishu.asyncio.sleep", side_effect=lambda delay: sleeps.append(delay)),
+ patch.object(adapter, "_build_lark_client", return_value=SimpleNamespace()),
+ ):
+ loop = asyncio.new_event_loop()
+ future = loop.create_future()
+ future.set_result(None)
+
+ class _Loop:
+ def __init__(self):
+ self.calls = 0
+
+ def run_in_executor(self, *_args, **_kwargs):
+ self.calls += 1
+ if self.calls == 1:
+ raise OSError("temporary websocket failure")
+ return future
+
+ fake_loop = _Loop()
+ try:
+ with patch("gateway.platforms.feishu.asyncio.get_running_loop", return_value=fake_loop):
+ connected = asyncio.run(adapter.connect())
+ finally:
+ loop.close()
+
+ self.assertTrue(connected)
+ self.assertEqual(sleeps, [1])
+ self.assertEqual(fake_loop.calls, 2)
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_edit_message_updates_existing_feishu_message(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ captured = {}
+
+ class _MessageAPI:
+ def update(self, request):
+ captured["request"] = request
+ return SimpleNamespace(success=lambda: True)
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(
+ v1=SimpleNamespace(
+ message=_MessageAPI(),
+ )
+ )
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
+ result = asyncio.run(
+ adapter.edit_message(
+ chat_id="oc_chat",
+ message_id="om_progress",
+ content="📖 read_file: \"/tmp/image.png\"",
+ )
+ )
+
+ self.assertTrue(result.success)
+ self.assertEqual(result.message_id, "om_progress")
+ self.assertEqual(captured["request"].message_id, "om_progress")
+ self.assertEqual(captured["request"].request_body.msg_type, "text")
+ self.assertEqual(
+ captured["request"].request_body.content,
+ json.dumps({"text": "📖 read_file: \"/tmp/image.png\""}, ensure_ascii=False),
+ )
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_edit_message_falls_back_to_text_when_post_update_is_rejected(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ captured = {"calls": []}
+
+ class _MessageAPI:
+ def update(self, request):
+ captured["calls"].append(request)
+ if len(captured["calls"]) == 1:
+ return SimpleNamespace(success=lambda: False, code=230001, msg="content format of the post type is incorrect")
+ return SimpleNamespace(success=lambda: True)
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(
+ v1=SimpleNamespace(
+ message=_MessageAPI(),
+ )
+ )
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
+ result = asyncio.run(
+ adapter.edit_message(
+ chat_id="oc_chat",
+ message_id="om_progress",
+ content="可以用 **粗体** 和 *斜体*。",
+ )
+ )
+
+ self.assertTrue(result.success)
+ self.assertEqual(captured["calls"][0].request_body.msg_type, "post")
+ self.assertEqual(captured["calls"][1].request_body.msg_type, "text")
+ self.assertEqual(
+ captured["calls"][1].request_body.content,
+ json.dumps({"text": "可以用 粗体 和 斜体。"}, ensure_ascii=False),
+ )
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_get_chat_info_uses_real_feishu_chat_api(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+
+ class _ChatAPI:
+ def get(self, request):
+ self.request = request
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(name="Hermes Group", chat_type="group"),
+ )
+
+ chat_api = _ChatAPI()
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(
+ v1=SimpleNamespace(
+ chat=chat_api,
+ )
+ )
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
+ info = asyncio.run(adapter.get_chat_info("oc_chat"))
+
+ self.assertEqual(chat_api.request.chat_id, "oc_chat")
+ self.assertEqual(info["chat_id"], "oc_chat")
+ self.assertEqual(info["name"], "Hermes Group")
+ self.assertEqual(info["type"], "group")
+
+class TestAdapterModule(unittest.TestCase):
+ def test_adapter_requirement_helper_exists(self):
+ source = Path("gateway/platforms/feishu.py").read_text(encoding="utf-8")
+ self.assertIn("def check_feishu_requirements()", source)
+ self.assertIn("FEISHU_AVAILABLE", source)
+
+ def test_adapter_declares_websocket_scope(self):
+ source = Path("gateway/platforms/feishu.py").read_text(encoding="utf-8")
+ self.assertIn("Supported modes: websocket, webhook", source)
+ self.assertIn("FEISHU_CONNECTION_MODE", source)
+
+ def test_adapter_registers_message_read_noop_handler(self):
+ source = Path("gateway/platforms/feishu.py").read_text(encoding="utf-8")
+ self.assertIn("register_p2_im_message_message_read_v1", source)
+ self.assertIn("def _on_message_read_event", source)
+
+ def test_adapter_registers_reaction_and_card_handlers_for_websocket(self):
+ source = Path("gateway/platforms/feishu.py").read_text(encoding="utf-8")
+ self.assertIn("register_p2_im_message_reaction_created_v1", source)
+ self.assertIn("register_p2_im_message_reaction_deleted_v1", source)
+ self.assertIn("register_p2_card_action_trigger", source)
+
+
+class TestAdapterBehavior(unittest.TestCase):
+ @patch.dict(os.environ, {}, clear=True)
+ def test_build_event_handler_registers_reaction_and_card_processors(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ calls = []
+
+ class _Builder:
+ def register_p2_im_message_message_read_v1(self, _handler):
+ calls.append("message_read")
+ return self
+
+ def register_p2_im_message_receive_v1(self, _handler):
+ calls.append("message_receive")
+ return self
+
+ def register_p2_im_message_reaction_created_v1(self, _handler):
+ calls.append("reaction_created")
+ return self
+
+ def register_p2_im_message_reaction_deleted_v1(self, _handler):
+ calls.append("reaction_deleted")
+ return self
+
+ def register_p2_card_action_trigger(self, _handler):
+ calls.append("card_action")
+ return self
+
+ def build(self):
+ calls.append("build")
+ return "handler"
+
+ class _Dispatcher:
+ @staticmethod
+ def builder(_encrypt_key, _verification_token):
+ calls.append("builder")
+ return _Builder()
+
+ with patch("gateway.platforms.feishu.EventDispatcherHandler", _Dispatcher):
+ handler = adapter._build_event_handler()
+
+ self.assertEqual(handler, "handler")
+ self.assertEqual(
+ calls,
+ [
+ "builder",
+ "message_read",
+ "message_receive",
+ "reaction_created",
+ "reaction_deleted",
+ "card_action",
+ "build",
+ ],
+ )
+
+ @patch.dict(os.environ, {}, clear=True)
+ @unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
+ def test_add_ack_reaction_uses_ok_emoji(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ captured = {}
+
+ class _ReactionAPI:
+ def create(self, request):
+ captured["request"] = request
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(reaction_id="r_typing"),
+ )
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(v1=SimpleNamespace(message_reaction=_ReactionAPI()))
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
+ reaction_id = asyncio.run(adapter._add_ack_reaction("om_msg"))
+
+ self.assertEqual(reaction_id, "r_typing")
+ self.assertEqual(captured["request"].request_body.reaction_type["emoji_type"], "OK")
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_add_ack_reaction_logs_warning_on_failure(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+
+ class _ReactionAPI:
+ def create(self, request):
+ raise RuntimeError("boom")
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(v1=SimpleNamespace(message_reaction=_ReactionAPI()))
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ with (
+ patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct),
+ self.assertLogs("gateway.platforms.feishu", level="WARNING") as logs,
+ ):
+ reaction_id = asyncio.run(adapter._add_ack_reaction("om_msg"))
+
+ self.assertIsNone(reaction_id)
+ self.assertTrue(
+ any("Failed to add ack reaction to om_msg" in entry for entry in logs.output),
+ logs.output,
+ )
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_ack_reaction_events_are_ignored_to_avoid_feedback_loops(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ adapter._loop = object()
+ event = SimpleNamespace(
+ message_id="om_msg",
+ operator_type="user",
+ reaction_type=SimpleNamespace(emoji_type="OK"),
+ )
+ data = SimpleNamespace(event=event)
+
+ with patch("gateway.platforms.feishu.asyncio.run_coroutine_threadsafe") as run_threadsafe:
+ adapter._on_reaction_event("im.message.reaction.created_v1", data)
+
+ run_threadsafe.assert_not_called()
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_normalize_inbound_text_strips_feishu_mentions(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ cleaned = adapter._normalize_inbound_text("hi @_user_1 there @_user_2")
+ self.assertEqual(cleaned, "hi there")
+
+ @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True)
+ def test_group_message_requires_mentions_even_when_policy_open(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ message = SimpleNamespace(mentions=[])
+ sender_id = SimpleNamespace(open_id="ou_any", user_id=None)
+ self.assertFalse(adapter._should_accept_group_message(message, sender_id))
+
+ message_with_mention = SimpleNamespace(mentions=[SimpleNamespace(key="@_user_1")])
+ self.assertFalse(adapter._should_accept_group_message(message_with_mention, sender_id))
+
+ @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True)
+ def test_group_message_with_other_user_mention_is_rejected_when_bot_identity_unknown(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ sender_id = SimpleNamespace(open_id="ou_any", user_id=None)
+ other_mention = SimpleNamespace(
+ name="Other User",
+ id=SimpleNamespace(open_id="ou_other", user_id="u_other"),
+ )
+
+ self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[other_mention]), sender_id))
+
+ @patch.dict(
+ os.environ,
+ {
+ "FEISHU_GROUP_POLICY": "allowlist",
+ "FEISHU_ALLOWED_USERS": "ou_allowed",
+ "FEISHU_BOT_NAME": "Hermes Bot",
+ },
+ clear=True,
+ )
+ def test_group_message_allowlist_and_mention_both_required(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ mentioned = SimpleNamespace(
+ mentions=[
+ SimpleNamespace(
+ name="Hermes Bot",
+ id=SimpleNamespace(open_id="ou_other", user_id="u_other"),
+ )
+ ]
+ )
+
+ self.assertTrue(
+ adapter._should_accept_group_message(
+ mentioned,
+ SimpleNamespace(open_id="ou_allowed", user_id=None),
+ )
+ )
+ self.assertFalse(
+ adapter._should_accept_group_message(
+ mentioned,
+ SimpleNamespace(open_id="ou_blocked", user_id=None),
+ )
+ )
+
+ @patch.dict(
+ os.environ,
+ {
+ "FEISHU_GROUP_POLICY": "open",
+ "FEISHU_BOT_OPEN_ID": "ou_bot",
+ },
+ clear=True,
+ )
+ def test_group_message_matches_bot_open_id_when_configured(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ sender_id = SimpleNamespace(open_id="ou_any", user_id=None)
+
+ bot_mention = SimpleNamespace(
+ name="Hermes",
+ id=SimpleNamespace(open_id="ou_bot", user_id="u_bot"),
+ )
+ other_mention = SimpleNamespace(
+ name="Other",
+ id=SimpleNamespace(open_id="ou_other", user_id="u_other"),
+ )
+
+ self.assertTrue(adapter._should_accept_group_message(SimpleNamespace(mentions=[bot_mention]), sender_id))
+ self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[other_mention]), sender_id))
+
+ @patch.dict(
+ os.environ,
+ {
+ "FEISHU_GROUP_POLICY": "open",
+ "FEISHU_BOT_NAME": "Hermes Bot",
+ },
+ clear=True,
+ )
+ def test_group_message_matches_bot_name_when_only_name_available(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ sender_id = SimpleNamespace(open_id="ou_any", user_id=None)
+
+ named_mention = SimpleNamespace(
+ name="Hermes Bot",
+ id=SimpleNamespace(open_id="ou_other", user_id="u_other"),
+ )
+ different_mention = SimpleNamespace(
+ name="Another Bot",
+ id=SimpleNamespace(open_id="ou_other", user_id="u_other"),
+ )
+
+ self.assertTrue(adapter._should_accept_group_message(SimpleNamespace(mentions=[named_mention]), sender_id))
+ self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[different_mention]), sender_id))
+
+ @patch.dict(
+ os.environ,
+ {
+ "FEISHU_GROUP_POLICY": "open",
+ "FEISHU_BOT_OPEN_ID": "ou_bot",
+ },
+ clear=True,
+ )
+ def test_group_post_message_uses_parsed_mentions_when_sdk_mentions_missing(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ sender_id = SimpleNamespace(open_id="ou_any", user_id=None)
+ message = SimpleNamespace(
+ message_type="post",
+ mentions=[],
+ content='{"en_us":{"content":[[{"tag":"at","user_name":"Hermes","open_id":"ou_bot"}]]}}',
+ )
+
+ self.assertTrue(adapter._should_accept_group_message(message, sender_id))
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_extract_post_message_as_text(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ message = SimpleNamespace(
+ message_type="post",
+ content='{"zh_cn":{"title":"Title","content":[[{"tag":"text","text":"hello "}],[{"tag":"a","text":"doc","href":"https://example.com"}]]}}',
+ message_id="om_post",
+ )
+
+ text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))
+
+ self.assertEqual(text, "Title\nhello\n[doc](https://example.com)")
+ self.assertEqual(msg_type.value, "text")
+ self.assertEqual(media_urls, [])
+ self.assertEqual(media_types, [])
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_extract_post_message_uses_first_available_language_block(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ message = SimpleNamespace(
+ message_type="post",
+ content='{"fr_fr":{"title":"Subject","content":[[{"tag":"text","text":"bonjour"}]]}}',
+ message_id="om_post_fr",
+ )
+
+ text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))
+
+ self.assertEqual(text, "Subject\nbonjour")
+ self.assertEqual(msg_type.value, "text")
+ self.assertEqual(media_urls, [])
+ self.assertEqual(media_types, [])
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_extract_post_message_with_rich_elements_does_not_drop_content(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ message = SimpleNamespace(
+ message_type="post",
+ content=(
+ '{"en_us":{"title":"Rich message","content":['
+ '[{"tag":"img","alt":"diagram"}],'
+ '[{"tag":"at","user_name":"Alice"},{"tag":"text","text":" please check the attachment"}],'
+ '[{"tag":"media","file_name":"spec.pdf"}],'
+ '[{"tag":"emotion","emoji_type":"smile"}]'
+ ']}}'
+ ),
+ message_id="om_post_rich",
+ )
+
+ text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))
+
+ self.assertEqual(text, "Rich message\n[Image: diagram]\n@Alice please check the attachment\n[Attachment: spec.pdf]\n:smile:")
+ self.assertEqual(msg_type.value, "text")
+ self.assertEqual(media_urls, [])
+ self.assertEqual(media_types, [])
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_extract_post_message_downloads_embedded_resources(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ adapter._download_feishu_image = AsyncMock(return_value=("/tmp/feishu-image.png", "image/png"))
+ adapter._download_feishu_message_resource = AsyncMock(return_value=("/tmp/spec.pdf", "application/pdf"))
+ message = SimpleNamespace(
+ message_type="post",
+ content=(
+ '{"en_us":{"title":"Rich message","content":['
+ '[{"tag":"img","image_key":"img_123","alt":"diagram"}],'
+ '[{"tag":"media","file_key":"file_123","file_name":"spec.pdf"}]'
+ ']}}'
+ ),
+ message_id="om_post_media",
+ )
+
+ text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))
+
+ self.assertEqual(text, "Rich message\n[Image: diagram]\n[Attachment: spec.pdf]")
+ self.assertEqual(msg_type.value, "text")
+ self.assertEqual(media_urls, ["/tmp/feishu-image.png", "/tmp/spec.pdf"])
+ self.assertEqual(media_types, ["image/png", "application/pdf"])
+ adapter._download_feishu_image.assert_awaited_once_with(
+ message_id="om_post_media",
+ image_key="img_123",
+ )
+ adapter._download_feishu_message_resource.assert_awaited_once_with(
+ message_id="om_post_media",
+ file_key="file_123",
+ resource_type="file",
+ fallback_filename="spec.pdf",
+ )
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_extract_merge_forward_message_as_text_summary(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ message = SimpleNamespace(
+ message_type="merge_forward",
+ content=json.dumps(
+ {
+ "title": "Forwarded updates",
+ "messages": [
+ {"sender_name": "Alice", "text": "Investigating the incident"},
+ {"sender_name": "Bob", "text": "ETA 10 minutes"},
+ ],
+ }
+ ),
+ message_id="om_merge_forward",
+ )
+
+ text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))
+
+ self.assertEqual(
+ text,
+ "Forwarded updates\n- Alice: Investigating the incident\n- Bob: ETA 10 minutes",
+ )
+ self.assertEqual(msg_type.value, "text")
+ self.assertEqual(media_urls, [])
+ self.assertEqual(media_types, [])
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_extract_share_chat_message_as_text_summary(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ message = SimpleNamespace(
+ message_type="share_chat",
+ content='{"chat_id":"oc_shared","chat_name":"Platform Ops"}',
+ message_id="om_share_chat",
+ )
+
+ text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))
+
+ self.assertEqual(text, "Shared chat: Platform Ops\nChat ID: oc_shared")
+ self.assertEqual(msg_type.value, "text")
+ self.assertEqual(media_urls, [])
+ self.assertEqual(media_types, [])
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_extract_interactive_message_as_text_summary(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ message = SimpleNamespace(
+ message_type="interactive",
+ content=json.dumps(
+ {
+ "card": {
+ "header": {"title": {"tag": "plain_text", "content": "Approval Request"}},
+ "elements": [
+ {"tag": "div", "text": {"tag": "plain_text", "content": "Requester: Alice"}},
+ {
+ "tag": "action",
+ "actions": [
+ {"tag": "button", "text": {"tag": "plain_text", "content": "Approve"}},
+ ],
+ },
+ ],
+ }
+ }
+ ),
+ message_id="om_interactive",
+ )
+
+ text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))
+
+ self.assertEqual(text, "Approval Request\nRequester: Alice\nApprove\nActions: Approve")
+ self.assertEqual(msg_type.value, "text")
+ self.assertEqual(media_urls, [])
+ self.assertEqual(media_types, [])
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_extract_image_message_downloads_and_caches(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ adapter._download_feishu_image = AsyncMock(return_value=("/tmp/feishu-image.png", "image/png"))
+ message = SimpleNamespace(
+ message_type="image",
+ content='{"image_key":"img_123"}',
+ message_id="om_image",
+ )
+
+ text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))
+
+ self.assertEqual(text, "")
+ self.assertEqual(msg_type.value, "photo")
+ self.assertEqual(media_urls, ["/tmp/feishu-image.png"])
+ self.assertEqual(media_types, ["image/png"])
+ adapter._download_feishu_image.assert_awaited_once_with(
+ message_id="om_image",
+ image_key="img_123",
+ )
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_extract_audio_message_downloads_and_caches(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ adapter._download_feishu_message_resource = AsyncMock(
+ return_value=("/tmp/feishu-audio.ogg", "audio/ogg")
+ )
+ message = SimpleNamespace(
+ message_type="audio",
+ content='{"file_key":"file_audio","file_name":"voice.ogg"}',
+ message_id="om_audio",
+ )
+
+ text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))
+
+ self.assertEqual(text, "")
+ self.assertEqual(msg_type.value, "audio")
+ self.assertEqual(media_urls, ["/tmp/feishu-audio.ogg"])
+ self.assertEqual(media_types, ["audio/ogg"])
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_extract_file_message_downloads_and_caches(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ adapter._download_feishu_message_resource = AsyncMock(
+ return_value=("/tmp/doc_123_report.pdf", "application/pdf")
+ )
+ message = SimpleNamespace(
+ message_type="file",
+ content='{"file_key":"file_doc","file_name":"report.pdf"}',
+ message_id="om_file",
+ )
+
+ text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))
+
+ self.assertEqual(text, "")
+ self.assertEqual(msg_type.value, "document")
+ self.assertEqual(media_urls, ["/tmp/doc_123_report.pdf"])
+ self.assertEqual(media_types, ["application/pdf"])
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_extract_media_message_with_image_mime_becomes_photo(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ adapter._download_feishu_message_resource = AsyncMock(
+ return_value=("/tmp/feishu-media.jpg", "image/jpeg")
+ )
+ message = SimpleNamespace(
+ message_type="media",
+ content='{"file_key":"file_media","file_name":"photo.jpg"}',
+ message_id="om_media",
+ )
+
+ text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))
+
+ self.assertEqual(text, "")
+ self.assertEqual(msg_type.value, "photo")
+ self.assertEqual(media_urls, ["/tmp/feishu-media.jpg"])
+ self.assertEqual(media_types, ["image/jpeg"])
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_extract_media_message_with_video_mime_becomes_video(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ adapter._download_feishu_message_resource = AsyncMock(
+ return_value=("/tmp/feishu-video.mp4", "video/mp4")
+ )
+ message = SimpleNamespace(
+ message_type="media",
+ content='{"file_key":"file_video","file_name":"clip.mp4"}',
+ message_id="om_video",
+ )
+
+ text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message))
+
+ self.assertEqual(text, "")
+ self.assertEqual(msg_type.value, "video")
+ self.assertEqual(media_urls, ["/tmp/feishu-video.mp4"])
+ self.assertEqual(media_types, ["video/mp4"])
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_extract_text_from_raw_content_uses_relation_message_fallbacks(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+
+ shared = adapter._extract_text_from_raw_content(
+ msg_type="share_chat",
+ raw_content='{"chat_id":"oc_shared","chat_name":"Platform Ops"}',
+ )
+ attachment = adapter._extract_text_from_raw_content(
+ msg_type="file",
+ raw_content='{"file_key":"file_1","file_name":"report.pdf"}',
+ )
+
+ self.assertEqual(shared, "Shared chat: Platform Ops\nChat ID: oc_shared")
+ self.assertEqual(attachment, "[Attachment: report.pdf]")
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_extract_text_message_starting_with_slash_becomes_command(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ adapter._dispatch_inbound_event = AsyncMock()
+ adapter.get_chat_info = AsyncMock(
+ return_value={"chat_id": "oc_chat", "name": "Feishu DM", "type": "dm"}
+ )
+ adapter._resolve_sender_profile = AsyncMock(
+ return_value={"user_id": "ou_user", "user_name": "张三", "user_id_alt": None}
+ )
+ message = SimpleNamespace(
+ chat_id="oc_chat",
+ thread_id=None,
+ parent_id=None,
+ upper_message_id=None,
+ message_type="text",
+ content='{"text":"/help test"}',
+ message_id="om_command",
+ )
+
+ asyncio.run(
+ adapter._process_inbound_message(
+ data=SimpleNamespace(event=SimpleNamespace(message=message)),
+ message=message,
+ sender_id=SimpleNamespace(open_id="ou_user", user_id=None, union_id=None),
+ chat_type="p2p",
+ message_id="om_command",
+ )
+ )
+
+ event = adapter._dispatch_inbound_event.await_args.args[0]
+ self.assertEqual(event.message_type.value, "command")
+ self.assertEqual(event.text, "/help test")
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_extract_text_file_injects_content(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as tmp:
+ tmp.write("hello from feishu")
+ path = tmp.name
+
+ try:
+ text = asyncio.run(adapter._maybe_extract_text_document(path, "text/plain"))
+ finally:
+ os.unlink(path)
+
+ self.assertIn("hello from feishu", text)
+ self.assertIn("[Content of", text)
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_message_event_submits_to_adapter_loop(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ adapter._loop = object()
+
+ message = SimpleNamespace(
+ message_id="om_text",
+ chat_type="p2p",
+ chat_id="oc_chat",
+ message_type="text",
+ content='{"text":"hello"}',
+ )
+ sender_id = SimpleNamespace(open_id="ou_user", user_id=None, union_id=None)
+ sender = SimpleNamespace(sender_id=sender_id, sender_type="user")
+ data = SimpleNamespace(event=SimpleNamespace(message=message, sender=sender))
+
+ future = SimpleNamespace(add_done_callback=lambda *_args, **_kwargs: None)
+ def _submit(coro, _loop):
+ coro.close()
+ return future
+
+ with patch("gateway.platforms.feishu.asyncio.run_coroutine_threadsafe", side_effect=_submit) as submit:
+ adapter._on_message_event(data)
+
+ self.assertTrue(submit.called)
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_process_inbound_message_uses_event_sender_identity_only(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.base import MessageType
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ adapter._dispatch_inbound_event = AsyncMock()
+ # Sender name now comes from the contact API; mock it to return a known value.
+ adapter._resolve_sender_name_from_api = AsyncMock(return_value="张三")
+ adapter.get_chat_info = AsyncMock(
+ return_value={"chat_id": "oc_chat", "name": "Feishu DM", "type": "dm"}
+ )
+ message = SimpleNamespace(
+ chat_id="oc_chat",
+ thread_id=None,
+ message_type="text",
+ content='{"text":"hello"}',
+ message_id="om_text",
+ )
+ sender_id = SimpleNamespace(
+ open_id="ou_user",
+ user_id="u_user",
+ union_id="on_union",
+ )
+ data = SimpleNamespace(event=SimpleNamespace(message=message, sender=SimpleNamespace(sender_id=sender_id)))
+
+ asyncio.run(
+ adapter._process_inbound_message(
+ data=data,
+ message=message,
+ sender_id=sender_id,
+ chat_type="p2p",
+ message_id="om_text",
+ )
+ )
+
+ adapter._dispatch_inbound_event.assert_awaited_once()
+ event = adapter._dispatch_inbound_event.await_args.args[0]
+ self.assertEqual(event.message_type, MessageType.TEXT)
+ self.assertEqual(event.source.user_id, "ou_user")
+ self.assertEqual(event.source.user_name, "张三")
+ self.assertEqual(event.source.user_id_alt, "on_union")
+ self.assertEqual(event.source.chat_name, "Feishu DM")
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_text_batch_merges_rapid_messages_into_single_event(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.base import MessageEvent, MessageType
+ from gateway.platforms.feishu import FeishuAdapter
+ from gateway.session import SessionSource
+
+ adapter = FeishuAdapter(PlatformConfig())
+ adapter.handle_message = AsyncMock()
+ source = SessionSource(
+ platform=adapter.platform,
+ chat_id="oc_chat",
+ chat_name="Feishu DM",
+ chat_type="dm",
+ user_id="ou_user",
+ user_name="张三",
+ )
+
+ async def _sleep(_delay):
+ return None
+
+ async def _run() -> None:
+ with patch("gateway.platforms.feishu.asyncio.sleep", side_effect=_sleep):
+ await adapter._dispatch_inbound_event(
+ MessageEvent(text="A", message_type=MessageType.TEXT, source=source, message_id="om_1")
+ )
+ await adapter._dispatch_inbound_event(
+ MessageEvent(text="B", message_type=MessageType.TEXT, source=source, message_id="om_2")
+ )
+ pending = list(adapter._pending_text_batch_tasks.values())
+ self.assertEqual(len(pending), 1)
+ await asyncio.gather(*pending, return_exceptions=True)
+
+ asyncio.run(_run())
+
+ adapter.handle_message.assert_awaited_once()
+ event = adapter.handle_message.await_args.args[0]
+ self.assertEqual(event.text, "A\nB")
+ self.assertEqual(event.message_type, MessageType.TEXT)
+
+ @patch.dict(
+ os.environ,
+ {
+ "HERMES_FEISHU_TEXT_BATCH_MAX_MESSAGES": "2",
+ },
+ clear=True,
+ )
+ def test_text_batch_flushes_when_message_count_limit_is_hit(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.base import MessageEvent, MessageType
+ from gateway.platforms.feishu import FeishuAdapter
+ from gateway.session import SessionSource
+
+ adapter = FeishuAdapter(PlatformConfig())
+ adapter.handle_message = AsyncMock()
+ source = SessionSource(
+ platform=adapter.platform,
+ chat_id="oc_chat",
+ chat_name="Feishu DM",
+ chat_type="dm",
+ user_id="ou_user",
+ user_name="张三",
+ )
+
+ async def _sleep(_delay):
+ return None
+
+ async def _run() -> None:
+ with patch("gateway.platforms.feishu.asyncio.sleep", side_effect=_sleep):
+ await adapter._dispatch_inbound_event(
+ MessageEvent(text="A", message_type=MessageType.TEXT, source=source, message_id="om_1")
+ )
+ await adapter._dispatch_inbound_event(
+ MessageEvent(text="B", message_type=MessageType.TEXT, source=source, message_id="om_2")
+ )
+ await adapter._dispatch_inbound_event(
+ MessageEvent(text="C", message_type=MessageType.TEXT, source=source, message_id="om_3")
+ )
+ pending = list(adapter._pending_text_batch_tasks.values())
+ self.assertEqual(len(pending), 1)
+ await asyncio.gather(*pending, return_exceptions=True)
+
+ asyncio.run(_run())
+
+ self.assertEqual(adapter.handle_message.await_count, 2)
+ first = adapter.handle_message.await_args_list[0].args[0]
+ second = adapter.handle_message.await_args_list[1].args[0]
+ self.assertEqual(first.text, "A\nB")
+ self.assertEqual(second.text, "C")
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_media_batch_merges_rapid_photo_messages(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.base import MessageEvent, MessageType
+ from gateway.platforms.feishu import FeishuAdapter
+ from gateway.session import SessionSource
+
+ adapter = FeishuAdapter(PlatformConfig())
+ adapter.handle_message = AsyncMock()
+ source = SessionSource(
+ platform=adapter.platform,
+ chat_id="oc_chat",
+ chat_name="Feishu DM",
+ chat_type="dm",
+ user_id="ou_user",
+ user_name="张三",
+ )
+
+ async def _sleep(_delay):
+ return None
+
+ async def _run() -> None:
+ with patch("gateway.platforms.feishu.asyncio.sleep", side_effect=_sleep):
+ await adapter._dispatch_inbound_event(
+ MessageEvent(
+ text="第一张",
+ message_type=MessageType.PHOTO,
+ source=source,
+ message_id="om_p1",
+ media_urls=["/tmp/a.png"],
+ media_types=["image/png"],
+ )
+ )
+ await adapter._dispatch_inbound_event(
+ MessageEvent(
+ text="第二张",
+ message_type=MessageType.PHOTO,
+ source=source,
+ message_id="om_p2",
+ media_urls=["/tmp/b.png"],
+ media_types=["image/png"],
+ )
+ )
+ pending = list(adapter._pending_media_batch_tasks.values())
+ self.assertEqual(len(pending), 1)
+ await asyncio.gather(*pending, return_exceptions=True)
+
+ asyncio.run(_run())
+
+ adapter.handle_message.assert_awaited_once()
+ event = adapter.handle_message.await_args.args[0]
+ self.assertEqual(event.media_urls, ["/tmp/a.png", "/tmp/b.png"])
+ self.assertIn("第一张", event.text)
+ self.assertIn("第二张", event.text)
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_send_image_downloads_then_uses_native_image_send(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ adapter.send_image_file = AsyncMock(return_value=SimpleNamespace(success=True, message_id="om_img"))
+
+ async def _run():
+ with patch("gateway.platforms.feishu.cache_image_from_url", new=AsyncMock(return_value="/tmp/cached.png")):
+ return await adapter.send_image("oc_chat", "https://example.com/cat.png", caption="cat")
+
+ result = asyncio.run(_run())
+
+ self.assertTrue(result.success)
+ adapter.send_image_file.assert_awaited_once()
+ self.assertEqual(adapter.send_image_file.await_args.kwargs["image_path"], "/tmp/cached.png")
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_send_animation_degrades_to_document_send(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ adapter.send_document = AsyncMock(return_value=SimpleNamespace(success=True, message_id="om_gif"))
+
+ async def _run():
+ with patch.object(
+ adapter,
+ "_download_remote_document",
+ new=AsyncMock(return_value=("/tmp/anim.gif", "anim.gif")),
+ ):
+ return await adapter.send_animation("oc_chat", "https://example.com/anim.gif", caption="look")
+
+ result = asyncio.run(_run())
+
+ self.assertTrue(result.success)
+ adapter.send_document.assert_awaited_once()
+ caption = adapter.send_document.await_args.kwargs["caption"]
+ self.assertIn("GIF downgraded to file", caption)
+ self.assertIn("look", caption)
+
+ def test_dedup_state_persists_across_adapter_restart(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ with tempfile.TemporaryDirectory() as temp_home:
+ with patch.dict(os.environ, {"HERMES_HOME": temp_home}, clear=False):
+ first = FeishuAdapter(PlatformConfig())
+ self.assertFalse(first._is_duplicate("om_same"))
+ second = FeishuAdapter(PlatformConfig())
+ self.assertTrue(second._is_duplicate("om_same"))
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_process_inbound_group_message_keeps_group_type_when_chat_lookup_falls_back(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ adapter._dispatch_inbound_event = AsyncMock()
+ adapter.get_chat_info = AsyncMock(
+ return_value={"chat_id": "oc_group", "name": "oc_group", "type": "dm"}
+ )
+ adapter._resolve_sender_profile = AsyncMock(
+ return_value={"user_id": "ou_user", "user_name": "张三", "user_id_alt": None}
+ )
+ message = SimpleNamespace(
+ chat_id="oc_group",
+ thread_id=None,
+ message_type="text",
+ content='{"text":"hello group"}',
+ message_id="om_group_text",
+ )
+ sender_id = SimpleNamespace(open_id="ou_user", user_id=None, union_id=None)
+ data = SimpleNamespace(event=SimpleNamespace(message=message))
+
+ asyncio.run(
+ adapter._process_inbound_message(
+ data=data,
+ message=message,
+ sender_id=sender_id,
+ chat_type="group",
+ message_id="om_group_text",
+ )
+ )
+
+ event = adapter._dispatch_inbound_event.await_args.args[0]
+ self.assertEqual(event.source.chat_type, "group")
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_process_inbound_message_fetches_reply_to_text(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ adapter._dispatch_inbound_event = AsyncMock()
+ adapter.get_chat_info = AsyncMock(
+ return_value={"chat_id": "oc_chat", "name": "Feishu DM", "type": "dm"}
+ )
+ adapter._resolve_sender_profile = AsyncMock(
+ return_value={"user_id": "ou_user", "user_name": "张三", "user_id_alt": None}
+ )
+ adapter._fetch_message_text = AsyncMock(return_value="父消息内容")
+ message = SimpleNamespace(
+ chat_id="oc_chat",
+ thread_id=None,
+ parent_id="om_parent",
+ upper_message_id=None,
+ message_type="text",
+ content='{"text":"reply"}',
+ message_id="om_reply",
+ )
+
+ asyncio.run(
+ adapter._process_inbound_message(
+ data=SimpleNamespace(event=SimpleNamespace(message=message)),
+ message=message,
+ sender_id=SimpleNamespace(open_id="ou_user", user_id=None, union_id=None),
+ chat_type="p2p",
+ message_id="om_reply",
+ )
+ )
+
+ event = adapter._dispatch_inbound_event.await_args.args[0]
+ self.assertEqual(event.reply_to_message_id, "om_parent")
+ self.assertEqual(event.reply_to_text, "父消息内容")
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_send_replies_in_thread_when_thread_metadata_present(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ captured = {}
+
+ class _ReplyAPI:
+ def reply(self, request):
+ captured["request"] = request
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(message_id="om_reply"),
+ )
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(
+ v1=SimpleNamespace(
+ message=_ReplyAPI(),
+ )
+ )
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
+ result = asyncio.run(
+ adapter.send(
+ chat_id="oc_chat",
+ content="hello",
+ reply_to="om_parent",
+ metadata={"thread_id": "omt-thread"},
+ )
+ )
+
+ self.assertTrue(result.success)
+ self.assertEqual(result.message_id, "om_reply")
+ self.assertTrue(captured["request"].request_body.reply_in_thread)
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_send_retries_transient_failure(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ captured = {"attempts": 0}
+ sleeps = []
+
+ class _MessageAPI:
+ def create(self, request):
+ captured["attempts"] += 1
+ captured["request"] = request
+ if captured["attempts"] == 1:
+ raise OSError("temporary send failure")
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(message_id="om_retry"),
+ )
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(
+ v1=SimpleNamespace(
+ message=_MessageAPI(),
+ )
+ )
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ async def _sleep(delay):
+ sleeps.append(delay)
+
+ with (
+ patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct),
+ patch("gateway.platforms.feishu.asyncio.sleep", side_effect=_sleep),
+ ):
+ result = asyncio.run(adapter.send(chat_id="oc_chat", content="hello retry"))
+
+ self.assertTrue(result.success)
+ self.assertEqual(result.message_id, "om_retry")
+ self.assertEqual(captured["attempts"], 2)
+ self.assertEqual(sleeps, [1])
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_send_does_not_retry_deterministic_api_failure(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ captured = {"attempts": 0}
+ sleeps = []
+
+ class _MessageAPI:
+ def create(self, request):
+ captured["attempts"] += 1
+ return SimpleNamespace(
+ success=lambda: False,
+ code=400,
+ msg="bad request",
+ )
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(
+ v1=SimpleNamespace(
+ message=_MessageAPI(),
+ )
+ )
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ async def _sleep(delay):
+ sleeps.append(delay)
+
+ with (
+ patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct),
+ patch("gateway.platforms.feishu.asyncio.sleep", side_effect=_sleep),
+ ):
+ result = asyncio.run(adapter.send(chat_id="oc_chat", content="bad payload"))
+
+ self.assertFalse(result.success)
+ self.assertEqual(result.error, "[400] bad request")
+ self.assertEqual(captured["attempts"], 1)
+ self.assertEqual(sleeps, [])
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_send_document_reply_uses_thread_flag(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ captured = {}
+
+ class _FileAPI:
+ def create(self, request):
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(file_key="file_123"),
+ )
+
+ class _MessageAPI:
+ def reply(self, request):
+ captured["request"] = request
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(message_id="om_file_reply"),
+ )
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(
+ v1=SimpleNamespace(
+ file=_FileAPI(),
+ message=_MessageAPI(),
+ )
+ )
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ with tempfile.NamedTemporaryFile("wb", suffix=".pdf", delete=False) as tmp:
+ tmp.write(b"%PDF-1.4 test")
+ file_path = tmp.name
+
+ try:
+ with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
+ result = asyncio.run(
+ adapter.send_document(
+ chat_id="oc_chat",
+ file_path=file_path,
+ reply_to="om_parent",
+ metadata={"thread_id": "omt-thread"},
+ )
+ )
+ finally:
+ os.unlink(file_path)
+
+ self.assertTrue(result.success)
+ self.assertTrue(captured["request"].request_body.reply_in_thread)
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_send_document_uploads_file_and_sends_file_message(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ captured = {}
+
+ class _FileAPI:
+ def create(self, request):
+ captured["upload_request"] = request
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(file_key="file_123"),
+ )
+
+ class _MessageAPI:
+ def create(self, request):
+ captured["message_request"] = request
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(message_id="om_file_msg"),
+ )
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(
+ v1=SimpleNamespace(
+ file=_FileAPI(),
+ message=_MessageAPI(),
+ )
+ )
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ with tempfile.NamedTemporaryFile("wb", suffix=".pdf", delete=False) as tmp:
+ tmp.write(b"%PDF-1.4 test")
+ file_path = tmp.name
+
+ try:
+ with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
+ result = asyncio.run(adapter.send_document(chat_id="oc_chat", file_path=file_path))
+ finally:
+ os.unlink(file_path)
+
+ self.assertTrue(result.success)
+ self.assertEqual(result.message_id, "om_file_msg")
+ self.assertEqual(captured["upload_request"].request_body.file_type, "pdf")
+ self.assertEqual(
+ captured["message_request"].request_body.content,
+ '{"file_key": "file_123"}',
+ )
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_send_document_with_caption_uses_single_post_message(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ captured = {}
+
+ class _FileAPI:
+ def create(self, request):
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(file_key="file_123"),
+ )
+
+ class _MessageAPI:
+ def create(self, request):
+ captured["message_request"] = request
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(message_id="om_post_msg"),
+ )
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(
+ v1=SimpleNamespace(
+ file=_FileAPI(),
+ message=_MessageAPI(),
+ )
+ )
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ with tempfile.NamedTemporaryFile("wb", suffix=".pdf", delete=False) as tmp:
+ tmp.write(b"%PDF-1.4 test")
+ file_path = tmp.name
+
+ try:
+ with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
+ result = asyncio.run(
+ adapter.send_document(chat_id="oc_chat", file_path=file_path, caption="报告请看")
+ )
+ finally:
+ os.unlink(file_path)
+
+ self.assertTrue(result.success)
+ self.assertEqual(captured["message_request"].request_body.msg_type, "post")
+ self.assertIn('"tag": "media"', captured["message_request"].request_body.content)
+ self.assertIn('"file_key": "file_123"', captured["message_request"].request_body.content)
+ self.assertIn("报告请看", captured["message_request"].request_body.content)
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_send_image_file_uploads_image_and_sends_image_message(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ captured = {}
+
+ class _ImageAPI:
+ def create(self, request):
+ captured["upload_request"] = request
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(image_key="img_123"),
+ )
+
+ class _MessageAPI:
+ def create(self, request):
+ captured["message_request"] = request
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(message_id="om_image_msg"),
+ )
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(
+ v1=SimpleNamespace(
+ image=_ImageAPI(),
+ message=_MessageAPI(),
+ )
+ )
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ with tempfile.NamedTemporaryFile("wb", suffix=".png", delete=False) as tmp:
+ tmp.write(b"\x89PNG\r\n\x1a\n")
+ image_path = tmp.name
+
+ try:
+ with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
+ result = asyncio.run(adapter.send_image_file(chat_id="oc_chat", image_path=image_path))
+ finally:
+ os.unlink(image_path)
+
+ self.assertTrue(result.success)
+ self.assertEqual(result.message_id, "om_image_msg")
+ self.assertEqual(captured["upload_request"].request_body.image_type, "message")
+ self.assertEqual(
+ captured["message_request"].request_body.content,
+ '{"image_key": "img_123"}',
+ )
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_send_image_file_with_caption_uses_single_post_message(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ captured = {}
+
+ class _ImageAPI:
+ def create(self, request):
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(image_key="img_123"),
+ )
+
+ class _MessageAPI:
+ def create(self, request):
+ captured["message_request"] = request
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(message_id="om_post_img"),
+ )
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(
+ v1=SimpleNamespace(
+ image=_ImageAPI(),
+ message=_MessageAPI(),
+ )
+ )
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ with tempfile.NamedTemporaryFile("wb", suffix=".png", delete=False) as tmp:
+ tmp.write(b"\x89PNG\r\n\x1a\n")
+ image_path = tmp.name
+
+ try:
+ with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
+ result = asyncio.run(
+ adapter.send_image_file(chat_id="oc_chat", image_path=image_path, caption="截图说明")
+ )
+ finally:
+ os.unlink(image_path)
+
+ self.assertTrue(result.success)
+ self.assertEqual(captured["message_request"].request_body.msg_type, "post")
+ self.assertIn('"tag": "img"', captured["message_request"].request_body.content)
+ self.assertIn('"image_key": "img_123"', captured["message_request"].request_body.content)
+ self.assertIn("截图说明", captured["message_request"].request_body.content)
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_send_video_uploads_file_and_sends_media_message(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ captured = {}
+
+ class _FileAPI:
+ def create(self, request):
+ captured["upload_request"] = request
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(file_key="file_video_123"),
+ )
+
+ class _MessageAPI:
+ def create(self, request):
+ captured["message_request"] = request
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(message_id="om_video_msg"),
+ )
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(
+ v1=SimpleNamespace(
+ file=_FileAPI(),
+ message=_MessageAPI(),
+ )
+ )
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ with tempfile.NamedTemporaryFile("wb", suffix=".mp4", delete=False) as tmp:
+ tmp.write(b"\x00\x00\x00\x18ftypmp42")
+ video_path = tmp.name
+
+ try:
+ with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
+ result = asyncio.run(adapter.send_video(chat_id="oc_chat", video_path=video_path))
+ finally:
+ os.unlink(video_path)
+
+ self.assertTrue(result.success)
+ self.assertEqual(captured["upload_request"].request_body.file_type, "mp4")
+ self.assertEqual(captured["message_request"].request_body.msg_type, "media")
+ self.assertEqual(captured["message_request"].request_body.content, '{"file_key": "file_video_123"}')
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_send_voice_uploads_opus_and_sends_audio_message(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ captured = {}
+
+ class _FileAPI:
+ def create(self, request):
+ captured["upload_request"] = request
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(file_key="file_audio_123"),
+ )
+
+ class _MessageAPI:
+ def create(self, request):
+ captured["message_request"] = request
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(message_id="om_audio_msg"),
+ )
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(
+ v1=SimpleNamespace(
+ file=_FileAPI(),
+ message=_MessageAPI(),
+ )
+ )
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ with tempfile.NamedTemporaryFile("wb", suffix=".opus", delete=False) as tmp:
+ tmp.write(b"opus")
+ audio_path = tmp.name
+
+ try:
+ with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
+ result = asyncio.run(adapter.send_voice(chat_id="oc_chat", audio_path=audio_path))
+ finally:
+ os.unlink(audio_path)
+
+ self.assertTrue(result.success)
+ self.assertEqual(captured["upload_request"].request_body.file_type, "opus")
+ self.assertEqual(captured["message_request"].request_body.msg_type, "audio")
+ self.assertEqual(captured["message_request"].request_body.content, '{"file_key": "file_audio_123"}')
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_build_post_payload_extracts_title_and_links(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ payload = json.loads(adapter._build_post_payload("# 标题\n访问 [文档](https://example.com)"))
+
+ elements = payload["zh_cn"]["content"][0]
+ self.assertEqual(elements, [{"tag": "md", "text": "# 标题\n访问 [文档](https://example.com)"}])
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_build_post_payload_wraps_markdown_in_md_tag(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ payload = json.loads(
+ adapter._build_post_payload("支持 **粗体**、*斜体* 和 `代码`")
+ )
+
+ elements = payload["zh_cn"]["content"][0]
+ self.assertEqual(
+ elements,
+ [
+ {"tag": "md", "text": "支持 **粗体**、*斜体* 和 `代码`"},
+ ],
+ )
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_build_post_payload_keeps_full_markdown_text(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ payload = json.loads(
+ adapter._build_post_payload(
+ "---\n1. 第一项\n 2. 子项\n- 外层\n - 内层\n下划线 和 ~~删除线~~"
+ )
+ )
+
+ rows = payload["zh_cn"]["content"]
+ self.assertEqual(
+ rows,
+ [[{"tag": "md", "text": "---\n1. 第一项\n 2. 子项\n- 外层\n - 内层\n下划线 和 ~~删除线~~"}]],
+ )
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_send_uses_post_for_inline_markdown(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ captured = {}
+
+ class _MessageAPI:
+ def create(self, request):
+ captured["request"] = request
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(message_id="om_markdown"),
+ )
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(
+ v1=SimpleNamespace(
+ message=_MessageAPI(),
+ )
+ )
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
+ result = asyncio.run(
+ adapter.send(
+ chat_id="oc_chat",
+ content="可以用 **粗体** 和 *斜体*。",
+ )
+ )
+
+ self.assertTrue(result.success)
+ self.assertEqual(captured["request"].request_body.msg_type, "post")
+ payload = json.loads(captured["request"].request_body.content)
+ elements = payload["zh_cn"]["content"][0]
+ self.assertEqual(elements, [{"tag": "md", "text": "可以用 **粗体** 和 *斜体*。"}])
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_send_falls_back_to_text_when_post_payload_is_rejected(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ captured = {"calls": []}
+
+ class _MessageAPI:
+ def create(self, request):
+ captured["calls"].append(request)
+ if len(captured["calls"]) == 1:
+ raise RuntimeError("content format of the post type is incorrect")
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(message_id="om_plain"),
+ )
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(
+ v1=SimpleNamespace(
+ message=_MessageAPI(),
+ )
+ )
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
+ result = asyncio.run(
+ adapter.send(
+ chat_id="oc_chat",
+ content="可以用 **粗体** 和 *斜体*。",
+ )
+ )
+
+ self.assertTrue(result.success)
+ self.assertEqual(captured["calls"][0].request_body.msg_type, "post")
+ self.assertEqual(captured["calls"][1].request_body.msg_type, "text")
+ self.assertEqual(
+ captured["calls"][1].request_body.content,
+ json.dumps({"text": "可以用 粗体 和 斜体。"}, ensure_ascii=False),
+ )
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_send_falls_back_to_text_when_post_response_is_unsuccessful(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ captured = {"calls": []}
+
+ class _MessageAPI:
+ def create(self, request):
+ captured["calls"].append(request)
+ if len(captured["calls"]) == 1:
+ return SimpleNamespace(success=lambda: False, code=230001, msg="content format of the post type is incorrect")
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(message_id="om_plain_response"),
+ )
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(
+ v1=SimpleNamespace(
+ message=_MessageAPI(),
+ )
+ )
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
+ result = asyncio.run(
+ adapter.send(
+ chat_id="oc_chat",
+ content="可以用 **粗体** 和 *斜体*。",
+ )
+ )
+
+ self.assertTrue(result.success)
+ self.assertEqual(captured["calls"][0].request_body.msg_type, "post")
+ self.assertEqual(captured["calls"][1].request_body.msg_type, "text")
+ self.assertEqual(
+ captured["calls"][1].request_body.content,
+ json.dumps({"text": "可以用 粗体 和 斜体。"}, ensure_ascii=False),
+ )
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_send_uses_post_for_advanced_markdown_lines(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ captured = {}
+
+ class _MessageAPI:
+ def create(self, request):
+ captured["request"] = request
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(message_id="om_markdown_advanced"),
+ )
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(
+ v1=SimpleNamespace(
+ message=_MessageAPI(),
+ )
+ )
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
+ result = asyncio.run(
+ adapter.send(
+ chat_id="oc_chat",
+ content="---\n1. 第一项\n下划线\n~~删除线~~",
+ )
+ )
+
+ self.assertTrue(result.success)
+ self.assertEqual(captured["request"].request_body.msg_type, "post")
+ payload = json.loads(captured["request"].request_body.content)
+ rows = payload["zh_cn"]["content"]
+ self.assertEqual(
+ rows,
+ [[{"tag": "md", "text": "---\n1. 第一项\n下划线\n~~删除线~~"}]],
+ )
+
+
+@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
+class TestWebhookSecurity(unittest.TestCase):
+ """Tests for webhook signature verification, rate limiting, and body size limits."""
+
+ def _make_adapter(self, encrypt_key: str = "") -> "FeishuAdapter":
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ with patch.dict(os.environ, {"FEISHU_APP_ID": "cli", "FEISHU_APP_SECRET": "sec", "FEISHU_ENCRYPT_KEY": encrypt_key}, clear=True):
+ return FeishuAdapter(PlatformConfig())
+
+ def test_signature_valid_passes(self):
+ import hashlib
+ from gateway.platforms.feishu import FeishuAdapter
+ from gateway.config import PlatformConfig
+
+ encrypt_key = "test_secret"
+ adapter = self._make_adapter(encrypt_key)
+ body = b'{"type":"event"}'
+ timestamp = "1700000000"
+ nonce = "abc123"
+ content = f"{timestamp}{nonce}{encrypt_key}" + body.decode("utf-8")
+ sig = hashlib.sha256(content.encode("utf-8")).hexdigest()
+ headers = {"x-lark-request-timestamp": timestamp, "x-lark-request-nonce": nonce, "x-lark-signature": sig}
+ self.assertTrue(adapter._is_webhook_signature_valid(headers, body))
+
+ def test_signature_invalid_rejected(self):
+ adapter = self._make_adapter("test_secret")
+ headers = {
+ "x-lark-request-timestamp": "1700000000",
+ "x-lark-request-nonce": "abc",
+ "x-lark-signature": "deadbeef" * 8,
+ }
+ self.assertFalse(adapter._is_webhook_signature_valid(headers, b'{"type":"event"}'))
+
+ def test_signature_missing_headers_rejected(self):
+ adapter = self._make_adapter("test_secret")
+ self.assertFalse(adapter._is_webhook_signature_valid({}, b'{}'))
+
+ def test_rate_limit_allows_requests_within_window(self):
+ adapter = self._make_adapter()
+ for _ in range(5):
+ self.assertTrue(adapter._check_webhook_rate_limit("10.0.0.1"))
+
+ def test_rate_limit_blocks_after_exceeding_max(self):
+ from gateway.platforms.feishu import _FEISHU_WEBHOOK_RATE_LIMIT_MAX
+ adapter = self._make_adapter()
+ for _ in range(_FEISHU_WEBHOOK_RATE_LIMIT_MAX):
+ adapter._check_webhook_rate_limit("10.0.0.2")
+ self.assertFalse(adapter._check_webhook_rate_limit("10.0.0.2"))
+
+ def test_rate_limit_resets_after_window_expires(self):
+ from gateway.platforms.feishu import _FEISHU_WEBHOOK_RATE_LIMIT_MAX, _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS
+ adapter = self._make_adapter()
+ ip = "10.0.0.3"
+ for _ in range(_FEISHU_WEBHOOK_RATE_LIMIT_MAX):
+ adapter._check_webhook_rate_limit(ip)
+ self.assertFalse(adapter._check_webhook_rate_limit(ip))
+ # Simulate window expiry by backdating the stored entry.
+ count, window_start = adapter._webhook_rate_counts[ip]
+ adapter._webhook_rate_counts[ip] = (count, window_start - _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS - 1)
+ self.assertTrue(adapter._check_webhook_rate_limit(ip))
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_webhook_request_rejects_oversized_body(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter, _FEISHU_WEBHOOK_MAX_BODY_BYTES
+
+ adapter = FeishuAdapter(PlatformConfig())
+ # Simulate a request whose Content-Length already signals oversize.
+ request = SimpleNamespace(
+ remote="127.0.0.1",
+ content_length=_FEISHU_WEBHOOK_MAX_BODY_BYTES + 1,
+ )
+ response = asyncio.run(adapter._handle_webhook_request(request))
+ self.assertEqual(response.status, 413)
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_webhook_request_rejects_invalid_json(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ request = SimpleNamespace(
+ remote="127.0.0.1",
+ content_length=None,
+ read=AsyncMock(return_value=b"not-json"),
+ )
+ response = asyncio.run(adapter._handle_webhook_request(request))
+ self.assertEqual(response.status, 400)
+
+ @patch.dict(os.environ, {"FEISHU_ENCRYPT_KEY": "secret"}, clear=True)
+ def test_webhook_request_rejects_bad_signature(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ body = json.dumps({"header": {"event_type": "im.message.receive_v1"}}).encode()
+ request = SimpleNamespace(
+ remote="127.0.0.1",
+ content_length=None,
+ headers={"x-lark-request-timestamp": "123", "x-lark-request-nonce": "abc", "x-lark-signature": "bad"},
+ read=AsyncMock(return_value=body),
+ )
+ response = asyncio.run(adapter._handle_webhook_request(request))
+ self.assertEqual(response.status, 401)
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_webhook_url_verification_challenge_passes_without_signature(self):
+ """Challenge requests must succeed even when no encrypt_key is set."""
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ body = json.dumps({"type": "url_verification", "challenge": "test_challenge_token"}).encode()
+ request = SimpleNamespace(
+ remote="127.0.0.1",
+ content_length=None,
+ read=AsyncMock(return_value=body),
+ )
+ response = asyncio.run(adapter._handle_webhook_request(request))
+ self.assertEqual(response.status, 200)
+ self.assertIn(b"test_challenge_token", response.body)
+
+
+class TestDedupTTL(unittest.TestCase):
+ """Tests for TTL-aware deduplication."""
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_duplicate_within_ttl_is_rejected(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ with patch.object(adapter, "_persist_seen_message_ids"):
+ adapter._seen_message_ids = {"om_dup": time.time()}
+ adapter._seen_message_order = ["om_dup"]
+ self.assertTrue(adapter._is_duplicate("om_dup"))
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_expired_entry_is_not_considered_duplicate(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter, _FEISHU_DEDUP_TTL_SECONDS
+
+ adapter = FeishuAdapter(PlatformConfig())
+ # Plant an entry that expired well past the TTL.
+ stale_ts = time.time() - _FEISHU_DEDUP_TTL_SECONDS - 60
+ adapter._seen_message_ids = {"om_old": stale_ts}
+ adapter._seen_message_order = ["om_old"]
+ with patch.object(adapter, "_persist_seen_message_ids"):
+ self.assertFalse(adapter._is_duplicate("om_old"))
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_persist_saves_timestamps_as_dict(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ ts = time.time()
+ adapter._seen_message_ids = {"om_ts1": ts}
+ adapter._seen_message_order = ["om_ts1"]
+ with tempfile.TemporaryDirectory() as tmpdir:
+ adapter._dedup_state_path = Path(tmpdir) / "dedup.json"
+ adapter._persist_seen_message_ids()
+ saved = json.loads(adapter._dedup_state_path.read_text())
+ self.assertIsInstance(saved["message_ids"], dict)
+ self.assertAlmostEqual(saved["message_ids"]["om_ts1"], ts, places=1)
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_load_backward_compat_list_format(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = Path(tmpdir) / "dedup.json"
+ path.write_text(json.dumps({"message_ids": ["om_a", "om_b"]}), encoding="utf-8")
+ adapter._dedup_state_path = path
+ adapter._load_seen_message_ids()
+ self.assertIn("om_a", adapter._seen_message_ids)
+ self.assertIn("om_b", adapter._seen_message_ids)
+
+
+class TestGroupMentionAtAll(unittest.TestCase):
+ """Tests for @_all (Feishu @everyone) group mention routing."""
+
+ @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True)
+ def test_at_all_in_content_accepts_without_explicit_bot_mention(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ message = SimpleNamespace(
+ content='{"text":"@_all 请注意"}',
+ mentions=[],
+ )
+ sender_id = SimpleNamespace(open_id="ou_any", user_id=None)
+ self.assertTrue(adapter._should_accept_group_message(message, sender_id))
+
+ @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "allowlist", "FEISHU_ALLOWED_USERS": "ou_allowed"}, clear=True)
+ def test_at_all_still_requires_policy_gate(self):
+ """@_all bypasses mention gating but NOT the allowlist policy."""
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ message = SimpleNamespace(content='{"text":"@_all attention"}', mentions=[])
+ # Non-allowlisted user — should be blocked even with @_all.
+ blocked_sender = SimpleNamespace(open_id="ou_blocked", user_id=None)
+ self.assertFalse(adapter._should_accept_group_message(message, blocked_sender))
+ # Allowlisted user — should pass.
+ allowed_sender = SimpleNamespace(open_id="ou_allowed", user_id=None)
+ self.assertTrue(adapter._should_accept_group_message(message, allowed_sender))
+
+
+@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
+class TestSenderNameResolution(unittest.TestCase):
+ """Tests for _resolve_sender_name_from_api."""
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_returns_none_when_client_is_none(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ adapter._client = None
+ result = asyncio.run(adapter._resolve_sender_name_from_api("ou_abc"))
+ self.assertIsNone(result)
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_returns_cached_name_within_ttl(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ adapter._client = SimpleNamespace()
+ future_expire = time.time() + 600
+ adapter._sender_name_cache["ou_cached"] = ("Alice", future_expire)
+ result = asyncio.run(adapter._resolve_sender_name_from_api("ou_cached"))
+ self.assertEqual(result, "Alice")
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_fetches_and_caches_name_from_api(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ user_obj = SimpleNamespace(name="Bob", display_name=None, nickname=None, en_name=None)
+ mock_response = SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(user=user_obj),
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ class _ContactAPI:
+ def get(self, request):
+ return mock_response
+
+ adapter._client = SimpleNamespace(
+ contact=SimpleNamespace(v3=SimpleNamespace(user=_ContactAPI()))
+ )
+
+ with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
+ result = asyncio.run(adapter._resolve_sender_name_from_api("ou_bob"))
+
+ self.assertEqual(result, "Bob")
+ self.assertIn("ou_bob", adapter._sender_name_cache)
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_expired_cache_triggers_new_api_call(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ # Expired cache entry.
+ adapter._sender_name_cache["ou_expired"] = ("OldName", time.time() - 1)
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ user_obj = SimpleNamespace(name="NewName", display_name=None, nickname=None, en_name=None)
+
+ class _ContactAPI:
+ def get(self, request):
+ return SimpleNamespace(success=lambda: True, data=SimpleNamespace(user=user_obj))
+
+ adapter._client = SimpleNamespace(
+ contact=SimpleNamespace(v3=SimpleNamespace(user=_ContactAPI()))
+ )
+
+ with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
+ result = asyncio.run(adapter._resolve_sender_name_from_api("ou_expired"))
+
+ self.assertEqual(result, "NewName")
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_api_failure_returns_none_without_raising(self):
+ from gateway.config import PlatformConfig
+ from gateway.platforms.feishu import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+
+ class _BrokenContactAPI:
+ def get(self, _request):
+ raise RuntimeError("API down")
+
+ adapter._client = SimpleNamespace(
+ contact=SimpleNamespace(v3=SimpleNamespace(user=_BrokenContactAPI()))
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
+ result = asyncio.run(adapter._resolve_sender_name_from_api("ou_broken"))
+
+ self.assertIsNone(result)
diff --git a/tests/gateway/test_hooks.py b/tests/gateway/test_hooks.py
index 039ce6b2ef7c..1301aebae895 100644
--- a/tests/gateway/test_hooks.py
+++ b/tests/gateway/test_hooks.py
@@ -29,13 +29,18 @@ class TestHookRegistryInit:
assert reg._handlers == {}
+def _patch_no_builtins(reg):
+ """Suppress built-in hook registration so tests only exercise user-hook discovery."""
+ return patch.object(reg, "_register_builtin_hooks")
+
+
class TestDiscoverAndLoad:
def test_loads_valid_hook(self, tmp_path):
_create_hook(tmp_path, "my-hook", '["agent:start"]',
"def handle(event_type, context):\n pass\n")
reg = HookRegistry()
- with patch("gateway.hooks.HOOKS_DIR", tmp_path):
+ with patch("gateway.hooks.HOOKS_DIR", tmp_path), _patch_no_builtins(reg):
reg.discover_and_load()
assert len(reg.loaded_hooks) == 1
@@ -48,7 +53,7 @@ class TestDiscoverAndLoad:
(hook_dir / "handler.py").write_text("def handle(e, c): pass\n")
reg = HookRegistry()
- with patch("gateway.hooks.HOOKS_DIR", tmp_path):
+ with patch("gateway.hooks.HOOKS_DIR", tmp_path), _patch_no_builtins(reg):
reg.discover_and_load()
assert len(reg.loaded_hooks) == 0
@@ -59,7 +64,7 @@ class TestDiscoverAndLoad:
(hook_dir / "HOOK.yaml").write_text("name: bad\nevents: ['agent:start']\n")
reg = HookRegistry()
- with patch("gateway.hooks.HOOKS_DIR", tmp_path):
+ with patch("gateway.hooks.HOOKS_DIR", tmp_path), _patch_no_builtins(reg):
reg.discover_and_load()
assert len(reg.loaded_hooks) == 0
@@ -71,7 +76,7 @@ class TestDiscoverAndLoad:
(hook_dir / "handler.py").write_text("def handle(e, c): pass\n")
reg = HookRegistry()
- with patch("gateway.hooks.HOOKS_DIR", tmp_path):
+ with patch("gateway.hooks.HOOKS_DIR", tmp_path), _patch_no_builtins(reg):
reg.discover_and_load()
assert len(reg.loaded_hooks) == 0
@@ -83,14 +88,14 @@ class TestDiscoverAndLoad:
(hook_dir / "handler.py").write_text("def something_else(): pass\n")
reg = HookRegistry()
- with patch("gateway.hooks.HOOKS_DIR", tmp_path):
+ with patch("gateway.hooks.HOOKS_DIR", tmp_path), _patch_no_builtins(reg):
reg.discover_and_load()
assert len(reg.loaded_hooks) == 0
def test_nonexistent_hooks_dir(self, tmp_path):
reg = HookRegistry()
- with patch("gateway.hooks.HOOKS_DIR", tmp_path / "nonexistent"):
+ with patch("gateway.hooks.HOOKS_DIR", tmp_path / "nonexistent"), _patch_no_builtins(reg):
reg.discover_and_load()
assert len(reg.loaded_hooks) == 0
@@ -102,7 +107,7 @@ class TestDiscoverAndLoad:
"def handle(e, c): pass\n")
reg = HookRegistry()
- with patch("gateway.hooks.HOOKS_DIR", tmp_path):
+ with patch("gateway.hooks.HOOKS_DIR", tmp_path), _patch_no_builtins(reg):
reg.discover_and_load()
assert len(reg.loaded_hooks) == 2
diff --git a/tests/gateway/test_matrix_voice.py b/tests/gateway/test_matrix_voice.py
new file mode 100644
index 000000000000..79f0947f6197
--- /dev/null
+++ b/tests/gateway/test_matrix_voice.py
@@ -0,0 +1,340 @@
+"""Tests for Matrix voice message support (MSC3245)."""
+import io
+
+import pytest
+from unittest.mock import AsyncMock, MagicMock
+
+nio = pytest.importorskip("nio", reason="matrix-nio not installed")
+
+from gateway.platforms.base import MessageType
+
+
+# ---------------------------------------------------------------------------
+# Adapter helpers
+# ---------------------------------------------------------------------------
+
+def _make_adapter():
+ """Create a MatrixAdapter with mocked config."""
+ from gateway.platforms.matrix import MatrixAdapter
+ from gateway.config import PlatformConfig
+
+ config = PlatformConfig(
+ enabled=True,
+ token="***",
+ extra={
+ "homeserver": "https://matrix.example.org",
+ "user_id": "@bot:example.org",
+ },
+ )
+ adapter = MatrixAdapter(config)
+ return adapter
+
+
+def _make_room(room_id: str = "!test:example.org", member_count: int = 2):
+ """Create a mock Matrix room."""
+ room = MagicMock()
+ room.room_id = room_id
+ room.member_count = member_count
+ return room
+
+
+def _make_audio_event(
+ event_id: str = "$audio_event",
+ sender: str = "@alice:example.org",
+ body: str = "Voice message",
+ url: str = "mxc://example.org/abc123",
+ is_voice: bool = False,
+ mimetype: str = "audio/ogg",
+ timestamp: float = 9999999999000, # ms
+):
+ """
+ Create a mock RoomMessageAudio event that passes isinstance checks.
+
+ Args:
+ is_voice: If True, adds org.matrix.msc3245.voice field to content
+ """
+ import nio
+
+ # Build the source dict that nio events expose via .source
+ content = {
+ "msgtype": "m.audio",
+ "body": body,
+ "url": url,
+ "info": {
+ "mimetype": mimetype,
+ },
+ }
+
+ if is_voice:
+ content["org.matrix.msc3245.voice"] = {}
+
+ # Create a real nio RoomMessageAudio-like object
+ # We use MagicMock but configure __class__ to pass isinstance check
+ event = MagicMock(spec=nio.RoomMessageAudio)
+ event.event_id = event_id
+ event.sender = sender
+ event.body = body
+ event.url = url
+ event.server_timestamp = timestamp
+ event.source = {
+ "type": "m.room.message",
+ "content": content,
+ }
+ # For MIME type extraction - needs to be a dict
+ event.content = content
+
+ return event
+
+
+def _make_download_response(body: bytes = b"fake audio data"):
+ """Create a mock nio.MemoryDownloadResponse."""
+ import nio
+ resp = MagicMock()
+ resp.body = body
+ resp.__class__ = nio.MemoryDownloadResponse
+ return resp
+
+
+# ---------------------------------------------------------------------------
+# Tests: MSC3245 Voice Detection (RED -> GREEN)
+# ---------------------------------------------------------------------------
+
+class TestMatrixVoiceMessageDetection:
+ """Test that MSC3245 voice messages are detected and tagged correctly."""
+
+ def setup_method(self):
+ self.adapter = _make_adapter()
+ self.adapter._user_id = "@bot:example.org"
+ self.adapter._startup_ts = 0.0
+ self.adapter._dm_rooms = {}
+ self.adapter._message_handler = AsyncMock()
+ # Mock _mxc_to_http to return a fake HTTP URL
+ self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}"
+ # Mock client for authenticated download
+ self.adapter._client = MagicMock()
+ self.adapter._client.download = AsyncMock(return_value=_make_download_response())
+
+ @pytest.mark.asyncio
+ async def test_voice_message_has_type_voice(self):
+ """Voice messages (with MSC3245 field) should be MessageType.VOICE."""
+ room = _make_room()
+ event = _make_audio_event(is_voice=True)
+
+ # Capture the MessageEvent passed to handle_message
+ captured_event = None
+
+ async def capture(msg_event):
+ nonlocal captured_event
+ captured_event = msg_event
+
+ self.adapter.handle_message = capture
+
+ await self.adapter._on_room_message_media(room, event)
+
+ assert captured_event is not None, "No event was captured"
+ assert captured_event.message_type == MessageType.VOICE, \
+ f"Expected MessageType.VOICE, got {captured_event.message_type}"
+
+ @pytest.mark.asyncio
+ async def test_voice_message_has_local_path(self):
+ """Voice messages should have a local cached path in media_urls."""
+ room = _make_room()
+ event = _make_audio_event(is_voice=True)
+
+ captured_event = None
+
+ async def capture(msg_event):
+ nonlocal captured_event
+ captured_event = msg_event
+
+ self.adapter.handle_message = capture
+
+ await self.adapter._on_room_message_media(room, event)
+
+ assert captured_event is not None
+ assert captured_event.media_urls is not None
+ assert len(captured_event.media_urls) > 0
+ # Should be a local path, not an HTTP URL
+ assert not captured_event.media_urls[0].startswith("http"), \
+ f"media_urls should contain local path, got {captured_event.media_urls[0]}"
+ self.adapter._client.download.assert_awaited_once_with(mxc=event.url)
+ assert captured_event.media_types == ["audio/ogg"]
+
+ @pytest.mark.asyncio
+ async def test_audio_without_msc3245_stays_audio_type(self):
+ """Regular audio uploads (no MSC3245 field) should remain MessageType.AUDIO."""
+ room = _make_room()
+ event = _make_audio_event(is_voice=False) # NOT a voice message
+
+ captured_event = None
+
+ async def capture(msg_event):
+ nonlocal captured_event
+ captured_event = msg_event
+
+ self.adapter.handle_message = capture
+
+ await self.adapter._on_room_message_media(room, event)
+
+ assert captured_event is not None
+ assert captured_event.message_type == MessageType.AUDIO, \
+ f"Expected MessageType.AUDIO for non-voice, got {captured_event.message_type}"
+
+ @pytest.mark.asyncio
+ async def test_regular_audio_has_http_url(self):
+ """Regular audio uploads should keep HTTP URL (not cached locally)."""
+ room = _make_room()
+ event = _make_audio_event(is_voice=False)
+
+ captured_event = None
+
+ async def capture(msg_event):
+ nonlocal captured_event
+ captured_event = msg_event
+
+ self.adapter.handle_message = capture
+
+ await self.adapter._on_room_message_media(room, event)
+
+ assert captured_event is not None
+ assert captured_event.media_urls is not None
+ # Should be HTTP URL, not local path
+ assert captured_event.media_urls[0].startswith("http"), \
+ f"Non-voice audio should have HTTP URL, got {captured_event.media_urls[0]}"
+ self.adapter._client.download.assert_not_awaited()
+ assert captured_event.media_types == ["audio/ogg"]
+
+
+class TestMatrixVoiceCacheFallback:
+ """Test graceful fallback when voice caching fails."""
+
+ def setup_method(self):
+ self.adapter = _make_adapter()
+ self.adapter._user_id = "@bot:example.org"
+ self.adapter._startup_ts = 0.0
+ self.adapter._dm_rooms = {}
+ self.adapter._message_handler = AsyncMock()
+ self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}"
+ self.adapter._client = MagicMock()
+
+ @pytest.mark.asyncio
+ async def test_voice_cache_failure_falls_back_to_http_url(self):
+ """If caching fails, voice message should still be delivered with HTTP URL."""
+ room = _make_room()
+ event = _make_audio_event(is_voice=True)
+
+ # Make download fail
+ import nio
+ error_resp = MagicMock()
+ error_resp.__class__ = nio.DownloadError
+ self.adapter._client.download = AsyncMock(return_value=error_resp)
+
+ captured_event = None
+
+ async def capture(msg_event):
+ nonlocal captured_event
+ captured_event = msg_event
+
+ self.adapter.handle_message = capture
+
+ await self.adapter._on_room_message_media(room, event)
+
+ assert captured_event is not None
+ assert captured_event.media_urls is not None
+ # Should fall back to HTTP URL
+ assert captured_event.media_urls[0].startswith("http"), \
+ f"Should fall back to HTTP URL on cache failure, got {captured_event.media_urls[0]}"
+
+ @pytest.mark.asyncio
+ async def test_voice_cache_exception_falls_back_to_http_url(self):
+ """Unexpected download exceptions should also fall back to HTTP URL."""
+ room = _make_room()
+ event = _make_audio_event(is_voice=True)
+
+ self.adapter._client.download = AsyncMock(side_effect=RuntimeError("boom"))
+
+ captured_event = None
+
+ async def capture(msg_event):
+ nonlocal captured_event
+ captured_event = msg_event
+
+ self.adapter.handle_message = capture
+
+ await self.adapter._on_room_message_media(room, event)
+
+ assert captured_event is not None
+ assert captured_event.media_urls is not None
+ assert captured_event.media_urls[0].startswith("http"), \
+ f"Should fall back to HTTP URL on exception, got {captured_event.media_urls[0]}"
+
+
+# ---------------------------------------------------------------------------
+# Tests: send_voice includes MSC3245 field (RED -> GREEN)
+# ---------------------------------------------------------------------------
+
+class TestMatrixSendVoiceMSC3245:
+ """Test that send_voice includes MSC3245 field for native voice rendering."""
+
+ def setup_method(self):
+ self.adapter = _make_adapter()
+ self.adapter._user_id = "@bot:example.org"
+ # Mock client with successful upload
+ self.adapter._client = MagicMock()
+ self.upload_call = None
+
+ async def mock_upload(*args, **kwargs):
+ self.upload_call = (args, kwargs)
+ import nio
+ resp = MagicMock()
+ resp.content_uri = "mxc://example.org/uploaded"
+ resp.__class__ = nio.UploadResponse
+ return resp, None
+
+ self.adapter._client.upload = mock_upload
+
+ @pytest.mark.asyncio
+ async def test_send_voice_includes_msc3245_field(self):
+ """send_voice should include org.matrix.msc3245.voice in message content."""
+ import tempfile
+ import os
+
+ # Create a temp audio file
+ with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
+ f.write(b"fake audio data")
+ temp_path = f.name
+
+ try:
+ # Capture the message content sent to room_send
+ sent_content = None
+
+ async def mock_room_send(room_id, event_type, content):
+ nonlocal sent_content
+ sent_content = content
+ resp = MagicMock()
+ resp.event_id = "$sent_event"
+ import nio
+ resp.__class__ = nio.RoomSendResponse
+ return resp
+
+ self.adapter._client.room_send = mock_room_send
+
+ await self.adapter.send_voice(
+ chat_id="!room:example.org",
+ audio_path=temp_path,
+ caption="Test voice",
+ )
+
+ assert sent_content is not None, "No message was sent"
+ assert "org.matrix.msc3245.voice" in sent_content, \
+ f"MSC3245 voice field missing from content: {sent_content.keys()}"
+ assert sent_content["msgtype"] == "m.audio"
+ assert sent_content["info"]["mimetype"] == "audio/ogg"
+ assert self.upload_call is not None, "Expected upload() to be called"
+ args, kwargs = self.upload_call
+ assert isinstance(args[0], io.BytesIO)
+ assert kwargs["content_type"] == "audio/ogg"
+ assert kwargs["filename"].endswith(".ogg")
+
+ finally:
+ os.unlink(temp_path)
diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py
new file mode 100644
index 000000000000..99675605d0c9
--- /dev/null
+++ b/tests/gateway/test_telegram_group_gating.py
@@ -0,0 +1,110 @@
+import json
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+from gateway.config import Platform, PlatformConfig, load_gateway_config
+
+
+def _make_adapter(require_mention=None, free_response_chats=None, mention_patterns=None):
+ from gateway.platforms.telegram import TelegramAdapter
+
+ extra = {}
+ if require_mention is not None:
+ extra["require_mention"] = require_mention
+ if free_response_chats is not None:
+ extra["free_response_chats"] = free_response_chats
+ if mention_patterns is not None:
+ extra["mention_patterns"] = mention_patterns
+
+ adapter = object.__new__(TelegramAdapter)
+ adapter.platform = Platform.TELEGRAM
+ adapter.config = PlatformConfig(enabled=True, token="***", extra=extra)
+ adapter._bot = SimpleNamespace(id=999, username="hermes_bot")
+ adapter._message_handler = AsyncMock()
+ adapter._pending_text_batches = {}
+ adapter._pending_text_batch_tasks = {}
+ adapter._text_batch_delay_seconds = 0.01
+ adapter._mention_patterns = adapter._compile_mention_patterns()
+ return adapter
+
+
+def _group_message(text="hello", *, chat_id=-100, reply_to_bot=False, entities=None, caption=None, caption_entities=None):
+ reply_to_message = None
+ if reply_to_bot:
+ reply_to_message = SimpleNamespace(from_user=SimpleNamespace(id=999))
+ return SimpleNamespace(
+ text=text,
+ caption=caption,
+ entities=entities or [],
+ caption_entities=caption_entities or [],
+ chat=SimpleNamespace(id=chat_id, type="group"),
+ reply_to_message=reply_to_message,
+ )
+
+
+def _mention_entity(text, mention="@hermes_bot"):
+ offset = text.index(mention)
+ return SimpleNamespace(type="mention", offset=offset, length=len(mention))
+
+
+def test_group_messages_can_be_opened_via_config():
+ adapter = _make_adapter(require_mention=False)
+
+ assert adapter._should_process_message(_group_message("hello everyone")) is True
+
+
+def test_group_messages_can_require_direct_trigger_via_config():
+ adapter = _make_adapter(require_mention=True)
+
+ assert adapter._should_process_message(_group_message("hello everyone")) is False
+ assert adapter._should_process_message(_group_message("hi @hermes_bot", entities=[_mention_entity("hi @hermes_bot")])) is True
+ assert adapter._should_process_message(_group_message("replying", reply_to_bot=True)) is True
+ assert adapter._should_process_message(_group_message("/status"), is_command=True) is True
+
+
+def test_free_response_chats_bypass_mention_requirement():
+ adapter = _make_adapter(require_mention=True, free_response_chats=["-200"])
+
+ assert adapter._should_process_message(_group_message("hello everyone", chat_id=-200)) is True
+ assert adapter._should_process_message(_group_message("hello everyone", chat_id=-201)) is False
+
+
+def test_regex_mention_patterns_allow_custom_wake_words():
+ adapter = _make_adapter(require_mention=True, mention_patterns=[r"^\s*chompy\b"])
+
+ assert adapter._should_process_message(_group_message("chompy status")) is True
+ assert adapter._should_process_message(_group_message(" chompy help")) is True
+ assert adapter._should_process_message(_group_message("hey chompy")) is False
+
+
+def test_invalid_regex_patterns_are_ignored():
+ adapter = _make_adapter(require_mention=True, mention_patterns=[r"(", r"^\s*chompy\b"])
+
+ assert adapter._should_process_message(_group_message("chompy status")) is True
+ assert adapter._should_process_message(_group_message("hello everyone")) is False
+
+
+def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path):
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ (hermes_home / "config.yaml").write_text(
+ "telegram:\n"
+ " require_mention: true\n"
+ " mention_patterns:\n"
+ " - \"^\\\\s*chompy\\\\b\"\n"
+ " free_response_chats:\n"
+ " - \"-123\"\n",
+ encoding="utf-8",
+ )
+
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ monkeypatch.delenv("TELEGRAM_REQUIRE_MENTION", raising=False)
+ monkeypatch.delenv("TELEGRAM_MENTION_PATTERNS", raising=False)
+ monkeypatch.delenv("TELEGRAM_FREE_RESPONSE_CHATS", raising=False)
+
+ config = load_gateway_config()
+
+ assert config is not None
+ assert __import__("os").environ["TELEGRAM_REQUIRE_MENTION"] == "true"
+ assert json.loads(__import__("os").environ["TELEGRAM_MENTION_PATTERNS"]) == [r"^\s*chompy\b"]
+ assert __import__("os").environ["TELEGRAM_FREE_RESPONSE_CHATS"] == "-123"
diff --git a/tests/gateway/test_unauthorized_dm_behavior.py b/tests/gateway/test_unauthorized_dm_behavior.py
index 0dbe457a8265..02aae301c161 100644
--- a/tests/gateway/test_unauthorized_dm_behavior.py
+++ b/tests/gateway/test_unauthorized_dm_behavior.py
@@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
+import gateway.run as gateway_run
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent
from gateway.session import SessionSource
@@ -19,7 +20,7 @@ def _clear_auth_env(monkeypatch) -> None:
"SMS_ALLOWED_USERS",
"MATTERMOST_ALLOWED_USERS",
"MATRIX_ALLOWED_USERS",
- "DINGTALK_ALLOWED_USERS",
+ "DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS", "WECOM_ALLOWED_USERS",
"GATEWAY_ALLOWED_USERS",
"TELEGRAM_ALLOW_ALL_USERS",
"DISCORD_ALLOW_ALL_USERS",
@@ -30,7 +31,7 @@ def _clear_auth_env(monkeypatch) -> None:
"SMS_ALLOW_ALL_USERS",
"MATTERMOST_ALLOW_ALL_USERS",
"MATRIX_ALLOW_ALL_USERS",
- "DINGTALK_ALLOW_ALL_USERS",
+ "DINGTALK_ALLOW_ALL_USERS", "FEISHU_ALLOW_ALL_USERS", "WECOM_ALLOW_ALL_USERS",
"GATEWAY_ALLOW_ALL_USERS",
):
monkeypatch.delenv(key, raising=False)
@@ -62,6 +63,32 @@ def _make_runner(platform: Platform, config: GatewayConfig):
return runner, adapter
+def test_whatsapp_lid_user_matches_phone_allowlist_via_session_mapping(monkeypatch, tmp_path):
+ _clear_auth_env(monkeypatch)
+ monkeypatch.setenv("WHATSAPP_ALLOWED_USERS", "15550000001")
+ monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
+
+ session_dir = tmp_path / "whatsapp" / "session"
+ session_dir.mkdir(parents=True)
+ (session_dir / "lid-mapping-15550000001.json").write_text('"900000000000001"', encoding="utf-8")
+ (session_dir / "lid-mapping-900000000000001_reverse.json").write_text('"15550000001"', encoding="utf-8")
+
+ runner, _adapter = _make_runner(
+ Platform.WHATSAPP,
+ GatewayConfig(platforms={Platform.WHATSAPP: PlatformConfig(enabled=True)}),
+ )
+
+ source = SessionSource(
+ platform=Platform.WHATSAPP,
+ user_id="900000000000001@lid",
+ chat_id="900000000000001@lid",
+ user_name="tester",
+ chat_type="dm",
+ )
+
+ assert runner._is_user_authorized(source) is True
+
+
@pytest.mark.asyncio
async def test_unauthorized_dm_pairs_by_default(monkeypatch):
_clear_auth_env(monkeypatch)
diff --git a/tests/gateway/test_wecom.py b/tests/gateway/test_wecom.py
new file mode 100644
index 000000000000..a7101c6973f9
--- /dev/null
+++ b/tests/gateway/test_wecom.py
@@ -0,0 +1,596 @@
+"""Tests for the WeCom platform adapter."""
+
+import base64
+import os
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from gateway.config import Platform, PlatformConfig
+from gateway.platforms.base import SendResult
+
+
+class TestWeComRequirements:
+ def test_returns_false_without_aiohttp(self, monkeypatch):
+ monkeypatch.setattr("gateway.platforms.wecom.AIOHTTP_AVAILABLE", False)
+ monkeypatch.setattr("gateway.platforms.wecom.HTTPX_AVAILABLE", True)
+ from gateway.platforms.wecom import check_wecom_requirements
+
+ assert check_wecom_requirements() is False
+
+ def test_returns_false_without_httpx(self, monkeypatch):
+ monkeypatch.setattr("gateway.platforms.wecom.AIOHTTP_AVAILABLE", True)
+ monkeypatch.setattr("gateway.platforms.wecom.HTTPX_AVAILABLE", False)
+ from gateway.platforms.wecom import check_wecom_requirements
+
+ assert check_wecom_requirements() is False
+
+ def test_returns_true_when_available(self, monkeypatch):
+ monkeypatch.setattr("gateway.platforms.wecom.AIOHTTP_AVAILABLE", True)
+ monkeypatch.setattr("gateway.platforms.wecom.HTTPX_AVAILABLE", True)
+ from gateway.platforms.wecom import check_wecom_requirements
+
+ assert check_wecom_requirements() is True
+
+
+class TestWeComAdapterInit:
+ def test_reads_config_from_extra(self):
+ from gateway.platforms.wecom import WeComAdapter
+
+ config = PlatformConfig(
+ enabled=True,
+ extra={
+ "bot_id": "cfg-bot",
+ "secret": "cfg-secret",
+ "websocket_url": "wss://custom.wecom.example/ws",
+ "group_policy": "allowlist",
+ "group_allow_from": ["group-1"],
+ },
+ )
+ adapter = WeComAdapter(config)
+
+ assert adapter._bot_id == "cfg-bot"
+ assert adapter._secret == "cfg-secret"
+ assert adapter._ws_url == "wss://custom.wecom.example/ws"
+ assert adapter._group_policy == "allowlist"
+ assert adapter._group_allow_from == ["group-1"]
+
+ def test_falls_back_to_env_vars(self, monkeypatch):
+ monkeypatch.setenv("WECOM_BOT_ID", "env-bot")
+ monkeypatch.setenv("WECOM_SECRET", "env-secret")
+ monkeypatch.setenv("WECOM_WEBSOCKET_URL", "wss://env.example/ws")
+ from gateway.platforms.wecom import WeComAdapter
+
+ adapter = WeComAdapter(PlatformConfig(enabled=True))
+ assert adapter._bot_id == "env-bot"
+ assert adapter._secret == "env-secret"
+ assert adapter._ws_url == "wss://env.example/ws"
+
+
+class TestWeComConnect:
+ @pytest.mark.asyncio
+ async def test_connect_records_missing_credentials(self, monkeypatch):
+ import gateway.platforms.wecom as wecom_module
+ from gateway.platforms.wecom import WeComAdapter
+
+ monkeypatch.setattr(wecom_module, "AIOHTTP_AVAILABLE", True)
+ monkeypatch.setattr(wecom_module, "HTTPX_AVAILABLE", True)
+
+ adapter = WeComAdapter(PlatformConfig(enabled=True))
+
+ success = await adapter.connect()
+
+ assert success is False
+ assert adapter.has_fatal_error is True
+ assert adapter.fatal_error_code == "wecom_missing_credentials"
+ assert "WECOM_BOT_ID" in (adapter.fatal_error_message or "")
+
+ @pytest.mark.asyncio
+ async def test_connect_records_handshake_failure_details(self, monkeypatch):
+ import gateway.platforms.wecom as wecom_module
+ from gateway.platforms.wecom import WeComAdapter
+
+ class DummyClient:
+ async def aclose(self):
+ return None
+
+ monkeypatch.setattr(wecom_module, "AIOHTTP_AVAILABLE", True)
+ monkeypatch.setattr(wecom_module, "HTTPX_AVAILABLE", True)
+ monkeypatch.setattr(
+ wecom_module,
+ "httpx",
+ SimpleNamespace(AsyncClient=lambda **kwargs: DummyClient()),
+ )
+
+ adapter = WeComAdapter(
+ PlatformConfig(enabled=True, extra={"bot_id": "bot-1", "secret": "secret-1"})
+ )
+ adapter._open_connection = AsyncMock(side_effect=RuntimeError("invalid secret (errcode=40013)"))
+
+ success = await adapter.connect()
+
+ assert success is False
+ assert adapter.has_fatal_error is True
+ assert adapter.fatal_error_code == "wecom_connect_error"
+ assert "invalid secret" in (adapter.fatal_error_message or "")
+
+
+class TestWeComReplyMode:
+ @pytest.mark.asyncio
+ async def test_send_uses_passive_reply_stream_when_reply_context_exists(self):
+ from gateway.platforms.wecom import WeComAdapter
+
+ adapter = WeComAdapter(PlatformConfig(enabled=True))
+ adapter._reply_req_ids["msg-1"] = "req-1"
+ adapter._send_reply_request = AsyncMock(
+ return_value={"headers": {"req_id": "req-1"}, "errcode": 0}
+ )
+
+ result = await adapter.send("chat-123", "hello from reply", reply_to="msg-1")
+
+ assert result.success is True
+ adapter._send_reply_request.assert_awaited_once()
+ args = adapter._send_reply_request.await_args.args
+ assert args[0] == "req-1"
+ assert args[1]["msgtype"] == "stream"
+ assert args[1]["stream"]["finish"] is True
+ assert args[1]["stream"]["content"] == "hello from reply"
+
+ @pytest.mark.asyncio
+ async def test_send_image_file_uses_passive_reply_media_when_reply_context_exists(self):
+ from gateway.platforms.wecom import WeComAdapter
+
+ adapter = WeComAdapter(PlatformConfig(enabled=True))
+ adapter._reply_req_ids["msg-1"] = "req-1"
+ adapter._prepare_outbound_media = AsyncMock(
+ return_value={
+ "data": b"image-bytes",
+ "content_type": "image/png",
+ "file_name": "demo.png",
+ "detected_type": "image",
+ "final_type": "image",
+ "rejected": False,
+ "reject_reason": None,
+ "downgraded": False,
+ "downgrade_note": None,
+ }
+ )
+ adapter._upload_media_bytes = AsyncMock(return_value={"media_id": "media-1", "type": "image"})
+ adapter._send_reply_request = AsyncMock(
+ return_value={"headers": {"req_id": "req-1"}, "errcode": 0}
+ )
+
+ result = await adapter.send_image_file("chat-123", "/tmp/demo.png", reply_to="msg-1")
+
+ assert result.success is True
+ adapter._send_reply_request.assert_awaited_once()
+ args = adapter._send_reply_request.await_args.args
+ assert args[0] == "req-1"
+ assert args[1] == {"msgtype": "image", "image": {"media_id": "media-1"}}
+
+
+class TestExtractText:
+ def test_extracts_plain_text(self):
+ from gateway.platforms.wecom import WeComAdapter
+
+ body = {
+ "msgtype": "text",
+ "text": {"content": " hello world "},
+ }
+ text, reply_text = WeComAdapter._extract_text(body)
+ assert text == "hello world"
+ assert reply_text is None
+
+ def test_extracts_mixed_text(self):
+ from gateway.platforms.wecom import WeComAdapter
+
+ body = {
+ "msgtype": "mixed",
+ "mixed": {
+ "msg_item": [
+ {"msgtype": "text", "text": {"content": "part1"}},
+ {"msgtype": "image", "image": {"url": "https://example.com/x.png"}},
+ {"msgtype": "text", "text": {"content": "part2"}},
+ ]
+ },
+ }
+ text, _reply_text = WeComAdapter._extract_text(body)
+ assert text == "part1\npart2"
+
+ def test_extracts_voice_and_quote(self):
+ from gateway.platforms.wecom import WeComAdapter
+
+ body = {
+ "msgtype": "voice",
+ "voice": {"content": "spoken text"},
+ "quote": {"msgtype": "text", "text": {"content": "quoted"}},
+ }
+ text, reply_text = WeComAdapter._extract_text(body)
+ assert text == "spoken text"
+ assert reply_text == "quoted"
+
+
+class TestCallbackDispatch:
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("cmd", ["aibot_msg_callback", "aibot_callback"])
+ async def test_dispatch_accepts_new_and_legacy_callback_cmds(self, cmd):
+ from gateway.platforms.wecom import WeComAdapter
+
+ adapter = WeComAdapter(PlatformConfig(enabled=True))
+ adapter._on_message = AsyncMock()
+
+ await adapter._dispatch_payload({"cmd": cmd, "headers": {"req_id": "req-1"}, "body": {}})
+
+ adapter._on_message.assert_awaited_once()
+
+
+class TestPolicyHelpers:
+ def test_dm_allowlist(self):
+ from gateway.platforms.wecom import WeComAdapter
+
+ adapter = WeComAdapter(
+ PlatformConfig(enabled=True, extra={"dm_policy": "allowlist", "allow_from": ["user-1"]})
+ )
+ assert adapter._is_dm_allowed("user-1") is True
+ assert adapter._is_dm_allowed("user-2") is False
+
+ def test_group_allowlist_and_per_group_sender_allowlist(self):
+ from gateway.platforms.wecom import WeComAdapter
+
+ adapter = WeComAdapter(
+ PlatformConfig(
+ enabled=True,
+ extra={
+ "group_policy": "allowlist",
+ "group_allow_from": ["group-1"],
+ "groups": {"group-1": {"allow_from": ["user-1"]}},
+ },
+ )
+ )
+
+ assert adapter._is_group_allowed("group-1", "user-1") is True
+ assert adapter._is_group_allowed("group-1", "user-2") is False
+ assert adapter._is_group_allowed("group-2", "user-1") is False
+
+
+class TestMediaHelpers:
+ def test_detect_wecom_media_type(self):
+ from gateway.platforms.wecom import WeComAdapter
+
+ assert WeComAdapter._detect_wecom_media_type("image/png") == "image"
+ assert WeComAdapter._detect_wecom_media_type("video/mp4") == "video"
+ assert WeComAdapter._detect_wecom_media_type("audio/amr") == "voice"
+ assert WeComAdapter._detect_wecom_media_type("application/pdf") == "file"
+
+ def test_voice_non_amr_downgrades_to_file(self):
+ from gateway.platforms.wecom import WeComAdapter
+
+ result = WeComAdapter._apply_file_size_limits(128, "voice", "audio/mpeg")
+
+ assert result["final_type"] == "file"
+ assert result["downgraded"] is True
+ assert "AMR" in (result["downgrade_note"] or "")
+
+ def test_oversized_file_is_rejected(self):
+ from gateway.platforms.wecom import ABSOLUTE_MAX_BYTES, WeComAdapter
+
+ result = WeComAdapter._apply_file_size_limits(ABSOLUTE_MAX_BYTES + 1, "file", "application/pdf")
+
+ assert result["rejected"] is True
+ assert "20MB" in (result["reject_reason"] or "")
+
+ def test_decrypt_file_bytes_round_trip(self):
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+ from gateway.platforms.wecom import WeComAdapter
+
+ plaintext = b"wecom-secret"
+ key = os.urandom(32)
+ pad_len = 32 - (len(plaintext) % 32)
+ padded = plaintext + bytes([pad_len]) * pad_len
+ encryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).encryptor()
+ encrypted = encryptor.update(padded) + encryptor.finalize()
+
+ decrypted = WeComAdapter._decrypt_file_bytes(encrypted, base64.b64encode(key).decode("ascii"))
+
+ assert decrypted == plaintext
+
+ @pytest.mark.asyncio
+ async def test_load_outbound_media_rejects_placeholder_path(self):
+ from gateway.platforms.wecom import WeComAdapter
+
+ adapter = WeComAdapter(PlatformConfig(enabled=True))
+
+ with pytest.raises(ValueError, match="placeholder was not replaced"):
+ await adapter._load_outbound_media("")
+
+
+class TestMediaUpload:
+ @pytest.mark.asyncio
+ async def test_upload_media_bytes_uses_sdk_sequence(self, monkeypatch):
+ import gateway.platforms.wecom as wecom_module
+ from gateway.platforms.wecom import (
+ APP_CMD_UPLOAD_MEDIA_CHUNK,
+ APP_CMD_UPLOAD_MEDIA_FINISH,
+ APP_CMD_UPLOAD_MEDIA_INIT,
+ WeComAdapter,
+ )
+
+ adapter = WeComAdapter(PlatformConfig(enabled=True))
+ calls = []
+
+ async def fake_send_request(cmd, body, timeout=0):
+ calls.append((cmd, body))
+ if cmd == APP_CMD_UPLOAD_MEDIA_INIT:
+ return {"errcode": 0, "body": {"upload_id": "upload-1"}}
+ if cmd == APP_CMD_UPLOAD_MEDIA_CHUNK:
+ return {"errcode": 0}
+ if cmd == APP_CMD_UPLOAD_MEDIA_FINISH:
+ return {
+ "errcode": 0,
+ "body": {
+ "media_id": "media-1",
+ "type": "file",
+ "created_at": "2026-03-18T00:00:00Z",
+ },
+ }
+ raise AssertionError(f"unexpected cmd {cmd}")
+
+ monkeypatch.setattr(wecom_module, "UPLOAD_CHUNK_SIZE", 4)
+ adapter._send_request = fake_send_request
+
+ result = await adapter._upload_media_bytes(b"abcdefghij", "file", "demo.bin")
+
+ assert result["media_id"] == "media-1"
+ assert [cmd for cmd, _body in calls] == [
+ APP_CMD_UPLOAD_MEDIA_INIT,
+ APP_CMD_UPLOAD_MEDIA_CHUNK,
+ APP_CMD_UPLOAD_MEDIA_CHUNK,
+ APP_CMD_UPLOAD_MEDIA_CHUNK,
+ APP_CMD_UPLOAD_MEDIA_FINISH,
+ ]
+ assert calls[1][1]["chunk_index"] == 0
+ assert calls[2][1]["chunk_index"] == 1
+ assert calls[3][1]["chunk_index"] == 2
+
+ @pytest.mark.asyncio
+ async def test_download_remote_bytes_rejects_large_content_length(self):
+ from gateway.platforms.wecom import WeComAdapter
+
+ class FakeResponse:
+ headers = {"content-length": "10"}
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return None
+
+ def raise_for_status(self):
+ return None
+
+ async def aiter_bytes(self):
+ yield b"abc"
+
+ class FakeClient:
+ def stream(self, method, url, headers=None):
+ return FakeResponse()
+
+ adapter = WeComAdapter(PlatformConfig(enabled=True))
+ adapter._http_client = FakeClient()
+
+ with pytest.raises(ValueError, match="exceeds WeCom limit"):
+ await adapter._download_remote_bytes("https://example.com/file.bin", max_bytes=4)
+
+ @pytest.mark.asyncio
+ async def test_cache_media_decrypts_url_payload_before_writing(self):
+ from gateway.platforms.wecom import WeComAdapter
+
+ adapter = WeComAdapter(PlatformConfig(enabled=True))
+ plaintext = b"secret document bytes"
+ key = os.urandom(32)
+ pad_len = 32 - (len(plaintext) % 32)
+ padded = plaintext + bytes([pad_len]) * pad_len
+
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+
+ encryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).encryptor()
+ encrypted = encryptor.update(padded) + encryptor.finalize()
+ adapter._download_remote_bytes = AsyncMock(
+ return_value=(
+ encrypted,
+ {
+ "content-type": "application/octet-stream",
+ "content-disposition": 'attachment; filename="secret.bin"',
+ },
+ )
+ )
+
+ cached = await adapter._cache_media(
+ "file",
+ {
+ "url": "https://example.com/secret.bin",
+ "aeskey": base64.b64encode(key).decode("ascii"),
+ },
+ )
+
+ assert cached is not None
+ cached_path, content_type = cached
+ assert Path(cached_path).read_bytes() == plaintext
+ assert content_type == "application/octet-stream"
+
+
+class TestSend:
+ @pytest.mark.asyncio
+ async def test_send_uses_proactive_payload(self):
+ from gateway.platforms.wecom import APP_CMD_SEND, WeComAdapter
+
+ adapter = WeComAdapter(PlatformConfig(enabled=True))
+ adapter._send_request = AsyncMock(return_value={"headers": {"req_id": "req-1"}, "errcode": 0})
+
+ result = await adapter.send("chat-123", "Hello WeCom")
+
+ assert result.success is True
+ adapter._send_request.assert_awaited_once_with(
+ APP_CMD_SEND,
+ {
+ "chatid": "chat-123",
+ "msgtype": "markdown",
+ "markdown": {"content": "Hello WeCom"},
+ },
+ )
+
+ @pytest.mark.asyncio
+ async def test_send_reports_wecom_errors(self):
+ from gateway.platforms.wecom import WeComAdapter
+
+ adapter = WeComAdapter(PlatformConfig(enabled=True))
+ adapter._send_request = AsyncMock(return_value={"errcode": 40001, "errmsg": "bad request"})
+
+ result = await adapter.send("chat-123", "Hello WeCom")
+
+ assert result.success is False
+ assert "40001" in (result.error or "")
+
+ @pytest.mark.asyncio
+ async def test_send_image_falls_back_to_text_for_remote_url(self):
+ from gateway.platforms.wecom import WeComAdapter
+
+ adapter = WeComAdapter(PlatformConfig(enabled=True))
+ adapter._send_media_source = AsyncMock(return_value=SendResult(success=False, error="upload failed"))
+ adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="msg-1"))
+
+ result = await adapter.send_image("chat-123", "https://example.com/demo.png", caption="demo")
+
+ assert result.success is True
+ adapter.send.assert_awaited_once_with(chat_id="chat-123", content="demo\nhttps://example.com/demo.png", reply_to=None)
+
+ @pytest.mark.asyncio
+ async def test_send_voice_sends_caption_and_downgrade_note(self):
+ from gateway.platforms.wecom import WeComAdapter
+
+ adapter = WeComAdapter(PlatformConfig(enabled=True))
+ adapter._prepare_outbound_media = AsyncMock(
+ return_value={
+ "data": b"voice-bytes",
+ "content_type": "audio/mpeg",
+ "file_name": "voice.mp3",
+ "detected_type": "voice",
+ "final_type": "file",
+ "rejected": False,
+ "reject_reason": None,
+ "downgraded": True,
+ "downgrade_note": "语音格式 audio/mpeg 不支持,企微仅支持 AMR 格式,已转为文件格式发送",
+ }
+ )
+ adapter._upload_media_bytes = AsyncMock(return_value={"media_id": "media-1", "type": "file"})
+ adapter._send_media_message = AsyncMock(return_value={"headers": {"req_id": "req-media"}, "errcode": 0})
+ adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="msg-1"))
+
+ result = await adapter.send_voice("chat-123", "/tmp/voice.mp3", caption="listen")
+
+ assert result.success is True
+ adapter._send_media_message.assert_awaited_once_with("chat-123", "file", "media-1")
+ assert adapter.send.await_count == 2
+ adapter.send.assert_any_await(chat_id="chat-123", content="listen", reply_to=None)
+ adapter.send.assert_any_await(
+ chat_id="chat-123",
+ content="ℹ️ 语音格式 audio/mpeg 不支持,企微仅支持 AMR 格式,已转为文件格式发送",
+ reply_to=None,
+ )
+
+
+class TestInboundMessages:
+ @pytest.mark.asyncio
+ async def test_on_message_builds_event(self):
+ from gateway.platforms.wecom import WeComAdapter
+
+ adapter = WeComAdapter(PlatformConfig(enabled=True))
+ adapter.handle_message = AsyncMock()
+ adapter._extract_media = AsyncMock(return_value=(["/tmp/test.png"], ["image/png"]))
+
+ payload = {
+ "cmd": "aibot_msg_callback",
+ "headers": {"req_id": "req-1"},
+ "body": {
+ "msgid": "msg-1",
+ "chatid": "group-1",
+ "chattype": "group",
+ "from": {"userid": "user-1"},
+ "msgtype": "text",
+ "text": {"content": "hello"},
+ },
+ }
+
+ await adapter._on_message(payload)
+
+ adapter.handle_message.assert_awaited_once()
+ event = adapter.handle_message.await_args.args[0]
+ assert event.text == "hello"
+ assert event.source.chat_id == "group-1"
+ assert event.source.user_id == "user-1"
+ assert event.media_urls == ["/tmp/test.png"]
+ assert event.media_types == ["image/png"]
+
+ @pytest.mark.asyncio
+ async def test_on_message_preserves_quote_context(self):
+ from gateway.platforms.wecom import WeComAdapter
+
+ adapter = WeComAdapter(PlatformConfig(enabled=True))
+ adapter.handle_message = AsyncMock()
+ adapter._extract_media = AsyncMock(return_value=([], []))
+
+ payload = {
+ "cmd": "aibot_msg_callback",
+ "headers": {"req_id": "req-1"},
+ "body": {
+ "msgid": "msg-1",
+ "chatid": "group-1",
+ "chattype": "group",
+ "from": {"userid": "user-1"},
+ "msgtype": "text",
+ "text": {"content": "follow up"},
+ "quote": {"msgtype": "text", "text": {"content": "quoted message"}},
+ },
+ }
+
+ await adapter._on_message(payload)
+
+ event = adapter.handle_message.await_args.args[0]
+ assert event.reply_to_text == "quoted message"
+ assert event.reply_to_message_id == "quote:msg-1"
+
+ @pytest.mark.asyncio
+ async def test_on_message_respects_group_policy(self):
+ from gateway.platforms.wecom import WeComAdapter
+
+ adapter = WeComAdapter(
+ PlatformConfig(
+ enabled=True,
+ extra={"group_policy": "allowlist", "group_allow_from": ["group-allowed"]},
+ )
+ )
+ adapter.handle_message = AsyncMock()
+ adapter._extract_media = AsyncMock(return_value=([], []))
+
+ payload = {
+ "cmd": "aibot_callback",
+ "headers": {"req_id": "req-1"},
+ "body": {
+ "msgid": "msg-1",
+ "chatid": "group-blocked",
+ "chattype": "group",
+ "from": {"userid": "user-1"},
+ "msgtype": "text",
+ "text": {"content": "hello"},
+ },
+ }
+
+ await adapter._on_message(payload)
+ adapter.handle_message.assert_not_awaited()
+
+
+class TestPlatformEnum:
+ def test_wecom_in_platform_enum(self):
+ assert Platform.WECOM.value == "wecom"
diff --git a/tests/hermes_cli/test_launcher.py b/tests/hermes_cli/test_launcher.py
new file mode 100644
index 000000000000..9c3cea851f49
--- /dev/null
+++ b/tests/hermes_cli/test_launcher.py
@@ -0,0 +1,42 @@
+"""Tests for the top-level `./hermes` launcher script."""
+
+import runpy
+import sys
+import types
+from pathlib import Path
+
+
+def test_launcher_delegates_to_argparse_entrypoint(monkeypatch):
+ """`./hermes` should use `hermes_cli.main`, not the legacy Fire wrapper."""
+ launcher_path = Path(__file__).resolve().parents[2] / "hermes"
+ called = []
+
+ fake_main_module = types.ModuleType("hermes_cli.main")
+
+ def fake_main():
+ called.append("hermes_cli.main")
+
+ fake_main_module.main = fake_main
+ monkeypatch.setitem(sys.modules, "hermes_cli.main", fake_main_module)
+
+ fake_cli_module = types.ModuleType("cli")
+
+ def legacy_cli_main(*args, **kwargs):
+ raise AssertionError("launcher should not import cli.main")
+
+ fake_cli_module.main = legacy_cli_main
+ monkeypatch.setitem(sys.modules, "cli", fake_cli_module)
+
+ fake_fire_module = types.ModuleType("fire")
+
+ def legacy_fire(*args, **kwargs):
+ raise AssertionError("launcher should not invoke fire.Fire")
+
+ fake_fire_module.Fire = legacy_fire
+ monkeypatch.setitem(sys.modules, "fire", fake_fire_module)
+
+ monkeypatch.setattr(sys, "argv", [str(launcher_path), "gateway", "status"])
+
+ runpy.run_path(str(launcher_path), run_name="__main__")
+
+ assert called == ["hermes_cli.main"]
diff --git a/tests/hermes_cli/test_tool_token_estimation.py b/tests/hermes_cli/test_tool_token_estimation.py
index 3b5583e1500b..3e48980bf88b 100644
--- a/tests/hermes_cli/test_tool_token_estimation.py
+++ b/tests/hermes_cli/test_tool_token_estimation.py
@@ -4,10 +4,20 @@ from unittest.mock import patch
import pytest
+# tiktoken is not in core/[all] deps — skip estimation tests when unavailable
+_has_tiktoken = True
+try:
+ import tiktoken # noqa: F401
+except ImportError:
+ _has_tiktoken = False
+
+_needs_tiktoken = pytest.mark.skipif(not _has_tiktoken, reason="tiktoken not installed")
+
# ─── Token Estimation Tests ──────────────────────────────────────────────────
+@_needs_tiktoken
def test_estimate_tool_tokens_returns_positive_counts():
"""_estimate_tool_tokens should return a non-empty dict with positive values."""
from hermes_cli.tools_config import _estimate_tool_tokens, _tool_token_cache
@@ -26,6 +36,7 @@ def test_estimate_tool_tokens_returns_positive_counts():
assert count > 0, f"Tool {name} has non-positive token count: {count}"
+@_needs_tiktoken
def test_estimate_tool_tokens_is_cached():
"""Second call should return the same cached dict object."""
import hermes_cli.tools_config as tc
@@ -60,6 +71,7 @@ def test_estimate_tool_tokens_returns_empty_when_tiktoken_unavailable(monkeypatc
tc._tool_token_cache = None
+@_needs_tiktoken
def test_estimate_tool_tokens_covers_known_tools():
"""Should include schemas for well-known tools like terminal, web_search."""
import hermes_cli.tools_config as tc
diff --git a/tests/test_api_key_providers.py b/tests/test_api_key_providers.py
index ad1676e6554e..0c6337d3ec9c 100644
--- a/tests/test_api_key_providers.py
+++ b/tests/test_api_key_providers.py
@@ -266,7 +266,8 @@ class TestResolveProvider:
def test_auto_does_not_select_copilot_from_github_token(self, monkeypatch):
monkeypatch.setenv("GITHUB_TOKEN", "gh-test-token")
- assert resolve_provider("auto") == "openrouter"
+ with pytest.raises(AuthError, match="No inference provider configured"):
+ resolve_provider("auto")
# =============================================================================
diff --git a/tests/test_cli_status_bar.py b/tests/test_cli_status_bar.py
index 936ec21902e3..104c58b1f88c 100644
--- a/tests/test_cli_status_bar.py
+++ b/tests/test_cli_status_bar.py
@@ -214,8 +214,9 @@ class TestStatusBarWidthSource:
frags = cli_obj._get_status_bar_fragments()
total_text = "".join(text for _, text in frags)
- assert len(total_text) <= width + 4, ( # +4 for minor padding chars
- f"At width={width}, fragment total {len(total_text)} chars overflows "
+ display_width = cli_obj._status_bar_display_width(total_text)
+ assert display_width <= width + 4, ( # +4 for minor padding chars
+ f"At width={width}, fragment total {display_width} cells overflows "
f"({total_text!r})"
)
diff --git a/tests/test_codex_models.py b/tests/test_codex_models.py
index 32fe631535a1..da178d9be09c 100644
--- a/tests/test_codex_models.py
+++ b/tests/test_codex_models.py
@@ -33,6 +33,7 @@ def test_get_codex_model_ids_prioritizes_default_and_cache(tmp_path, monkeypatch
assert "gpt-5.3-codex" in models
# Non-codex-suffixed models are included when the cache says they're available
assert "gpt-5.4" in models
+ assert "gpt-5.4-mini" in models
assert "gpt-5-hidden-codex" not in models
@@ -64,7 +65,7 @@ def test_get_codex_model_ids_adds_forward_compat_models_from_templates(monkeypat
models = get_codex_model_ids(access_token="codex-access-token")
- assert models == ["gpt-5.2-codex", "gpt-5.3-codex", "gpt-5.4", "gpt-5.3-codex-spark"]
+ assert models == ["gpt-5.2-codex", "gpt-5.4-mini", "gpt-5.4", "gpt-5.3-codex", "gpt-5.3-codex-spark"]
def test_model_command_uses_runtime_access_token_for_codex_list(monkeypatch):
diff --git a/tests/test_plugins_cmd.py b/tests/test_plugins_cmd.py
index e93e2dc50a5c..ac95571be2f4 100644
--- a/tests/test_plugins_cmd.py
+++ b/tests/test_plugins_cmd.py
@@ -150,11 +150,11 @@ class TestPluginsCommandDispatch:
plugins_command(args)
mock_list.assert_called_once()
- @patch("hermes_cli.plugins_cmd.cmd_list")
- def test_none_falls_through_to_list(self, mock_list):
+ @patch("hermes_cli.plugins_cmd.cmd_toggle")
+ def test_none_falls_through_to_toggle(self, mock_toggle):
args = self._make_args(None)
plugins_command(args)
- mock_list.assert_called_once()
+ mock_toggle.assert_called_once()
@patch("hermes_cli.plugins_cmd.cmd_install")
def test_install_dispatches(self, mock_install):
diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py
new file mode 100644
index 000000000000..1a377f5f5ead
--- /dev/null
+++ b/tests/test_project_metadata.py
@@ -0,0 +1,18 @@
+"""Regression tests for packaging metadata in pyproject.toml."""
+
+from pathlib import Path
+import tomllib
+
+
+def _load_optional_dependencies():
+ pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
+ with pyproject_path.open("rb") as handle:
+ project = tomllib.load(handle)["project"]
+ return project["optional-dependencies"]
+
+
+def test_all_extra_includes_matrix_dependency():
+ optional_dependencies = _load_optional_dependencies()
+
+ assert "matrix" in optional_dependencies
+ assert "hermes-agent[matrix]" in optional_dependencies["all"]
diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py
index 0d48b7c1fa4b..abdda05fa94d 100644
--- a/tests/tools/test_approval.py
+++ b/tests/tools/test_approval.py
@@ -339,6 +339,16 @@ class TestTeePattern:
assert dangerous is True
assert key is not None
+ def test_tee_custom_hermes_home_env(self):
+ dangerous, key, desc = detect_dangerous_command("echo x | tee $HERMES_HOME/.env")
+ assert dangerous is True
+ assert key is not None
+
+ def test_tee_quoted_custom_hermes_home_env(self):
+ dangerous, key, desc = detect_dangerous_command('echo x | tee "$HERMES_HOME/.env"')
+ assert dangerous is True
+ assert key is not None
+
def test_tee_tmp_safe(self):
dangerous, key, desc = detect_dangerous_command("echo hello | tee /tmp/output.txt")
assert dangerous is False
@@ -374,6 +384,30 @@ class TestFindExecFullPathRm:
assert key is None
+class TestSensitiveRedirectPattern:
+ """Detect shell redirection writes to sensitive user-managed paths."""
+
+ def test_redirect_to_custom_hermes_home_env(self):
+ dangerous, key, desc = detect_dangerous_command("echo x > $HERMES_HOME/.env")
+ assert dangerous is True
+ assert key is not None
+
+ def test_append_to_home_ssh_authorized_keys(self):
+ dangerous, key, desc = detect_dangerous_command("cat key >> $HOME/.ssh/authorized_keys")
+ assert dangerous is True
+ assert key is not None
+
+ def test_append_to_tilde_ssh_authorized_keys(self):
+ dangerous, key, desc = detect_dangerous_command("cat key >> ~/.ssh/authorized_keys")
+ assert dangerous is True
+ assert key is not None
+
+ def test_redirect_to_safe_tmp_file(self):
+ dangerous, key, desc = detect_dangerous_command("echo hello > /tmp/output.txt")
+ assert dangerous is False
+ assert key is None
+
+
class TestPatternKeyUniqueness:
"""Bug: pattern_key is derived by splitting on \\b and taking [1], so
patterns starting with the same word (e.g. find -exec rm and find -delete)
@@ -606,3 +640,4 @@ class TestNormalizationBypass:
dangerous, key, desc = detect_dangerous_command(cmd)
assert dangerous is False
+
diff --git a/tests/tools/test_credential_files.py b/tests/tools/test_credential_files.py
index 293e2c6da771..c46f73fae337 100644
--- a/tests/tools/test_credential_files.py
+++ b/tests/tools/test_credential_files.py
@@ -1,13 +1,17 @@
-"""Tests for credential file passthrough registry (tools/credential_files.py)."""
+"""Tests for credential file passthrough and skills directory mounting."""
+import json
import os
from pathlib import Path
+from unittest.mock import patch
import pytest
from tools.credential_files import (
clear_credential_files,
get_credential_file_mounts,
+ get_skills_directory_mount,
+ iter_skills_files,
register_credential_file,
register_credential_files,
reset_config_cache,
@@ -15,8 +19,8 @@ from tools.credential_files import (
@pytest.fixture(autouse=True)
-def _clean_registry():
- """Reset registry between tests."""
+def _clean_state():
+ """Reset module state between tests."""
clear_credential_files()
reset_config_cache()
yield
@@ -24,135 +28,172 @@ def _clean_registry():
reset_config_cache()
-class TestRegisterCredentialFile:
- def test_registers_existing_file(self, tmp_path, monkeypatch):
- monkeypatch.setenv("HERMES_HOME", str(tmp_path))
- (tmp_path / "token.json").write_text('{"token": "abc"}')
+class TestRegisterCredentialFiles:
+ def test_dict_with_path_key(self, tmp_path):
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ (hermes_home / "token.json").write_text("{}")
- result = register_credential_file("token.json")
+ with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
+ missing = register_credential_files([{"path": "token.json"}])
- assert result is True
+ assert missing == []
mounts = get_credential_file_mounts()
assert len(mounts) == 1
- assert mounts[0]["host_path"] == str(tmp_path / "token.json")
+ assert mounts[0]["host_path"] == str(hermes_home / "token.json")
assert mounts[0]["container_path"] == "/root/.hermes/token.json"
- def test_skips_missing_file(self, tmp_path, monkeypatch):
- monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ def test_dict_with_name_key_fallback(self, tmp_path):
+ """Skills use 'name' instead of 'path' — both should work."""
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ (hermes_home / "google_token.json").write_text("{}")
- result = register_credential_file("nonexistent.json")
-
- assert result is False
- assert get_credential_file_mounts() == []
-
- def test_custom_container_base(self, tmp_path, monkeypatch):
- monkeypatch.setenv("HERMES_HOME", str(tmp_path))
- (tmp_path / "cred.json").write_text("{}")
-
- register_credential_file("cred.json", container_base="/home/user/.hermes")
+ with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
+ missing = register_credential_files([
+ {"name": "google_token.json", "description": "OAuth token"},
+ ])
+ assert missing == []
mounts = get_credential_file_mounts()
- assert mounts[0]["container_path"] == "/home/user/.hermes/cred.json"
+ assert len(mounts) == 1
+ assert "google_token.json" in mounts[0]["container_path"]
- def test_deduplicates_by_container_path(self, tmp_path, monkeypatch):
- monkeypatch.setenv("HERMES_HOME", str(tmp_path))
- (tmp_path / "token.json").write_text("{}")
+ def test_string_entry(self, tmp_path):
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ (hermes_home / "secret.key").write_text("key")
- register_credential_file("token.json")
- register_credential_file("token.json")
+ with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
+ missing = register_credential_files(["secret.key"])
+ assert missing == []
mounts = get_credential_file_mounts()
assert len(mounts) == 1
+ def test_missing_file_reported(self, tmp_path):
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
-class TestRegisterCredentialFiles:
- def test_string_entries(self, tmp_path, monkeypatch):
- monkeypatch.setenv("HERMES_HOME", str(tmp_path))
- (tmp_path / "a.json").write_text("{}")
- (tmp_path / "b.json").write_text("{}")
+ with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
+ missing = register_credential_files([
+ {"name": "does_not_exist.json"},
+ ])
- missing = register_credential_files(["a.json", "b.json"])
-
- assert missing == []
- assert len(get_credential_file_mounts()) == 2
-
- def test_dict_entries(self, tmp_path, monkeypatch):
- monkeypatch.setenv("HERMES_HOME", str(tmp_path))
- (tmp_path / "token.json").write_text("{}")
-
- missing = register_credential_files([
- {"path": "token.json", "description": "OAuth token"},
- ])
-
- assert missing == []
- assert len(get_credential_file_mounts()) == 1
-
- def test_returns_missing_files(self, tmp_path, monkeypatch):
- monkeypatch.setenv("HERMES_HOME", str(tmp_path))
- (tmp_path / "exists.json").write_text("{}")
-
- missing = register_credential_files([
- "exists.json",
- "missing.json",
- {"path": "also_missing.json"},
- ])
-
- assert missing == ["missing.json", "also_missing.json"]
- assert len(get_credential_file_mounts()) == 1
-
- def test_empty_list(self, tmp_path, monkeypatch):
- monkeypatch.setenv("HERMES_HOME", str(tmp_path))
- assert register_credential_files([]) == []
-
-
-class TestConfigCredentialFiles:
- def test_loads_from_config(self, tmp_path, monkeypatch):
- monkeypatch.setenv("HERMES_HOME", str(tmp_path))
- (tmp_path / "oauth.json").write_text("{}")
- (tmp_path / "config.yaml").write_text(
- "terminal:\n credential_files:\n - oauth.json\n"
- )
-
- mounts = get_credential_file_mounts()
-
- assert len(mounts) == 1
- assert mounts[0]["host_path"] == str(tmp_path / "oauth.json")
-
- def test_config_skips_missing_files(self, tmp_path, monkeypatch):
- monkeypatch.setenv("HERMES_HOME", str(tmp_path))
- (tmp_path / "config.yaml").write_text(
- "terminal:\n credential_files:\n - nonexistent.json\n"
- )
-
- mounts = get_credential_file_mounts()
- assert mounts == []
-
- def test_combines_skill_and_config(self, tmp_path, monkeypatch):
- monkeypatch.setenv("HERMES_HOME", str(tmp_path))
- (tmp_path / "skill_token.json").write_text("{}")
- (tmp_path / "config_token.json").write_text("{}")
- (tmp_path / "config.yaml").write_text(
- "terminal:\n credential_files:\n - config_token.json\n"
- )
-
- register_credential_file("skill_token.json")
- mounts = get_credential_file_mounts()
-
- assert len(mounts) == 2
- paths = {m["container_path"] for m in mounts}
- assert "/root/.hermes/skill_token.json" in paths
- assert "/root/.hermes/config_token.json" in paths
-
-
-class TestGetMountsRechecksExistence:
- def test_removed_file_excluded_from_mounts(self, tmp_path, monkeypatch):
- monkeypatch.setenv("HERMES_HOME", str(tmp_path))
- token = tmp_path / "token.json"
- token.write_text("{}")
-
- register_credential_file("token.json")
- assert len(get_credential_file_mounts()) == 1
-
- # Delete the file after registration
- token.unlink()
+ assert "does_not_exist.json" in missing
assert get_credential_file_mounts() == []
+
+ def test_path_takes_precedence_over_name(self, tmp_path):
+ """When both path and name are present, path wins."""
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ (hermes_home / "real.json").write_text("{}")
+
+ with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
+ missing = register_credential_files([
+ {"path": "real.json", "name": "wrong.json"},
+ ])
+
+ assert missing == []
+ mounts = get_credential_file_mounts()
+ assert "real.json" in mounts[0]["container_path"]
+
+
+class TestSkillsDirectoryMount:
+ def test_returns_mount_when_skills_dir_exists(self, tmp_path):
+ hermes_home = tmp_path / ".hermes"
+ skills_dir = hermes_home / "skills"
+ skills_dir.mkdir(parents=True)
+ (skills_dir / "test-skill").mkdir()
+ (skills_dir / "test-skill" / "SKILL.md").write_text("# test")
+
+ with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
+ mount = get_skills_directory_mount()
+
+ assert mount is not None
+ assert mount["host_path"] == str(skills_dir)
+ assert mount["container_path"] == "/root/.hermes/skills"
+
+ def test_returns_none_when_no_skills_dir(self, tmp_path):
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+
+ with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
+ mount = get_skills_directory_mount()
+
+ assert mount is None
+
+ def test_custom_container_base(self, tmp_path):
+ hermes_home = tmp_path / ".hermes"
+ (hermes_home / "skills").mkdir(parents=True)
+
+ with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
+ mount = get_skills_directory_mount(container_base="/home/user/.hermes")
+
+ assert mount["container_path"] == "/home/user/.hermes/skills"
+
+ def test_symlinks_are_sanitized(self, tmp_path):
+ """Symlinks in skills dir should be excluded from the mount."""
+ hermes_home = tmp_path / ".hermes"
+ skills_dir = hermes_home / "skills"
+ skills_dir.mkdir(parents=True)
+ (skills_dir / "legit.md").write_text("# real skill")
+ # Create a symlink pointing outside the skills tree
+ secret = tmp_path / "secret.txt"
+ secret.write_text("TOP SECRET")
+ (skills_dir / "evil_link").symlink_to(secret)
+
+ with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
+ mount = get_skills_directory_mount()
+
+ assert mount is not None
+ # The mount path should be a sanitized copy, not the original
+ safe_path = Path(mount["host_path"])
+ assert safe_path != skills_dir
+ # Legitimate file should be present
+ assert (safe_path / "legit.md").exists()
+ assert (safe_path / "legit.md").read_text() == "# real skill"
+ # Symlink should NOT be present
+ assert not (safe_path / "evil_link").exists()
+
+ def test_no_symlinks_returns_original_dir(self, tmp_path):
+ """When no symlinks exist, the original dir is returned (no copy)."""
+ hermes_home = tmp_path / ".hermes"
+ skills_dir = hermes_home / "skills"
+ skills_dir.mkdir(parents=True)
+ (skills_dir / "skill.md").write_text("ok")
+
+ with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
+ mount = get_skills_directory_mount()
+
+ assert mount["host_path"] == str(skills_dir)
+
+
+class TestIterSkillsFiles:
+ def test_returns_files_skipping_symlinks(self, tmp_path):
+ hermes_home = tmp_path / ".hermes"
+ skills_dir = hermes_home / "skills"
+ (skills_dir / "cat" / "myskill").mkdir(parents=True)
+ (skills_dir / "cat" / "myskill" / "SKILL.md").write_text("# skill")
+ (skills_dir / "cat" / "myskill" / "scripts").mkdir()
+ (skills_dir / "cat" / "myskill" / "scripts" / "run.sh").write_text("#!/bin/bash")
+ # Add a symlink that should be filtered
+ secret = tmp_path / "secret"
+ secret.write_text("nope")
+ (skills_dir / "cat" / "myskill" / "evil").symlink_to(secret)
+
+ with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
+ files = iter_skills_files()
+
+ paths = {f["container_path"] for f in files}
+ assert "/root/.hermes/skills/cat/myskill/SKILL.md" in paths
+ assert "/root/.hermes/skills/cat/myskill/scripts/run.sh" in paths
+ # Symlink should be excluded
+ assert not any("evil" in f["container_path"] for f in files)
+
+ def test_empty_when_no_skills_dir(self, tmp_path):
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+
+ with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
+ assert iter_skills_files() == []
diff --git a/tests/tools/test_daytona_environment.py b/tests/tools/test_daytona_environment.py
index 94a28dc7f28f..04e6347955cf 100644
--- a/tests/tools/test_daytona_environment.py
+++ b/tests/tools/test_daytona_environment.py
@@ -61,6 +61,10 @@ def make_env(daytona_sdk, monkeypatch):
"""Factory that creates a DaytonaEnvironment with a mocked SDK."""
# Prevent is_interrupted from interfering
monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False)
+ # Prevent skills/credential sync from consuming mock exec calls
+ monkeypatch.setattr("tools.credential_files.get_credential_file_mounts", lambda: [])
+ monkeypatch.setattr("tools.credential_files.get_skills_directory_mount", lambda **kw: None)
+ monkeypatch.setattr("tools.credential_files.iter_skills_files", lambda **kw: [])
def _factory(
sandbox=None,
diff --git a/tests/tools/test_skill_manager_tool.py b/tests/tools/test_skill_manager_tool.py
index bd992ec3dd52..06a2f88aed03 100644
--- a/tests/tools/test_skill_manager_tool.py
+++ b/tests/tools/test_skill_manager_tool.py
@@ -6,6 +6,7 @@ from unittest.mock import patch
from tools.skill_manager_tool import (
_validate_name,
+ _validate_category,
_validate_frontmatter,
_validate_file_path,
_find_skill,
@@ -82,6 +83,22 @@ class TestValidateName:
assert "Invalid skill name 'skill@name'" in err
+class TestValidateCategory:
+ def test_valid_categories(self):
+ assert _validate_category(None) is None
+ assert _validate_category("") is None
+ assert _validate_category("devops") is None
+ assert _validate_category("mlops-v2") is None
+
+ def test_path_traversal_rejected(self):
+ err = _validate_category("../escape")
+ assert "Invalid category '../escape'" in err
+
+ def test_absolute_path_rejected(self):
+ err = _validate_category("/tmp/escape")
+ assert "Invalid category '/tmp/escape'" in err
+
+
# ---------------------------------------------------------------------------
# _validate_frontmatter
# ---------------------------------------------------------------------------
@@ -191,6 +208,29 @@ class TestCreateSkill:
result = _create_skill("my-skill", "no frontmatter here")
assert result["success"] is False
+ def test_create_rejects_category_traversal(self, tmp_path):
+ skills_dir = tmp_path / "skills"
+ skills_dir.mkdir()
+
+ with patch("tools.skill_manager_tool.SKILLS_DIR", skills_dir):
+ result = _create_skill("my-skill", VALID_SKILL_CONTENT, category="../escape")
+
+ assert result["success"] is False
+ assert "Invalid category '../escape'" in result["error"]
+ assert not (tmp_path / "escape").exists()
+
+ def test_create_rejects_absolute_category(self, tmp_path):
+ skills_dir = tmp_path / "skills"
+ skills_dir.mkdir()
+ outside = tmp_path / "outside"
+
+ with patch("tools.skill_manager_tool.SKILLS_DIR", skills_dir):
+ result = _create_skill("my-skill", VALID_SKILL_CONTENT, category=str(outside))
+
+ assert result["success"] is False
+ assert f"Invalid category '{outside}'" in result["error"]
+ assert not (outside / "my-skill" / "SKILL.md").exists()
+
class TestEditSkill:
def test_edit_existing_skill(self, tmp_path):
diff --git a/tests/tools/test_terminal_timeout_output.py b/tests/tools/test_terminal_timeout_output.py
new file mode 100644
index 000000000000..52823581fc82
--- /dev/null
+++ b/tests/tools/test_terminal_timeout_output.py
@@ -0,0 +1,27 @@
+"""Verify that terminal command timeouts preserve partial output."""
+from tools.environments.local import LocalEnvironment
+
+
+class TestTimeoutPreservesPartialOutput:
+ """When a command times out, any output captured before the deadline
+ should be included in the result — not discarded."""
+
+ def test_timeout_includes_partial_output(self):
+ """A command that prints then sleeps past the deadline should
+ return both the printed text and the timeout notice."""
+ env = LocalEnvironment()
+ result = env.execute("echo 'hello from test' && sleep 30", timeout=2)
+
+ assert result["returncode"] == 124
+ assert "hello from test" in result["output"]
+ assert "timed out" in result["output"].lower()
+
+ def test_timeout_with_no_output(self):
+ """A command that produces nothing before timeout should still
+ return a clean timeout message."""
+ env = LocalEnvironment()
+ result = env.execute("sleep 30", timeout=1)
+
+ assert result["returncode"] == 124
+ assert "timed out" in result["output"].lower()
+ assert not result["output"].startswith("\n")
diff --git a/tests/tools/test_vision_tools.py b/tests/tools/test_vision_tools.py
index 4f152cebd468..97ee57a11a12 100644
--- a/tests/tools/test_vision_tools.py
+++ b/tests/tools/test_vision_tools.py
@@ -354,6 +354,78 @@ class TestErrorLoggingExcInfo:
assert warning_records[0].exc_info is not None
+class TestVisionSafetyGuards:
+ @pytest.mark.asyncio
+ async def test_local_non_image_file_rejected_before_llm_call(self, tmp_path):
+ secret = tmp_path / "secret.txt"
+ secret.write_text("TOP-SECRET=1\n", encoding="utf-8")
+
+ with patch("tools.vision_tools.async_call_llm", new_callable=AsyncMock) as mock_llm:
+ result = json.loads(await vision_analyze_tool(str(secret), "extract text"))
+
+ assert result["success"] is False
+ assert "Only real image files are supported" in result["error"]
+ mock_llm.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_blocked_remote_url_short_circuits_before_download(self):
+ blocked = {
+ "host": "blocked.test",
+ "rule": "blocked.test",
+ "source": "config",
+ "message": "Blocked by website policy",
+ }
+
+ with (
+ patch("tools.vision_tools.check_website_access", return_value=blocked),
+ patch("tools.vision_tools._validate_image_url", return_value=True),
+ patch("tools.vision_tools._download_image", new_callable=AsyncMock) as mock_download,
+ ):
+ result = json.loads(await vision_analyze_tool("https://blocked.test/cat.png", "describe"))
+
+ assert result["success"] is False
+ assert "Blocked by website policy" in result["error"]
+ mock_download.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_download_blocks_redirected_final_url(self, tmp_path):
+ from tools.vision_tools import _download_image
+
+ def fake_check(url):
+ if url == "https://allowed.test/cat.png":
+ return None
+ if url == "https://blocked.test/final.png":
+ return {
+ "host": "blocked.test",
+ "rule": "blocked.test",
+ "source": "config",
+ "message": "Blocked by website policy",
+ }
+ raise AssertionError(f"unexpected URL checked: {url}")
+
+ class FakeResponse:
+ url = "https://blocked.test/final.png"
+ content = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
+
+ def raise_for_status(self):
+ return None
+
+ with (
+ patch("tools.vision_tools.check_website_access", side_effect=fake_check),
+ patch("tools.vision_tools.httpx.AsyncClient") as mock_client_cls,
+ pytest.raises(PermissionError, match="Blocked by website policy"),
+ ):
+ mock_client = AsyncMock()
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=False)
+ mock_client.get = AsyncMock(return_value=FakeResponse())
+ mock_client_cls.return_value = mock_client
+
+ await _download_image("https://allowed.test/cat.png", tmp_path / "cat.png", max_retries=1)
+
+ assert not (tmp_path / "cat.png").exists()
+
+
# ---------------------------------------------------------------------------
# check_vision_requirements & get_debug_session_info
# ---------------------------------------------------------------------------
diff --git a/tools/approval.py b/tools/approval.py
index ee74b113475f..95011173fdc4 100644
--- a/tools/approval.py
+++ b/tools/approval.py
@@ -18,6 +18,21 @@ from typing import Optional
logger = logging.getLogger(__name__)
+# Sensitive write targets that should trigger approval even when referenced
+# via shell expansions like $HOME or $HERMES_HOME.
+_SSH_SENSITIVE_PATH = r'(?:~|\$home|\$\{home\})/\.ssh(?:/|$)'
+_HERMES_ENV_PATH = (
+ r'(?:~\/\.hermes/|'
+ r'(?:\$home|\$\{home\})/\.hermes/|'
+ r'(?:\$hermes_home|\$\{hermes_home\})/)'
+ r'\.env\b'
+)
+_SENSITIVE_WRITE_TARGET = (
+ r'(?:/etc/|/dev/sd|'
+ rf'{_SSH_SENSITIVE_PATH}|'
+ rf'{_HERMES_ENV_PATH})'
+)
+
# =========================================================================
# Dangerous command patterns
# =========================================================================
@@ -26,8 +41,8 @@ DANGEROUS_PATTERNS = [
(r'\brm\s+(-[^\s]*\s+)*/', "delete in root path"),
(r'\brm\s+-[^\s]*r', "recursive delete"),
(r'\brm\s+--recursive\b', "recursive delete (long flag)"),
- (r'\bchmod\s+(-[^\s]*\s+)*777\b', "world-writable permissions"),
- (r'\bchmod\s+--recursive\b.*777', "recursive world-writable (long flag)"),
+ (r'\bchmod\s+(-[^\s]*\s+)*(777|666|o\+[rwx]*w|a\+[rwx]*w)\b', "world/other-writable permissions"),
+ (r'\bchmod\s+--recursive\b.*(777|666|o\+[rwx]*w|a\+[rwx]*w)', "recursive world/other-writable (long flag)"),
(r'\bchown\s+(-[^\s]*)?R\s+root', "recursive chown to root"),
(r'\bchown\s+--recursive\b.*root', "recursive chown to root (long flag)"),
(r'\bmkfs\b', "format filesystem"),
@@ -46,7 +61,8 @@ DANGEROUS_PATTERNS = [
(r'\b(python[23]?|perl|ruby|node)\s+-[ec]\s+', "script execution via -e/-c flag"),
(r'\b(curl|wget)\b.*\|\s*(ba)?sh\b', "pipe remote content to shell"),
(r'\b(bash|sh|zsh|ksh)\s+<\s*\s*\(\s*(curl|wget)\b', "execute remote script via process substitution"),
- (r'\btee\b.*(/etc/|/dev/sd|\.ssh/|\.hermes/\.env)', "overwrite system file via tee"),
+ (rf'\btee\b.*["\']?{_SENSITIVE_WRITE_TARGET}', "overwrite system file via tee"),
+ (rf'>>?\s*["\']?{_SENSITIVE_WRITE_TARGET}', "overwrite system file via redirection"),
(r'\bxargs\s+.*\brm\b', "xargs with rm"),
(r'\bfind\b.*-exec\s+(/\S*/)?rm\b', "find -exec rm"),
(r'\bfind\b.*-delete\b', "find -delete"),
@@ -55,6 +71,10 @@ DANGEROUS_PATTERNS = [
(r'\bnohup\b.*gateway\s+run\b', "start gateway outside systemd (use 'systemctl --user restart hermes-gateway')"),
# Self-termination protection: prevent agent from killing its own process
(r'\b(pkill|killall)\b.*\b(hermes|gateway|cli\.py)\b', "kill hermes/gateway process (self-termination)"),
+ # File copy/move/edit into sensitive system paths
+ (r'\b(cp|mv|install)\b.*\s/etc/', "copy/move file into /etc/"),
+ (r'\bsed\s+-[^\s]*i.*\s/etc/', "in-place edit of system config"),
+ (r'\bsed\s+--in-place\b.*\s/etc/', "in-place edit of system config (long flag)"),
]
@@ -221,7 +241,7 @@ def save_permanent_allowlist(patterns: set):
# =========================================================================
def prompt_dangerous_approval(command: str, description: str,
- timeout_seconds: int = 60,
+ timeout_seconds: int | None = None,
allow_permanent: bool = True,
approval_callback=None) -> str:
"""Prompt the user to approve a dangerous command (CLI only).
@@ -236,6 +256,9 @@ def prompt_dangerous_approval(command: str, description: str,
Returns: 'once', 'session', 'always', or 'deny'
"""
+ if timeout_seconds is None:
+ timeout_seconds = _get_approval_timeout()
+
if approval_callback is not None:
try:
return approval_callback(command, description,
@@ -316,15 +339,28 @@ def _normalize_approval_mode(mode) -> str:
return "manual"
-def _get_approval_mode() -> str:
- """Read the approval mode from config. Returns 'manual', 'smart', or 'off'."""
+def _get_approval_config() -> dict:
+ """Read the approvals config block. Returns a dict with 'mode', 'timeout', etc."""
try:
from hermes_cli.config import load_config
config = load_config()
- mode = config.get("approvals", {}).get("mode", "manual")
- return _normalize_approval_mode(mode)
+ return config.get("approvals", {}) or {}
except Exception:
- return "manual"
+ return {}
+
+
+def _get_approval_mode() -> str:
+ """Read the approval mode from config. Returns 'manual', 'smart', or 'off'."""
+ mode = _get_approval_config().get("mode", "manual")
+ return _normalize_approval_mode(mode)
+
+
+def _get_approval_timeout() -> int:
+ """Read the approval timeout from config. Defaults to 60 seconds."""
+ try:
+ return int(_get_approval_config().get("timeout", 60))
+ except (ValueError, TypeError):
+ return 60
def _smart_approve(command: str, description: str) -> str:
diff --git a/tools/credential_files.py b/tools/credential_files.py
index 56c32d572a15..53ddd79d5422 100644
--- a/tools/credential_files.py
+++ b/tools/credential_files.py
@@ -83,7 +83,7 @@ def register_credential_files(
if isinstance(entry, str):
rel_path = entry.strip()
elif isinstance(entry, dict):
- rel_path = (entry.get("path") or "").strip()
+ rel_path = (entry.get("path") or entry.get("name") or "").strip()
else:
continue
if not rel_path:
@@ -152,6 +152,107 @@ def get_credential_file_mounts() -> List[Dict[str, str]]:
]
+def get_skills_directory_mount(
+ container_base: str = "/root/.hermes",
+) -> Dict[str, str] | None:
+ """Return mount info for a symlink-safe copy of the skills directory.
+
+ Skills may include ``scripts/``, ``templates/``, and ``references/``
+ subdirectories that the agent needs to execute inside remote sandboxes.
+
+ **Security:** Bind mounts follow symlinks, so a malicious symlink inside
+ the skills tree could expose arbitrary host files to the container. When
+ symlinks are detected, this function creates a sanitized copy (regular
+ files only) in a temp directory and returns that path instead. When no
+ symlinks are present (the common case), the original directory is returned
+ directly with zero overhead.
+
+ Returns a dict with ``host_path`` and ``container_path`` keys, or None.
+ """
+ hermes_home = _resolve_hermes_home()
+ skills_dir = hermes_home / "skills"
+ if not skills_dir.is_dir():
+ return None
+
+ host_path = _safe_skills_path(skills_dir)
+ return {
+ "host_path": host_path,
+ "container_path": f"{container_base.rstrip('/')}/skills",
+ }
+
+
+_safe_skills_tempdir: Path | None = None
+
+
+def _safe_skills_path(skills_dir: Path) -> str:
+ """Return *skills_dir* if symlink-free, else a sanitized temp copy."""
+ global _safe_skills_tempdir
+
+ symlinks = [p for p in skills_dir.rglob("*") if p.is_symlink()]
+ if not symlinks:
+ return str(skills_dir)
+
+ for link in symlinks:
+ logger.warning("credential_files: skipping symlink in skills dir: %s -> %s",
+ link, os.readlink(link))
+
+ import atexit
+ import shutil
+ import tempfile
+
+ # Reuse the same temp dir across calls to avoid accumulation.
+ if _safe_skills_tempdir and _safe_skills_tempdir.is_dir():
+ shutil.rmtree(_safe_skills_tempdir, ignore_errors=True)
+
+ safe_dir = Path(tempfile.mkdtemp(prefix="hermes-skills-safe-"))
+ _safe_skills_tempdir = safe_dir
+
+ for item in skills_dir.rglob("*"):
+ if item.is_symlink():
+ continue
+ rel = item.relative_to(skills_dir)
+ target = safe_dir / rel
+ if item.is_dir():
+ target.mkdir(parents=True, exist_ok=True)
+ elif item.is_file():
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(str(item), str(target))
+
+ def _cleanup():
+ if safe_dir.is_dir():
+ shutil.rmtree(safe_dir, ignore_errors=True)
+
+ atexit.register(_cleanup)
+ logger.info("credential_files: created symlink-safe skills copy at %s", safe_dir)
+ return str(safe_dir)
+
+
+def iter_skills_files(
+ container_base: str = "/root/.hermes",
+) -> List[Dict[str, str]]:
+ """Yield individual (host_path, container_path) entries for skills files.
+
+ Skips symlinks entirely. Preferred for backends that upload files
+ individually (Daytona, Modal) rather than mounting a directory.
+ """
+ hermes_home = _resolve_hermes_home()
+ skills_dir = hermes_home / "skills"
+ if not skills_dir.is_dir():
+ return []
+
+ container_root = f"{container_base.rstrip('/')}/skills"
+ result: List[Dict[str, str]] = []
+ for item in skills_dir.rglob("*"):
+ if item.is_symlink() or not item.is_file():
+ continue
+ rel = item.relative_to(skills_dir)
+ result.append({
+ "host_path": str(item),
+ "container_path": f"{container_root}/{rel}",
+ })
+ return result
+
+
def clear_credential_files() -> None:
"""Reset the skill-scoped registry (e.g. on session reset)."""
_registered_files.clear()
diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py
index 0a023c904ff0..84054c6e245a 100644
--- a/tools/cronjob_tools.py
+++ b/tools/cronjob_tools.py
@@ -372,7 +372,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
},
"deliver": {
"type": "string",
- "description": "Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, matrix, mattermost, homeassistant, dingtalk, email, sms, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'"
+ "description": "Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, matrix, mattermost, homeassistant, dingtalk, feishu, wecom, email, sms, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'"
},
"model": {
"type": "string",
diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py
index f974ee8ffc35..b5b0a57c4be5 100644
--- a/tools/delegate_tool.py
+++ b/tools/delegate_tool.py
@@ -289,7 +289,10 @@ def _run_single_child(
if interrupted:
status = "interrupted"
- elif completed and summary:
+ elif summary:
+ # A summary means the subagent produced usable output.
+ # exit_reason ("completed" vs "max_iterations") already
+ # tells the parent *how* the task ended.
status = "completed"
else:
status = "failed"
diff --git a/tools/environments/daytona.py b/tools/environments/daytona.py
index cc046bb4d3fe..eb2a67311068 100644
--- a/tools/environments/daytona.py
+++ b/tools/environments/daytona.py
@@ -113,15 +113,61 @@ class DaytonaEnvironment(BaseEnvironment):
logger.info("Daytona: created sandbox %s for task %s",
self._sandbox.id, task_id)
- # Resolve cwd: detect actual home dir inside the sandbox
- if self._requested_cwd in ("~", "/home/daytona"):
- try:
- home = self._sandbox.process.exec("echo $HOME").result.strip()
- if home:
+ # Detect remote home dir first so mounts go to the right place.
+ self._remote_home = "/root"
+ try:
+ home = self._sandbox.process.exec("echo $HOME").result.strip()
+ if home:
+ self._remote_home = home
+ if self._requested_cwd in ("~", "/home/daytona"):
self.cwd = home
- except Exception:
- pass # leave cwd as-is; sandbox will use its own default
- logger.info("Daytona: resolved cwd to %s", self.cwd)
+ except Exception:
+ pass
+ logger.info("Daytona: resolved home to %s, cwd to %s", self._remote_home, self.cwd)
+
+ # Track synced files to avoid redundant uploads.
+ # Key: remote_path, Value: (mtime, size)
+ self._synced_files: Dict[str, tuple] = {}
+
+ # Upload credential files and skills directory into the sandbox.
+ self._sync_skills_and_credentials()
+
+ def _upload_if_changed(self, host_path: str, remote_path: str) -> bool:
+ """Upload a file if its mtime/size changed since last sync."""
+ hp = Path(host_path)
+ try:
+ stat = hp.stat()
+ file_key = (stat.st_mtime, stat.st_size)
+ except OSError:
+ return False
+ if self._synced_files.get(remote_path) == file_key:
+ return False
+ try:
+ parent = str(Path(remote_path).parent)
+ self._sandbox.process.exec(f"mkdir -p {parent}")
+ self._sandbox.fs.upload_file(host_path, remote_path)
+ self._synced_files[remote_path] = file_key
+ return True
+ except Exception as e:
+ logger.debug("Daytona: upload failed %s: %s", host_path, e)
+ return False
+
+ def _sync_skills_and_credentials(self) -> None:
+ """Upload changed credential files and skill files into the sandbox."""
+ container_base = f"{self._remote_home}/.hermes"
+ try:
+ from tools.credential_files import get_credential_file_mounts, iter_skills_files
+
+ for mount_entry in get_credential_file_mounts():
+ remote_path = mount_entry["container_path"].replace("/root/.hermes", container_base, 1)
+ if self._upload_if_changed(mount_entry["host_path"], remote_path):
+ logger.debug("Daytona: synced credential %s", remote_path)
+
+ for entry in iter_skills_files(container_base=container_base):
+ if self._upload_if_changed(entry["host_path"], entry["container_path"]):
+ logger.debug("Daytona: synced skill %s", entry["container_path"])
+ except Exception as e:
+ logger.debug("Daytona: could not sync skills/credentials: %s", e)
def _ensure_sandbox_ready(self):
"""Restart sandbox if it was stopped (e.g., by a previous interrupt)."""
@@ -191,6 +237,9 @@ class DaytonaEnvironment(BaseEnvironment):
stdin_data: Optional[str] = None) -> dict:
with self._lock:
self._ensure_sandbox_ready()
+ # Incremental sync before each command so mid-session credential
+ # refreshes and skill updates are picked up.
+ self._sync_skills_and_credentials()
if stdin_data is not None:
marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}"
diff --git a/tools/environments/docker.py b/tools/environments/docker.py
index a24786d17eb3..2a7bb625512f 100644
--- a/tools/environments/docker.py
+++ b/tools/environments/docker.py
@@ -315,7 +315,7 @@ class DockerEnvironment(BaseEnvironment):
# Mount credential files (OAuth tokens, etc.) declared by skills.
# Read-only so the container can authenticate but not modify host creds.
try:
- from tools.credential_files import get_credential_file_mounts
+ from tools.credential_files import get_credential_file_mounts, get_skills_directory_mount
for mount_entry in get_credential_file_mounts():
volume_args.extend([
@@ -327,6 +327,20 @@ class DockerEnvironment(BaseEnvironment):
mount_entry["host_path"],
mount_entry["container_path"],
)
+
+ # Mount the skills directory so skill scripts/templates are
+ # available inside the container at the same relative path.
+ skills_mount = get_skills_directory_mount()
+ if skills_mount:
+ volume_args.extend([
+ "-v",
+ f"{skills_mount['host_path']}:{skills_mount['container_path']}:ro",
+ ])
+ logger.info(
+ "Docker: mounting skills dir %s -> %s",
+ skills_mount["host_path"],
+ skills_mount["container_path"],
+ )
except Exception as e:
logger.debug("Docker: could not load credential file mounts: %s", e)
diff --git a/tools/environments/local.py b/tools/environments/local.py
index 8cd416efaa80..27282b6ef67a 100644
--- a/tools/environments/local.py
+++ b/tools/environments/local.py
@@ -473,7 +473,12 @@ class LocalEnvironment(PersistentShellMixin, BaseEnvironment):
except (ProcessLookupError, PermissionError):
proc.kill()
reader.join(timeout=2)
- return self._timeout_result(effective_timeout)
+ partial = "".join(_output_chunks)
+ timeout_msg = f"\n[Command timed out after {effective_timeout}s]"
+ return {
+ "output": partial + timeout_msg if partial else timeout_msg.lstrip(),
+ "returncode": 124,
+ }
time.sleep(0.2)
reader.join(timeout=5)
diff --git a/tools/environments/modal.py b/tools/environments/modal.py
index 2842bed1149c..89e8f4776a37 100644
--- a/tools/environments/modal.py
+++ b/tools/environments/modal.py
@@ -142,7 +142,7 @@ class ModalEnvironment(BaseEnvironment):
# external services but can't modify the host's credentials.
cred_mounts = []
try:
- from tools.credential_files import get_credential_file_mounts
+ from tools.credential_files import get_credential_file_mounts, iter_skills_files
for mount_entry in get_credential_file_mounts():
cred_mounts.append(
@@ -156,6 +156,18 @@ class ModalEnvironment(BaseEnvironment):
mount_entry["host_path"],
mount_entry["container_path"],
)
+
+ # Mount individual skill files (symlinks filtered out).
+ skills_files = iter_skills_files()
+ for entry in skills_files:
+ cred_mounts.append(
+ _modal.Mount.from_local_file(
+ entry["host_path"],
+ remote_path=entry["container_path"],
+ )
+ )
+ if skills_files:
+ logger.info("Modal: mounting %d skill files", len(skills_files))
except Exception as e:
logger.debug("Modal: could not load credential file mounts: %s", e)
@@ -184,72 +196,69 @@ class ModalEnvironment(BaseEnvironment):
self._app, self._sandbox = self._worker.run_coroutine(
_create_sandbox(), timeout=300
)
- # Track synced credential files to avoid redundant pushes.
+ # Track synced files to avoid redundant pushes.
# Key: container_path, Value: (mtime, size) of last synced version.
- self._synced_creds: Dict[str, tuple] = {}
+ self._synced_files: Dict[str, tuple] = {}
logger.info("Modal: sandbox created (task=%s)", self._task_id)
- def _sync_credential_files(self) -> None:
- """Push credential files into the running sandbox.
+ def _push_file_to_sandbox(self, host_path: str, container_path: str) -> bool:
+ """Push a single file into the sandbox if changed. Returns True if synced."""
+ hp = Path(host_path)
+ try:
+ stat = hp.stat()
+ file_key = (stat.st_mtime, stat.st_size)
+ except OSError:
+ return False
- Mounts are set at sandbox creation, but credentials may be created
- later (e.g. OAuth setup mid-session). This writes the current file
- content into the sandbox via exec(), so new/updated credentials are
- available without recreating the sandbox.
+ if self._synced_files.get(container_path) == file_key:
+ return False
+
+ try:
+ content = hp.read_bytes()
+ except Exception:
+ return False
+
+ import base64
+ b64 = base64.b64encode(content).decode("ascii")
+ container_dir = str(Path(container_path).parent)
+ cmd = (
+ f"mkdir -p {shlex.quote(container_dir)} && "
+ f"echo {shlex.quote(b64)} | base64 -d > {shlex.quote(container_path)}"
+ )
+
+ async def _write():
+ proc = await self._sandbox.exec.aio("bash", "-c", cmd)
+ await proc.wait.aio()
+
+ self._worker.run_coroutine(_write(), timeout=15)
+ self._synced_files[container_path] = file_key
+ return True
+
+ def _sync_files(self) -> None:
+ """Push credential files and skill files into the running sandbox.
+
+ Runs before each command. Uses mtime+size caching so only changed
+ files are pushed (~13μs overhead in the no-op case).
"""
try:
- from tools.credential_files import get_credential_file_mounts
+ from tools.credential_files import get_credential_file_mounts, iter_skills_files
- mounts = get_credential_file_mounts()
- if not mounts:
- return
+ for entry in get_credential_file_mounts():
+ if self._push_file_to_sandbox(entry["host_path"], entry["container_path"]):
+ logger.debug("Modal: synced credential %s", entry["container_path"])
- for entry in mounts:
- host_path = entry["host_path"]
- container_path = entry["container_path"]
- hp = Path(host_path)
- try:
- stat = hp.stat()
- file_key = (stat.st_mtime, stat.st_size)
- except OSError:
- continue
-
- # Skip if already synced with same mtime+size
- if self._synced_creds.get(container_path) == file_key:
- continue
-
- try:
- content = hp.read_text(encoding="utf-8")
- except Exception:
- continue
-
- # Write via base64 to avoid shell escaping issues with JSON
- import base64
- b64 = base64.b64encode(content.encode("utf-8")).decode("ascii")
- container_dir = str(Path(container_path).parent)
- cmd = (
- f"mkdir -p {shlex.quote(container_dir)} && "
- f"echo {shlex.quote(b64)} | base64 -d > {shlex.quote(container_path)}"
- )
-
- _cp = container_path # capture for closure
-
- async def _write():
- proc = await self._sandbox.exec.aio("bash", "-c", cmd)
- await proc.wait.aio()
-
- self._worker.run_coroutine(_write(), timeout=15)
- self._synced_creds[container_path] = file_key
- logger.debug("Modal: synced credential %s -> %s", host_path, container_path)
+ for entry in iter_skills_files():
+ if self._push_file_to_sandbox(entry["host_path"], entry["container_path"]):
+ logger.debug("Modal: synced skill file %s", entry["container_path"])
except Exception as e:
- logger.debug("Modal: credential file sync failed: %s", e)
+ logger.debug("Modal: file sync failed: %s", e)
def execute(self, command: str, cwd: str = "", *,
timeout: int | None = None,
stdin_data: str | None = None) -> dict:
# Sync credential files before each command so mid-session
# OAuth setups are picked up without requiring a restart.
- self._sync_credential_files()
+ self._sync_files()
if stdin_data is not None:
marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}"
diff --git a/tools/environments/singularity.py b/tools/environments/singularity.py
index 72afbac5fdef..381ac2b2d629 100644
--- a/tools/environments/singularity.py
+++ b/tools/environments/singularity.py
@@ -254,6 +254,28 @@ class SingularityEnvironment(BaseEnvironment):
else:
cmd.append("--writable-tmpfs")
+ # Mount credential files and skills directory (read-only).
+ try:
+ from tools.credential_files import get_credential_file_mounts, get_skills_directory_mount
+
+ for mount_entry in get_credential_file_mounts():
+ cmd.extend(["--bind", f"{mount_entry['host_path']}:{mount_entry['container_path']}:ro"])
+ logger.info(
+ "Singularity: binding credential %s -> %s",
+ mount_entry["host_path"],
+ mount_entry["container_path"],
+ )
+ skills_mount = get_skills_directory_mount()
+ if skills_mount:
+ cmd.extend(["--bind", f"{skills_mount['host_path']}:{skills_mount['container_path']}:ro"])
+ logger.info(
+ "Singularity: binding skills dir %s -> %s",
+ skills_mount["host_path"],
+ skills_mount["container_path"],
+ )
+ except Exception as e:
+ logger.debug("Singularity: could not load credential/skills mounts: %s", e)
+
# Resource limits (cgroup-based, may require root or appropriate config)
if self._memory > 0:
cmd.extend(["--memory", f"{self._memory}M"])
diff --git a/tools/environments/ssh.py b/tools/environments/ssh.py
index fa3781a9900b..94b0a6b3f083 100644
--- a/tools/environments/ssh.py
+++ b/tools/environments/ssh.py
@@ -55,6 +55,8 @@ class SSHEnvironment(PersistentShellMixin, BaseEnvironment):
self.control_socket = self.control_dir / f"{user}@{host}:{port}.sock"
_ensure_ssh_available()
self._establish_connection()
+ self._remote_home = self._detect_remote_home()
+ self._sync_skills_and_credentials()
if self.persistent:
self._init_persistent_shell()
@@ -87,6 +89,79 @@ class SSHEnvironment(PersistentShellMixin, BaseEnvironment):
except subprocess.TimeoutExpired:
raise RuntimeError(f"SSH connection to {self.user}@{self.host} timed out")
+ def _detect_remote_home(self) -> str:
+ """Detect the remote user's home directory."""
+ try:
+ cmd = self._build_ssh_command()
+ cmd.append("echo $HOME")
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
+ home = result.stdout.strip()
+ if home and result.returncode == 0:
+ logger.debug("SSH: remote home = %s", home)
+ return home
+ except Exception:
+ pass
+ # Fallback: guess from username
+ if self.user == "root":
+ return "/root"
+ return f"/home/{self.user}"
+
+ def _sync_skills_and_credentials(self) -> None:
+ """Rsync skills directory and credential files to the remote host."""
+ try:
+ container_base = f"{self._remote_home}/.hermes"
+ from tools.credential_files import get_credential_file_mounts, get_skills_directory_mount
+
+ rsync_base = ["rsync", "-az", "--timeout=30", "--safe-links"]
+ ssh_opts = f"ssh -o ControlPath={self.control_socket} -o ControlMaster=auto"
+ if self.port != 22:
+ ssh_opts += f" -p {self.port}"
+ if self.key_path:
+ ssh_opts += f" -i {self.key_path}"
+ rsync_base.extend(["-e", ssh_opts])
+ dest_prefix = f"{self.user}@{self.host}"
+
+ # Sync individual credential files (remap /root/.hermes to detected home)
+ for mount_entry in get_credential_file_mounts():
+ remote_path = mount_entry["container_path"].replace("/root/.hermes", container_base, 1)
+ parent_dir = str(Path(remote_path).parent)
+ mkdir_cmd = self._build_ssh_command()
+ mkdir_cmd.append(f"mkdir -p {parent_dir}")
+ subprocess.run(mkdir_cmd, capture_output=True, text=True, timeout=10)
+ cmd = rsync_base + [mount_entry["host_path"], f"{dest_prefix}:{remote_path}"]
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
+ if result.returncode == 0:
+ logger.info("SSH: synced credential %s -> %s", mount_entry["host_path"], remote_path)
+ else:
+ logger.debug("SSH: rsync credential failed: %s", result.stderr.strip())
+
+ # Sync skills directory (remap to detected home)
+ skills_mount = get_skills_directory_mount(container_base=container_base)
+ if skills_mount:
+ remote_path = skills_mount["container_path"]
+ mkdir_cmd = self._build_ssh_command()
+ mkdir_cmd.append(f"mkdir -p {remote_path}")
+ subprocess.run(mkdir_cmd, capture_output=True, text=True, timeout=10)
+ cmd = rsync_base + [
+ skills_mount["host_path"].rstrip("/") + "/",
+ f"{dest_prefix}:{remote_path}/",
+ ]
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
+ if result.returncode == 0:
+ logger.info("SSH: synced skills dir %s -> %s", skills_mount["host_path"], remote_path)
+ else:
+ logger.debug("SSH: rsync skills dir failed: %s", result.stderr.strip())
+ except Exception as e:
+ logger.debug("SSH: could not sync skills/credentials: %s", e)
+
+ def execute(self, command: str, cwd: str = "", *,
+ timeout: int | None = None,
+ stdin_data: str | None = None) -> dict:
+ # Incremental sync before each command so mid-session credential
+ # refreshes and skill updates are picked up.
+ self._sync_skills_and_credentials()
+ return super().execute(command, cwd, timeout=timeout, stdin_data=stdin_data)
+
_poll_interval_start: float = 0.15 # SSH: higher initial interval (150ms) for network latency
@property
diff --git a/tools/file_operations.py b/tools/file_operations.py
index ebd4d601d340..96bdc2d535e6 100644
--- a/tools/file_operations.py
+++ b/tools/file_operations.py
@@ -32,6 +32,7 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional, List, Dict, Any
from pathlib import Path
+from hermes_constants import get_hermes_home
# ---------------------------------------------------------------------------
@@ -46,7 +47,7 @@ WRITE_DENIED_PATHS = {
os.path.join(_HOME, ".ssh", "id_rsa"),
os.path.join(_HOME, ".ssh", "id_ed25519"),
os.path.join(_HOME, ".ssh", "config"),
- os.path.join(_HOME, ".hermes", ".env"),
+ str(get_hermes_home() / ".env"),
os.path.join(_HOME, ".bashrc"),
os.path.join(_HOME, ".zshrc"),
os.path.join(_HOME, ".profile"),
diff --git a/tools/file_tools.py b/tools/file_tools.py
index 7387c4dcbc67..6226e76574f0 100644
--- a/tools/file_tools.py
+++ b/tools/file_tools.py
@@ -4,7 +4,9 @@
import errno
import json
import logging
+import os
import threading
+from pathlib import Path
from tools.file_operations import ShellFileOperations
from agent.redact import redact_sensitive_text
@@ -13,6 +15,31 @@ logger = logging.getLogger(__name__)
_EXPECTED_WRITE_ERRNOS = {errno.EACCES, errno.EPERM, errno.EROFS}
+# Paths that file tools should refuse to write to without going through the
+# terminal tool's approval system. These match prefixes after os.path.realpath.
+_SENSITIVE_PATH_PREFIXES = ("/etc/", "/boot/", "/usr/lib/systemd/")
+_SENSITIVE_EXACT_PATHS = {"/var/run/docker.sock", "/run/docker.sock"}
+
+
+def _check_sensitive_path(filepath: str) -> str | None:
+ """Return an error message if the path targets a sensitive system location."""
+ try:
+ resolved = os.path.realpath(os.path.expanduser(filepath))
+ except (OSError, ValueError):
+ resolved = filepath
+ for prefix in _SENSITIVE_PATH_PREFIXES:
+ if resolved.startswith(prefix):
+ return (
+ f"Refusing to write to sensitive system path: {filepath}\n"
+ "Use the terminal tool with sudo if you need to modify system files."
+ )
+ if resolved in _SENSITIVE_EXACT_PATHS:
+ return (
+ f"Refusing to write to sensitive system path: {filepath}\n"
+ "Use the terminal tool with sudo if you need to modify system files."
+ )
+ return None
+
def _is_expected_write_exception(exc: Exception) -> bool:
"""Return True for expected write denials that should not hit error logs."""
@@ -287,6 +314,9 @@ def notify_other_tool_call(task_id: str = "default"):
def write_file_tool(path: str, content: str, task_id: str = "default") -> str:
"""Write content to a file."""
+ sensitive_err = _check_sensitive_path(path)
+ if sensitive_err:
+ return json.dumps({"error": sensitive_err}, ensure_ascii=False)
try:
file_ops = _get_file_ops(task_id)
result = file_ops.write_file(path, content)
@@ -303,6 +333,18 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None,
new_string: str = None, replace_all: bool = False, patch: str = None,
task_id: str = "default") -> str:
"""Patch a file using replace mode or V4A patch format."""
+ # Check sensitive paths for both replace (explicit path) and V4A patch (extract paths)
+ _paths_to_check = []
+ if path:
+ _paths_to_check.append(path)
+ if mode == "patch" and patch:
+ import re as _re
+ for _m in _re.finditer(r'^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s*(.+)$', patch, _re.MULTILINE):
+ _paths_to_check.append(_m.group(1).strip())
+ for _p in _paths_to_check:
+ sensitive_err = _check_sensitive_path(_p)
+ if sensitive_err:
+ return json.dumps({"error": sensitive_err}, ensure_ascii=False)
try:
file_ops = _get_file_ops(task_id)
diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py
index 67a95547caf0..d12eed5099cb 100644
--- a/tools/send_message_tool.py
+++ b/tools/send_message_tool.py
@@ -15,6 +15,7 @@ import time
logger = logging.getLogger(__name__)
_TELEGRAM_TOPIC_TARGET_RE = re.compile(r"^\s*(-?\d+)(?::(\d+))?\s*$")
+_FEISHU_TARGET_RE = re.compile(r"^\s*((?:oc|ou|on|chat|open)_[-A-Za-z0-9]+)(?::([-A-Za-z0-9_]+))?\s*$")
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"}
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a"}
@@ -128,6 +129,8 @@ def _handle_send(args):
"mattermost": Platform.MATTERMOST,
"homeassistant": Platform.HOMEASSISTANT,
"dingtalk": Platform.DINGTALK,
+ "feishu": Platform.FEISHU,
+ "wecom": Platform.WECOM,
"email": Platform.EMAIL,
"sms": Platform.SMS,
}
@@ -198,6 +201,10 @@ def _parse_target_ref(platform_name: str, target_ref: str):
match = _TELEGRAM_TOPIC_TARGET_RE.fullmatch(target_ref)
if match:
return match.group(1), match.group(2), True
+ if platform_name == "feishu":
+ match = _FEISHU_TARGET_RE.fullmatch(target_ref)
+ if match:
+ return match.group(1), match.group(2), True
if target_ref.lstrip("-").isdigit():
return target_ref, None, True
return None, None, False
@@ -280,6 +287,13 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
from gateway.platforms.discord import DiscordAdapter
from gateway.platforms.slack import SlackAdapter
+ # Feishu adapter import is optional (requires lark-oapi)
+ try:
+ from gateway.platforms.feishu import FeishuAdapter
+ _feishu_available = True
+ except ImportError:
+ _feishu_available = False
+
media_files = media_files or []
# Platform message length limits (from adapter class attributes)
@@ -288,6 +302,8 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH,
Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH,
}
+ if _feishu_available:
+ _MAX_LENGTHS[Platform.FEISHU] = FeishuAdapter.MAX_MESSAGE_LENGTH
# Smart-chunk the message to fit within platform limits.
# For short messages or platforms without a known limit this is a no-op.
@@ -351,6 +367,10 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
result = await _send_homeassistant(pconfig.token, pconfig.extra, chat_id, chunk)
elif platform == Platform.DINGTALK:
result = await _send_dingtalk(pconfig.extra, chat_id, chunk)
+ elif platform == Platform.FEISHU:
+ result = await _send_feishu(pconfig, chat_id, chunk, thread_id=thread_id)
+ elif platform == Platform.WECOM:
+ result = await _send_wecom(pconfig.extra, chat_id, chunk)
else:
result = {"error": f"Direct sending not yet implemented for {platform.value}"}
@@ -777,6 +797,90 @@ async def _send_dingtalk(extra, chat_id, message):
return {"error": f"DingTalk send failed: {e}"}
+async def _send_wecom(extra, chat_id, message):
+ """Send via WeCom using the adapter's WebSocket send pipeline."""
+ try:
+ from gateway.platforms.wecom import WeComAdapter, check_wecom_requirements
+ if not check_wecom_requirements():
+ return {"error": "WeCom requirements not met. Need aiohttp + WECOM_BOT_ID/SECRET."}
+ except ImportError:
+ return {"error": "WeCom adapter not available."}
+
+ try:
+ from gateway.config import PlatformConfig
+ pconfig = PlatformConfig(extra=extra)
+ adapter = WeComAdapter(pconfig)
+ connected = await adapter.connect()
+ if not connected:
+ return {"error": f"WeCom: failed to connect — {adapter.fatal_error_message or 'unknown error'}"}
+ try:
+ result = await adapter.send(chat_id, message)
+ if not result.success:
+ return {"error": f"WeCom send failed: {result.error}"}
+ return {"success": True, "platform": "wecom", "chat_id": chat_id, "message_id": result.message_id}
+ finally:
+ await adapter.disconnect()
+ except Exception as e:
+ return {"error": f"WeCom send failed: {e}"}
+
+
+async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=None):
+ """Send via Feishu/Lark using the adapter's send pipeline."""
+ try:
+ from gateway.platforms.feishu import FeishuAdapter, FEISHU_AVAILABLE
+ if not FEISHU_AVAILABLE:
+ return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"}
+ from gateway.platforms.feishu import FEISHU_DOMAIN, LARK_DOMAIN
+ except ImportError:
+ return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"}
+
+ media_files = media_files or []
+
+ try:
+ adapter = FeishuAdapter(pconfig)
+ domain_name = getattr(adapter, "_domain_name", "feishu")
+ domain = FEISHU_DOMAIN if domain_name != "lark" else LARK_DOMAIN
+ adapter._client = adapter._build_lark_client(domain)
+ metadata = {"thread_id": thread_id} if thread_id else None
+
+ last_result = None
+ if message.strip():
+ last_result = await adapter.send(chat_id, message, metadata=metadata)
+ if not last_result.success:
+ return {"error": f"Feishu send failed: {last_result.error}"}
+
+ for media_path, is_voice in media_files:
+ if not os.path.exists(media_path):
+ return {"error": f"Media file not found: {media_path}"}
+
+ ext = os.path.splitext(media_path)[1].lower()
+ if ext in _IMAGE_EXTS:
+ last_result = await adapter.send_image_file(chat_id, media_path, metadata=metadata)
+ elif ext in _VIDEO_EXTS:
+ last_result = await adapter.send_video(chat_id, media_path, metadata=metadata)
+ elif ext in _VOICE_EXTS and is_voice:
+ last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
+ elif ext in _AUDIO_EXTS:
+ last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
+ else:
+ last_result = await adapter.send_document(chat_id, media_path, metadata=metadata)
+
+ if not last_result.success:
+ return {"error": f"Feishu media send failed: {last_result.error}"}
+
+ if last_result is None:
+ return {"error": "No deliverable text or media remained after processing MEDIA tags"}
+
+ return {
+ "success": True,
+ "platform": "feishu",
+ "chat_id": chat_id,
+ "message_id": last_result.message_id,
+ }
+ except Exception as e:
+ return {"error": f"Feishu send failed: {e}"}
+
+
def _check_send_message():
"""Gate send_message on gateway running (always available on messaging platforms)."""
platform = os.getenv("HERMES_SESSION_PLATFORM", "")
diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py
index 3c86194968ad..8507a6d1346f 100644
--- a/tools/skill_manager_tool.py
+++ b/tools/skill_manager_tool.py
@@ -113,6 +113,31 @@ def _validate_name(name: str) -> Optional[str]:
return None
+def _validate_category(category: Optional[str]) -> Optional[str]:
+ """Validate an optional category name used as a single directory segment."""
+ if category is None:
+ return None
+ if not isinstance(category, str):
+ return "Category must be a string."
+
+ category = category.strip()
+ if not category:
+ return None
+ if "/" in category or "\\" in category:
+ return (
+ f"Invalid category '{category}'. Use lowercase letters, numbers, "
+ "hyphens, dots, and underscores. Categories must be a single directory name."
+ )
+ if len(category) > MAX_NAME_LENGTH:
+ return f"Category exceeds {MAX_NAME_LENGTH} characters."
+ if not VALID_NAME_RE.match(category):
+ return (
+ f"Invalid category '{category}'. Use lowercase letters, numbers, "
+ "hyphens, dots, and underscores. Categories must be a single directory name."
+ )
+ return None
+
+
def _validate_frontmatter(content: str) -> Optional[str]:
"""
Validate that SKILL.md content has proper frontmatter with required fields.
@@ -241,6 +266,10 @@ def _create_skill(name: str, content: str, category: str = None) -> Dict[str, An
if err:
return {"success": False, "error": err}
+ err = _validate_category(category)
+ if err:
+ return {"success": False, "error": err}
+
# Validate content
err = _validate_frontmatter(content)
if err:
diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py
index 0c0a1fc9f65b..70791b0ca443 100644
--- a/tools/transcription_tools.py
+++ b/tools/transcription_tools.py
@@ -12,7 +12,7 @@ Provides speech-to-text transcription with three providers:
Used by the messaging gateway to automatically transcribe voice messages
sent by users on Telegram, Discord, WhatsApp, Slack, and Signal.
-Supported input formats: mp3, mp4, mpeg, mpga, m4a, wav, webm, ogg
+Supported input formats: mp3, mp4, mpeg, mpga, m4a, wav, webm, ogg, aac
Usage::
@@ -60,7 +60,7 @@ COMMON_LOCAL_BIN_DIRS = ("/opt/homebrew/bin", "/usr/local/bin")
GROQ_BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1")
OPENAI_BASE_URL = os.getenv("STT_OPENAI_BASE_URL", "https://api.openai.com/v1")
-SUPPORTED_FORMATS = {".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg"}
+SUPPORTED_FORMATS = {".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg", ".aac"}
LOCAL_NATIVE_AUDIO_FORMATS = {".wav", ".aiff", ".aif"}
MAX_FILE_SIZE = 25 * 1024 * 1024 # 25 MB
diff --git a/tools/vision_tools.py b/tools/vision_tools.py
index d8b96bc4e417..404d06a50040 100644
--- a/tools/vision_tools.py
+++ b/tools/vision_tools.py
@@ -39,11 +39,34 @@ from urllib.parse import urlparse
import httpx
from agent.auxiliary_client import async_call_llm, extract_content_or_reasoning
from tools.debug_helpers import DebugSession
+from tools.website_policy import check_website_access
logger = logging.getLogger(__name__)
_debug = DebugSession("vision_tools", env_var="VISION_TOOLS_DEBUG")
+# Configurable HTTP download timeout for _download_image().
+# Separate from auxiliary.vision.timeout which governs the LLM API call.
+# Resolution: config.yaml auxiliary.vision.download_timeout → env var → 30s default.
+def _resolve_download_timeout() -> float:
+ env_val = os.getenv("HERMES_VISION_DOWNLOAD_TIMEOUT", "").strip()
+ if env_val:
+ try:
+ return float(env_val)
+ except ValueError:
+ pass
+ try:
+ from hermes_cli.config import load_config
+ cfg = load_config()
+ val = cfg.get("auxiliary", {}).get("vision", {}).get("download_timeout")
+ if val is not None:
+ return float(val)
+ except Exception:
+ pass
+ return 30.0
+
+_VISION_DOWNLOAD_TIMEOUT = _resolve_download_timeout()
+
def _validate_image_url(url: str) -> bool:
"""
@@ -76,6 +99,28 @@ def _validate_image_url(url: str) -> bool:
return True
+def _detect_image_mime_type(image_path: Path) -> Optional[str]:
+ """Return a MIME type when the file looks like a supported image."""
+ with image_path.open("rb") as f:
+ header = f.read(64)
+
+ if header.startswith(b"\x89PNG\r\n\x1a\n"):
+ return "image/png"
+ if header.startswith(b"\xff\xd8\xff"):
+ return "image/jpeg"
+ if header.startswith((b"GIF87a", b"GIF89a")):
+ return "image/gif"
+ if header.startswith(b"BM"):
+ return "image/bmp"
+ if len(header) >= 12 and header[:4] == b"RIFF" and header[8:12] == b"WEBP":
+ return "image/webp"
+ if image_path.suffix.lower() == ".svg":
+ head = image_path.read_text(encoding="utf-8", errors="ignore")[:4096].lower()
+ if "