From 17b6000e90b325e5c1af98dc61fd7731b08882b1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:49:19 -0700 Subject: [PATCH 01/46] feat(skills): add songwriting-and-ai-music creative skill (salvage #1901) (#3834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a songwriting craft and AI music prompt engineering skill covering song structure, rhyme/meter, emotional arcs, Suno metatag reference, phonetic tricks for AI singers, parody adaptation, and production workflow. Complements existing music skills (heartmula, audiocraft, songsee) which cover model setup/usage — this one covers the creative process itself. Also removes the empty skills/music-creation/ category (only had a DESCRIPTION.md, no actual skills). Co-authored-by: 123mikeyd <123mikeyd@users.noreply.github.com> --- .../songwriting-and-ai-music/SKILL.md | 289 ++++++++++++++++++ skills/music-creation/DESCRIPTION.md | 3 - 2 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 skills/creative/songwriting-and-ai-music/SKILL.md delete mode 100644 skills/music-creation/DESCRIPTION.md 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. ---- From d6b78362102a07276a796075d9de01ad9ac6b604 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:49:58 -0700 Subject: [PATCH 02/46] fix: update session_log_file during context compression (#3835) When compression creates a child session with a new session_id, session_log_file was still pointing to the old session's JSON file. This caused _save_session_log() to write new data to the wrong file. Closes #3731. Co-authored-by: kelsia14 --- run_agent.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/run_agent.py b/run_agent.py index 66f1ff4c8179..52c727f05e3e 100644 --- a/run_agent.py +++ b/run_agent.py @@ -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"), From 59f2b228f7a04dcca46bb4d7016833936362592b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:02:11 -0700 Subject: [PATCH 03/46] fix(paths): respect HERMES_HOME for protected .env write-deny path (#3840) The write-deny list in file_operations.py hardcoded ~/.hermes/.env, which misses the actual .env in custom HERMES_HOME or profile setups. Use get_hermes_home() for profile-safe path resolution. Salvaged from PR #3232 by @erhnysr. Co-authored-by: Erhnysr --- tools/file_operations.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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"), From e314833c9d05432ee5aa42f8cea7132769117406 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:02:42 -0700 Subject: [PATCH 04/46] feat(display): configurable tool preview length -- show full paths by default (#3841) Tool call previews (paths, commands, queries) were hardcoded to truncate at 35-40 chars across CLI spinners, completion lines, and gateway progress messages. Users could not see full file paths in tool output. New config option: display.tool_preview_length (default 0 = no limit). Set a positive number to truncate at that length. Changes: - display.py: module-level _tool_preview_max_len with getter/setter; build_tool_preview() and get_cute_tool_message() _trunc/_path respect it - cli.py: reads config at startup, spinner widget respects config - gateway/run.py: reads config per-message, progress callback respects config - run_agent.py: removed redundant 30-char quiet-mode spinner truncation - config.py: added display.tool_preview_length to DEFAULT_CONFIG Reported by kriskaminski --- agent/display.py | 33 ++++++++++++++++++++++++++++++--- cli.py | 14 ++++++++++++-- gateway/run.py | 16 +++++++++++++--- hermes_cli/config.py | 1 + run_agent.py | 2 -- 5 files changed, 56 insertions(+), 10 deletions(-) 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/cli.py b/cli.py index 4d8146bb6696..5144b5bbb36c 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 @@ -4782,8 +4790,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() diff --git a/gateway/run.py b/gateway/run.py index 07eaa84d1fac..d2f43a6fd9d7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -4941,6 +4941,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 +4994,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_cli/config.py b/hermes_cli/config.py index 8a65d0e2ca56..34d4d1ac47a0 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -285,6 +285,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 diff --git a/run_agent.py b/run_agent.py index 52c727f05e3e..32661a1b7c1f 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5662,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 From ca4907dfbc71b3d7d603a8010cf67d9dc9c33de3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:17:42 -0700 Subject: [PATCH 05/46] feat(gateway): add Feishu/Lark platform support (#3817) Adds Feishu (ByteDance's enterprise messaging platform) as a gateway platform adapter with full feature parity: WebSocket + webhook transports, message batching, dedup, rate limiting, rich post/card content parsing, media handling (images/audio/files/video), group @mention gating, reaction routing, and interactive card button support. Cherry-picked from PR #1793 by penwyp with: - Moved to current main (PR was 458 commits behind) - Fixed _send_with_retry shadowing BasePlatformAdapter method (renamed to _feishu_send_with_retry to avoid signature mismatch crash) - Fixed import structure: aiohttp/websockets imported independently of lark_oapi so they remain available when SDK is missing - Fixed get_hermes_home import (hermes_constants, not hermes_cli.config) - Added skip decorators for tests requiring lark_oapi SDK - All 16 integration points added surgically to current main New dependency: lark-oapi>=1.5.3,<2 (optional, pip install hermes-agent[feishu]) Fixes #1788 Co-authored-by: penwyp --- cron/scheduler.py | 1 + gateway/config.py | 31 + gateway/platforms/feishu.py | 3255 +++++++++++++++++ gateway/run.py | 13 +- hermes_cli/config.py | 1 + hermes_cli/gateway.py | 29 + hermes_cli/skills_config.py | 1 + hermes_cli/tools_config.py | 3 +- pyproject.toml | 2 + tests/gateway/test_allowlist_startup_check.py | 4 +- tests/gateway/test_feishu.py | 2580 +++++++++++++ .../gateway/test_unauthorized_dm_behavior.py | 4 +- tools/cronjob_tools.py | 2 +- tools/send_message_tool.py | 74 + toolsets.py | 8 +- website/docs/reference/toolsets-reference.md | 1 + website/docs/user-guide/messaging/feishu.md | 129 + website/docs/user-guide/messaging/index.md | 5 +- website/sidebars.ts | 1 + 19 files changed, 6135 insertions(+), 9 deletions(-) create mode 100644 gateway/platforms/feishu.py create mode 100644 tests/gateway/test_feishu.py create mode 100644 website/docs/user-guide/messaging/feishu.md diff --git a/cron/scheduler.py b/cron/scheduler.py index 5784aec3d188..0058c1c0a598 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -146,6 +146,7 @@ def _deliver_result(job: dict, content: str) -> None: "mattermost": Platform.MATTERMOST, "homeassistant": Platform.HOMEASSISTANT, "dingtalk": Platform.DINGTALK, + "feishu": Platform.FEISHU, "email": Platform.EMAIL, "sms": Platform.SMS, } diff --git a/gateway/config.py b/gateway/config.py index 5dbc81c86cc5..75db564a4a34 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -57,6 +57,7 @@ class Platform(Enum): DINGTALK = "dingtalk" API_SERVER = "api_server" WEBHOOK = "webhook" + FEISHU = "feishu" @dataclass @@ -274,6 +275,9 @@ 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) return connected def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]: @@ -810,6 +814,33 @@ 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"), + ) + # Session settings idle_minutes = os.getenv("SESSION_IDLE_MINUTES") if idle_minutes: 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/run.py b/gateway/run.py index d2f43a6fd9d7..403463e64ce9 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -983,6 +983,7 @@ class GatewayRunner: "EMAIL_ALLOWED_USERS", "SMS_ALLOWED_USERS", "MATTERMOST_ALLOWED_USERS", "MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS", + "FEISHU_ALLOWED_USERS", "GATEWAY_ALLOWED_USERS") ) _allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any( @@ -991,7 +992,8 @@ 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") ) if not _any_allowlist and not _allow_all: logger.warning( @@ -1434,6 +1436,13 @@ 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.MATTERMOST: from gateway.platforms.mattermost import MattermostAdapter, check_mattermost_requirements if not check_mattermost_requirements(): @@ -1500,6 +1509,7 @@ class GatewayRunner: Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS", Platform.MATRIX: "MATRIX_ALLOWED_USERS", Platform.DINGTALK: "DINGTALK_ALLOWED_USERS", + Platform.FEISHU: "FEISHU_ALLOWED_USERS", } platform_allow_all_map = { Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS", @@ -1512,6 +1522,7 @@ 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", } # Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 34d4d1ac47a0..c2fc7a3be9d7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -34,6 +34,7 @@ _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", "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT", "WHATSAPP_MODE", "WHATSAPP_ENABLED", "MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE", diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index f7bfad370e00..db15fcc750c7 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1322,6 +1322,35 @@ _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."}, + ], + }, ] diff --git a/hermes_cli/skills_config.py b/hermes_cli/skills_config.py index df08a815a40e..2fd0227aad71 100644 --- a/hermes_cli/skills_config.py +++ b/hermes_cli/skills_config.py @@ -28,6 +28,7 @@ PLATFORMS = { "mattermost": "💬 Mattermost", "matrix": "💬 Matrix", "dingtalk": "💬 DingTalk", + "feishu": "🪽 Feishu", } # ─── Config Helpers ─────────────────────────────────────────────────────────── diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 7c76232f3a58..d8fa47cee45e 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -140,7 +140,8 @@ 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"}, "api_server": {"label": "🌐 API Server", "default_toolset": "hermes-api-server"}, "mattermost": {"label": "💬 Mattermost", "default_toolset": "hermes-mattermost"}, } diff --git a/pyproject.toml b/pyproject.toml index 4fff61805c9a..ac410ff754b4 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", @@ -83,6 +84,7 @@ all = [ "hermes-agent[acp]", "hermes-agent[voice]", "hermes-agent[dingtalk]", + "hermes-agent[feishu]", ] [project.scripts] diff --git a/tests/gateway/test_allowlist_startup_check.py b/tests/gateway/test_allowlist_startup_check.py index cd259e5a2207..24df941af1c9 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", "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") ) return not _any_allowlist and not _allow_all 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_unauthorized_dm_behavior.py b/tests/gateway/test_unauthorized_dm_behavior.py index 0dbe457a8265..a0285e28aac1 100644 --- a/tests/gateway/test_unauthorized_dm_behavior.py +++ b/tests/gateway/test_unauthorized_dm_behavior.py @@ -19,7 +19,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", "GATEWAY_ALLOWED_USERS", "TELEGRAM_ALLOW_ALL_USERS", "DISCORD_ALLOW_ALL_USERS", @@ -30,7 +30,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", "GATEWAY_ALLOW_ALL_USERS", ): monkeypatch.delenv(key, raising=False) diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 0a023c904ff0..5f209e16a4e5 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, 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/send_message_tool.py b/tools/send_message_tool.py index 67a95547caf0..e0384735a78b 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,7 @@ def _handle_send(args): "mattermost": Platform.MATTERMOST, "homeassistant": Platform.HOMEASSISTANT, "dingtalk": Platform.DINGTALK, + "feishu": Platform.FEISHU, "email": Platform.EMAIL, "sms": Platform.SMS, } @@ -198,6 +200,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 +286,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 +301,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 +366,8 @@ 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) else: result = {"error": f"Direct sending not yet implemented for {platform.value}"} @@ -777,6 +794,63 @@ async def _send_dingtalk(extra, chat_id, message): return {"error": f"DingTalk 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/toolsets.py b/toolsets.py index e1e780ef39a7..a08fe38c0fc0 100644 --- a/toolsets.py +++ b/toolsets.py @@ -351,6 +351,12 @@ TOOLSETS = { "includes": [] }, + "hermes-feishu": { + "description": "Feishu/Lark bot toolset - enterprise messaging via Feishu/Lark (full access)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + "hermes-sms": { "description": "SMS bot toolset - interact with Hermes via SMS (Twilio)", "tools": _HERMES_CORE_TOOLS, @@ -360,7 +366,7 @@ TOOLSETS = { "hermes-gateway": { "description": "Gateway toolset - union of all messaging platform tools", "tools": [], - "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk"] + "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu"] } } diff --git a/website/docs/reference/toolsets-reference.md b/website/docs/reference/toolsets-reference.md index 7e8651b54f49..133870eb9f46 100644 --- a/website/docs/reference/toolsets-reference.md +++ b/website/docs/reference/toolsets-reference.md @@ -21,6 +21,7 @@ Toolsets are named bundles of tools that you can enable with `hermes chat --tool | `hermes-cli` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_console`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `cronjob`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | | `hermes-api-server` | platform | _(same as hermes-cli)_ | | `hermes-dingtalk` | platform | _(same as hermes-cli)_ | +| `hermes-feishu` | platform | _(same as hermes-cli)_ | | `hermes-discord` | platform | _(same as hermes-cli)_ | | `hermes-email` | platform | _(same as hermes-cli)_ | | `hermes-gateway` | composite | Union of all messaging platform toolsets | diff --git a/website/docs/user-guide/messaging/feishu.md b/website/docs/user-guide/messaging/feishu.md new file mode 100644 index 000000000000..f515648b867e --- /dev/null +++ b/website/docs/user-guide/messaging/feishu.md @@ -0,0 +1,129 @@ +--- +sidebar_position: 11 +title: "Feishu / Lark" +description: "Set up Hermes Agent as a Feishu or Lark bot" +--- + +# Feishu / Lark Setup + +Hermes Agent integrates with Feishu and Lark as a full-featured bot. Once connected, you can chat with the agent in direct messages or group chats, receive cron job results in a home chat, and send text, images, audio, and file attachments through the normal gateway flow. + +The integration supports both connection modes: + +- `websocket` — recommended; Hermes opens the outbound connection and you do not need a public webhook endpoint +- `webhook` — useful when you want Feishu/Lark to push events into your gateway over HTTP + +## How Hermes Behaves + +| Context | Behavior | +|---------|----------| +| Direct messages | Hermes responds to every message. | +| Group chats | Hermes responds when the bot is addressed in the chat. | +| Shared group chats | By default, session history is isolated per user inside a shared chat. | + +This shared-chat behavior is controlled by `config.yaml`: + +```yaml +group_sessions_per_user: true +``` + +Set it to `false` only if you explicitly want one shared conversation per chat. + +## Step 1: Create a Feishu / Lark App + +1. Open the Feishu or Lark developer console: + - Feishu: + - Lark: +2. Create a new app. +3. In **Credentials & Basic Info**, copy the **App ID** and **App Secret**. +4. Enable the **Bot** capability for the app. + +:::warning +Keep the App Secret private. Anyone with it can impersonate your app. +::: + +## Step 2: Choose a Connection Mode + +### Recommended: WebSocket mode + +Use WebSocket mode when Hermes runs on your laptop, workstation, or a private server. No public URL is required. + +```bash +FEISHU_CONNECTION_MODE=websocket +``` + +### Optional: Webhook mode + +Use webhook mode only when you already run Hermes behind a reachable HTTP endpoint. + +```bash +FEISHU_CONNECTION_MODE=webhook +``` + +In webhook mode, Hermes serves a Feishu endpoint at: + +```text +/feishu/webhook +``` + +## Step 3: Configure Hermes + +### Option A: Interactive Setup + +```bash +hermes gateway setup +``` + +Select **Feishu / Lark** and fill in the prompts. + +### Option B: Manual Configuration + +Add the following to `~/.hermes/.env`: + +```bash +FEISHU_APP_ID=cli_xxx +FEISHU_APP_SECRET=secret_xxx +FEISHU_DOMAIN=feishu +FEISHU_CONNECTION_MODE=websocket + +# Optional but strongly recommended +FEISHU_ALLOWED_USERS=ou_xxx,ou_yyy +FEISHU_HOME_CHANNEL=oc_xxx +``` + +`FEISHU_DOMAIN` accepts: + +- `feishu` for Feishu China +- `lark` for Lark international + +## Step 4: Start the Gateway + +```bash +hermes gateway +``` + +Then message the bot from Feishu/Lark to confirm that the connection is live. + +## Home Chat + +Use `/set-home` in a Feishu/Lark chat to mark it as the home channel for cron job results and cross-platform notifications. + +You can also preconfigure it: + +```bash +FEISHU_HOME_CHANNEL=oc_xxx +``` + +## Security + +For production use, set an allowlist: + +```bash +FEISHU_ALLOWED_USERS=ou_xxx,ou_yyy +``` + +If you leave the allowlist empty, anyone who can reach the bot may be able to use it. + +## Toolset + +Feishu / Lark uses the `hermes-feishu` platform preset, which includes the same core tools as Telegram and other gateway-based messaging platforms. diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index cac2a5e1f856..3dc0e9cd7b6d 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -6,7 +6,7 @@ description: "Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, # Messaging Gateway -Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages. +Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages. For the full voice feature set — including CLI microphone mode, spoken replies in messaging, and Discord voice-channel conversations — see [Voice Mode](/docs/user-guide/features/voice-mode) and [Use Voice Mode with Hermes](/docs/guides/use-voice-mode-with-hermes). @@ -27,6 +27,7 @@ flowchart TB mm[Mattermost] mx[Matrix] dt[DingTalk] + fs[Feishu/Lark] api["API Server
(OpenAI-compatible)"] wh[Webhooks] end @@ -328,6 +329,7 @@ Each platform has its own toolset: | Mattermost | `hermes-mattermost` | Full tools including terminal | | Matrix | `hermes-matrix` | Full tools including terminal | | DingTalk | `hermes-dingtalk` | Full tools including terminal | +| Feishu/Lark | `hermes-feishu` | Full tools including terminal | | API Server | `hermes` (default) | Full tools including terminal | | Webhooks | `hermes-webhook` | Full tools including terminal | @@ -344,5 +346,6 @@ Each platform has its own toolset: - [Mattermost Setup](mattermost.md) - [Matrix Setup](matrix.md) - [DingTalk Setup](dingtalk.md) +- [Feishu/Lark Setup](feishu.md) - [Open WebUI + API Server](open-webui.md) - [Webhooks](webhooks.md) diff --git a/website/sidebars.ts b/website/sidebars.ts index 6f065bcc18df..a2fafdfce95c 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -54,6 +54,7 @@ const sidebars: SidebarsConfig = { 'user-guide/messaging/mattermost', 'user-guide/messaging/matrix', 'user-guide/messaging/dingtalk', + 'user-guide/messaging/feishu', 'user-guide/messaging/open-webui', 'user-guide/messaging/webhooks', ], From 2a0e8b001f67b568abab6f876268bdeeddc98037 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:21:27 -0700 Subject: [PATCH 06/46] fix(cli): handle closed stdout ValueError in safe print paths (#3843) When stdout is closed (piped to a dead process, broken terminal), Python raises ValueError('I/O operation on closed file'), not OSError. _safe_print and the API error printer only caught OSError, letting the ValueError propagate and crash the agent. Salvaged from PR #3760 by @apexscaleai. Fixes #3534. Co-authored-by: apexscaleai --- run_agent.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/run_agent.py b/run_agent.py index 32661a1b7c1f..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): @@ -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: From e4d575e563f83a89fa2ccf3ae911871381d0d82d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:21:36 -0700 Subject: [PATCH 07/46] fix: report subagent status as completed when summary exists (#3829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a subagent hit max_iterations, status was always 'failed' even if it produced a usable summary via _handle_max_iterations(). This happened because the status check required both completed=True AND a summary, but completed is False whenever max_iterations is reached (run_agent.py line 7969). Now gates status on whether a summary was produced — if the subagent returned a final_response, the parent has usable output regardless of iteration budget. The exit_reason field already distinguishes 'completed' vs 'max_iterations' for anything that needs to know how the task ended. Closes #1899. --- tools/delegate_tool.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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" From 3e2c8c529bfeb6ac530bcff1884ce5dc11162e2d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:21:50 -0700 Subject: [PATCH 08/46] =?UTF-8?q?fix(whatsapp):=20resolve=20LID=E2=86=94ph?= =?UTF-8?q?one=20aliases=20in=20allowlist=20matching=20(#3830)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp DMs can arrive with LID sender IDs even when WHATSAPP_ALLOWED_USERS is configured with phone numbers. The allowlist check now reads bridge session mapping files (lid-mapping-*.json) to resolve phone↔LID aliases, matching users regardless of which identifier format the message uses. Both the Python gateway (_is_user_authorized) and the Node bridge (allowlist.js) now share the same mapping-file-based resolution logic. Co-authored-by: Frederico Ribeiro --- gateway/run.py | 58 +++++++++++++- scripts/whatsapp-bridge/allowlist.js | 79 +++++++++++++++++++ scripts/whatsapp-bridge/allowlist.test.mjs | 47 +++++++++++ scripts/whatsapp-bridge/bridge.js | 14 ++-- .../gateway/test_unauthorized_dm_behavior.py | 27 +++++++ 5 files changed, 217 insertions(+), 8 deletions(-) create mode 100644 scripts/whatsapp-bridge/allowlist.js create mode 100644 scripts/whatsapp-bridge/allowlist.test.mjs diff --git a/gateway/run.py b/gateway/run.py index 403463e64ce9..ff4f93284834 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 @@ -1550,10 +1593,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: 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..46cc5c339e59 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,7 +48,7 @@ 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 @@ -190,10 +191,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 +515,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/tests/gateway/test_unauthorized_dm_behavior.py b/tests/gateway/test_unauthorized_dm_behavior.py index a0285e28aac1..6f4a9ff02416 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 @@ -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) From 2d264a4562b89cdb4d3a5eb128bb2eb523ce5ba3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:05:59 -0700 Subject: [PATCH 09/46] fix(tests): resolve 10 CI failures across hooks, tiktoken, plugins (#3848) test_hooks.py (7 failures): Built-in boot-md hook was always loaded by _register_builtin_hooks(), adding +1 to every expected hook count. Mock out built-in registration in TestDiscoverAndLoad so tests isolate user-hook discovery logic. test_tool_token_estimation.py (2 failures): tiktoken is not in core/[all] dependencies. The estimation function gracefully returns {} when tiktoken is missing, but tests expected non-empty results. Added skipif markers for tests that need tiktoken. test_plugins_cmd.py (1 failure): bare 'hermes plugins' now dispatches to cmd_toggle() (interactive curses UI) instead of cmd_list(). Updated test to match the new behavior. --- tests/gateway/test_hooks.py | 19 ++++++++++++------- .../hermes_cli/test_tool_token_estimation.py | 12 ++++++++++++ tests/test_plugins_cmd.py | 6 +++--- 3 files changed, 27 insertions(+), 10 deletions(-) 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/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_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): From 3e203de125aaf4608d91defc94060adcad300fae Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:08:22 -0700 Subject: [PATCH 10/46] fix(skills): block category path traversal in skill manager (#3844) Validate category names in _create_skill() before using them as filesystem path segments. Previously, categories like '../escape' or '/tmp/pwned' could write skill files outside ~/.hermes/skills/. Adds _validate_category() that rejects slashes, backslashes, absolute paths, and non-alphanumeric characters (reuses existing VALID_NAME_RE). Tests: 5 new tests for traversal, absolute paths, and valid categories. Salvaged from PR #1939 by Gutslabs. --- tests/tools/test_skill_manager_tool.py | 40 ++++++++++++++++++++++++++ tools/skill_manager_tool.py | 29 +++++++++++++++++++ 2 files changed, 69 insertions(+) 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/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: From 9d28f4aba30456efc1b8cc20a8dc277a69890e2d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:10:00 -0700 Subject: [PATCH 11/46] fix: add gpt-5.4-mini to Codex fallback catalog (#3855) Co-authored-by: Clippy --- hermes_cli/codex_models.py | 5 ++++- tests/test_codex_models.py | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) 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/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): From 981e14001c98bcb1ca66df323ba5f56ebee996e8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:44:39 -0700 Subject: [PATCH 12/46] fix: clear api_mode on provider switch instead of hardcoding chat_completions (#3857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #3726 fixed stale codex_responses persisting when switching providers by hardcoding api_mode=chat_completions in 5 model flows. This broke MiniMax, MiniMax-CN, and Alibaba which use /anthropic endpoints that need anthropic_messages — the hardcoded value overrides the URL-based auto-detection in runtime_provider.py. Fix: pop api_mode from config in the 3 URL-dependent flows (custom endpoint, Kimi, api_key_provider) instead of hardcoding. The runtime resolver already correctly auto-detects api_mode from the base_url suffix (/anthropic -> anthropic_messages, else chat_completions). OpenRouter and Copilot ACP flows keep the explicit value since their api_mode is always known. Reported by stefan171. --- hermes_cli/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e01bc660a54c..4c5f8f320e62 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1269,7 +1269,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() @@ -2051,7 +2051,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() @@ -2158,7 +2158,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() From b60cfd6ce6a99e6f36792b2d60ed7ae4f3f45fca Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:47:07 -0700 Subject: [PATCH 13/46] fix(telegram): gracefully handle deleted reply targets (#3858) * fix: add gpt-5.4-mini to Codex fallback catalog * fix(telegram): gracefully handle deleted reply targets When a user deletes their message while Hermes is processing, Telegram returns BadRequest 'Message to be replied not found'. Previously this was an unhandled permanent error causing silent delivery failure. Now clears reply_to_id and retries so the response is still delivered, matching the existing 'thread not found' recovery pattern. Inspired by PR #3231 by @heathley. Fixes #3229. --------- Co-authored-by: Clippy Co-authored-by: Nigel Gibbs --- gateway/platforms/telegram.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 926ac81d6f34..5f497221fca0 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -762,6 +762,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: From 5e67fc8c40d8f867504bf168f21564eb6903a00e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:55:04 -0700 Subject: [PATCH 14/46] fix(vision): reject non-image files and enforce website policy (salvage #1940) (#3845) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three safety gaps in vision_analyze_tool: 1. Local files accepted without checking if they're actually images — a renamed text file would get base64-encoded and sent to the model. Now validates magic bytes (PNG, JPEG, GIF, BMP, WebP, SVG). 2. No website policy enforcement on image URLs — blocked domains could be fetched via the vision tool. Now checks before download. 3. No redirect check — if an allowed URL redirected to a blocked domain, the download would proceed. Now re-checks the final URL. Fixed one test that needed _validate_image_url mocked to bypass DNS resolution on the fake blocked.test domain (is_safe_url does DNS checks that were added after the original PR). Co-authored-by: GutSlabs --- tests/tools/test_vision_tools.py | 72 ++++++++++++++++++++++++++++++++ tools/vision_tools.py | 42 ++++++++++++++++++- 2 files changed, 113 insertions(+), 1 deletion(-) 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/vision_tools.py b/tools/vision_tools.py index d8b96bc4e417..47b406846e5d 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -39,6 +39,7 @@ 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__) @@ -76,6 +77,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 " Path: """ Download an image from a URL to a local destination (async) with retry logic. @@ -115,6 +138,10 @@ async def _download_image(image_url: str, destination: Path, max_retries: int = last_error = None for attempt in range(max_retries): try: + blocked = check_website_access(image_url) + if blocked: + raise PermissionError(blocked["message"]) + # Download the image with appropriate headers using async httpx # Enable follow_redirects to handle image CDNs that redirect (e.g., Imgur, Picsum) # SSRF: event_hooks validates each redirect target against private IP ranges @@ -131,6 +158,11 @@ async def _download_image(image_url: str, destination: Path, max_retries: int = }, ) response.raise_for_status() + + final_url = str(response.url) + blocked = check_website_access(final_url) + if blocked: + raise PermissionError(blocked["message"]) # Save the image content destination.write_bytes(response.content) @@ -257,6 +289,7 @@ async def vision_analyze_tool( # Track whether we should clean up the file after processing. # Local files (e.g. from the image cache) should NOT be deleted. should_cleanup = True + detected_mime_type = None try: from tools.interrupt import is_interrupted @@ -275,6 +308,9 @@ async def vision_analyze_tool( should_cleanup = False # Don't delete cached/local files elif _validate_image_url(image_url): # Remote URL -- download to a temporary location + blocked = check_website_access(image_url) + if blocked: + raise PermissionError(blocked["message"]) logger.info("Downloading image from URL...") temp_dir = Path("./temp_vision_images") temp_image_path = temp_dir / f"temp_image_{uuid.uuid4()}.jpg" @@ -289,10 +325,14 @@ async def vision_analyze_tool( image_size_bytes = temp_image_path.stat().st_size image_size_kb = image_size_bytes / 1024 logger.info("Image ready (%.1f KB)", image_size_kb) + + detected_mime_type = _detect_image_mime_type(temp_image_path) + if not detected_mime_type: + raise ValueError("Only real image files are supported for vision analysis.") # Convert image to base64 data URL logger.info("Converting image to base64...") - image_data_url = _image_to_base64_data_url(temp_image_path) + image_data_url = _image_to_base64_data_url(temp_image_path, mime_type=detected_mime_type) # Calculate size in KB for better readability data_size_kb = len(image_data_url) / 1024 logger.info("Image converted to base64 (%.1f KB)", data_size_kb) From aa389924ad1bb0076b203b41fc15dc423abdee5c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:55:15 -0700 Subject: [PATCH 15/46] fix: prefer curated model list when live probe returns fewer models (#3856) The model picker for API-key providers (MiniMax, z.ai, etc.) probes the live /models endpoint when the curated list has fewer than 8 models. When the live endpoint returns fewer models than the curated list (e.g. MiniMax's Anthropic-compatible endpoint doesn't list M2.7), the incomplete live list was used instead. Now falls back to the curated list when live returns fewer models, ensuring new models like MiniMax-M2.7 always appear in the picker. --- hermes_cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4c5f8f320e62..cf445d845294 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2125,7 +2125,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: From 2d607d36f674539bd88443fa73153e6fe8d1ca2c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:57:57 -0700 Subject: [PATCH 16/46] fix(security): catch sensitive path writes in approval checks (#3859) Co-authored-by: Gutslabs --- tests/tools/test_approval.py | 35 +++++++++++++++++++++++++++++++++++ tools/approval.py | 18 +++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) 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/tools/approval.py b/tools/approval.py index ee74b113475f..8ae52407f9c6 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 # ========================================================================= @@ -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*["\']?{_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"), From 3cc50532d15956af15d520c902efd17a562a0708 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:05:36 -0700 Subject: [PATCH 17/46] fix: auxiliary client uses placeholder key for local servers without auth (#3842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local inference servers (Ollama, llama.cpp, vLLM, LM Studio) don't require API keys, but the auxiliary client's _resolve_custom_runtime() rejected endpoints with empty keys — causing the auto-detection chain to skip the user's local server entirely. This broke compression, summarization, and memory flush for users running local models without an OpenRouter/cloud API key. The main CLI already had this fix (PR #2556, 'no-key-required' placeholder), but the auxiliary client's resolution path was missed. Two fixes: - _resolve_custom_runtime(): use 'no-key-required' placeholder instead of returning None when base_url is present but key is empty - resolve_provider_client() custom branch: same placeholder fallback for explicit_base_url without explicit_api_key Updates 2 tests that expected the old (broken) behavior. --- agent/auxiliary_client.py | 14 ++++++++++---- tests/agent/test_auxiliary_client.py | 19 +++++++++++-------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index fcd2eb12f391..c4a5a184e665 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() @@ -897,11 +902,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" 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") From 86ac23c8da4564de93168071c6edff1ee87ac371 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:06:35 -0700 Subject: [PATCH 18/46] fix(auth): stop silently falling back to OpenRouter when no provider is configured (#3862) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, when no API keys or provider credentials were found, Hermes silently defaulted to OpenRouter + Claude Opus. This caused confusion when users configured local servers (LM Studio, Ollama, etc.) with a typo or unrecognized provider name — the system would silently route to OpenRouter instead of telling them something was wrong. Changes: - resolve_provider() now raises AuthError when no credentials are found instead of returning 'openrouter' as a silent fallback - Added local server aliases: lmstudio, ollama, vllm, llamacpp → custom - Removed hardcoded 'anthropic/claude-opus-4.6' fallback from gateway and cron scheduler (they read from config.yaml instead) - Updated cli-config.yaml.example with complete provider documentation including all supported providers, aliases, and local server setup --- cli-config.yaml.example | 31 +++++++++++++++++++++++-------- cli.py | 10 +++++----- cron/scheduler.py | 2 +- gateway/run.py | 6 +++--- hermes_cli/auth.py | 11 ++++++++++- tests/test_api_key_providers.py | 3 ++- 6 files changed, 44 insertions(+), 19 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 1f7a515c9c42..504b2178d8b5 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" diff --git a/cli.py b/cli.py index 5144b5bbb36c..a601878f244b 100644 --- a/cli.py +++ b/cli.py @@ -1087,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 @@ -1104,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 diff --git a/cron/scheduler.py b/cron/scheduler.py index 0058c1c0a598..52ae8e3185be 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -321,7 +321,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/run.py b/gateway/run.py index ff4f93284834..97d853646a82 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -323,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): diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 6b41ac6e5495..0c503701982d 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", + ) # ============================================================================= 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") # ============================================================================= From 3fad1e7cc1487cc3f9b6c9582a2532c0f86286a0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:24:17 -0700 Subject: [PATCH 19/46] fix(cron): resolve human-friendly delivery labels via channel directory (#3860) Cron jobs configured with deliver labels from send_message(action='list') like 'whatsapp:Alice (dm)' passed the label as a literal chat_id. WhatsApp bridge failed with jidDecode error since 'Alice (dm)' isn't a valid JID. Now _resolve_delivery_target() strips display suffixes like ' (dm)' and resolves human-friendly names via the channel directory before using them. Raw IDs pass through unchanged when the directory has no match. Fixes #1945. --- cron/scheduler.py | 16 ++++++++++++++ tests/cron/test_scheduler.py | 42 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/cron/scheduler.py b/cron/scheduler.py index 52ae8e3185be..f73d658521ac 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, 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", From f39ca81babb30e3086e6fba110acd1a8d4f621dc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:25:13 -0700 Subject: [PATCH 20/46] docs: comprehensive hermes claw migrate reference (#3864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing docs were two lines. The migration script handles 35 categories of data across persona, memory, skills, messaging platforms, model providers, MCP servers, agent config, and more. New docs cover: - All CLI options (--dry-run, --preset, --overwrite, --migrate-secrets, --source, --workspace-target, --skill-conflict, --yes) - 27 directly-imported categories with source → destination mapping - 7 archived categories with manual recreation guidance - Security notes on API key allowlisting - Usage examples for common migration scenarios --- website/docs/reference/cli-commands.md | 88 +++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index e1ef0b173b71..d27a34f2ccad 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -463,10 +463,94 @@ hermes insights [--days N] [--source platform] ## `hermes claw` ```bash -hermes claw migrate +hermes claw migrate [options] ``` -Used to migrate settings, memories, skills, and keys from OpenClaw to Hermes. +Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom path) and writes to `~/.hermes`. + +| Option | Description | +|--------|-------------| +| `--dry-run` | Preview what would be migrated without writing anything. | +| `--preset ` | Migration preset: `full` (default, includes secrets) or `user-data` (excludes API keys). | +| `--overwrite` | Overwrite existing Hermes files on conflicts (default: skip). | +| `--migrate-secrets` | Include API keys in migration (enabled by default with `--preset full`). | +| `--source ` | Custom OpenClaw directory (default: `~/.openclaw`). | +| `--workspace-target ` | Target directory for workspace instructions (AGENTS.md). | +| `--skill-conflict ` | Handle skill name collisions: `skip` (default), `overwrite`, or `rename`. | +| `--yes` | Skip the confirmation prompt. | + +### What gets migrated + +The migration covers your entire OpenClaw footprint. Items are either **directly imported** into Hermes equivalents or **archived** for manual review when there's no direct mapping. + +#### Directly imported + +| Category | OpenClaw source | Hermes destination | +|----------|----------------|-------------------| +| **Persona** | `SOUL.md` | `~/.hermes/SOUL.md` | +| **Workspace instructions** | `AGENTS.md` | `AGENTS.md` in target workspace | +| **Long-term memory** | `MEMORY.md` | `~/.hermes/MEMORY.md` (merged with existing entries) | +| **User profile** | `USER.md` | `~/.hermes/USER.md` (merged with existing entries) | +| **Daily memory files** | `workspace/memory/` | Merged into `~/.hermes/MEMORY.md` | +| **Default model** | Config model setting | `config.yaml` model section | +| **Custom providers** | Provider definitions (baseUrl, apiType, headers) | `config.yaml` custom\_providers | +| **MCP servers** | MCP server definitions | `config.yaml` mcp\_servers | +| **User skills** | Workspace skills | `~/.hermes/skills/openclaw-imports/` | +| **Shared skills** | `~/.openclaw/skills/` | `~/.hermes/skills/openclaw-imports/` | +| **Command allowlist** | Exec approval patterns | `config.yaml` command\_allowlist | +| **Messaging settings** | Allowlists, working directory | `config.yaml` messaging section | +| **Session policies** | Daily/idle reset policies | `config.yaml` session\_reset | +| **Agent defaults** | Compaction, context, thinking settings | `config.yaml` agent section | +| **Browser settings** | Browser automation config | `config.yaml` browser section | +| **Tool settings** | Exec timeout, sandbox, web search | `config.yaml` tools section | +| **Approval rules** | Approval mode and rules | `config.yaml` approvals section | +| **TTS config** | TTS provider and voice | `config.yaml` tts section | +| **TTS assets** | Workspace TTS files | `~/.hermes/tts/` | +| **Gateway config** | Gateway port and auth | `config.yaml` gateway section | +| **Telegram settings** | Bot token, allowlist | `~/.hermes/.env` | +| **Discord settings** | Bot token, allowlist | `~/.hermes/.env` | +| **Slack settings** | Bot/app tokens, allowlist | `~/.hermes/.env` | +| **WhatsApp settings** | Allowlist | `~/.hermes/.env` | +| **Signal settings** | Account, HTTP URL, allowlist | `~/.hermes/.env` | +| **Channel config** | Matrix, Mattermost, IRC, group settings | `config.yaml` + archive | +| **Provider API keys** | OPENROUTER\_API\_KEY, OPENAI\_API\_KEY, ANTHROPIC\_API\_KEY, etc. | `~/.hermes/.env` (requires `--migrate-secrets`) | + +#### Archived for manual review + +These OpenClaw features don't have direct Hermes equivalents. They're saved to an archive directory for you to review and recreate manually. + +| Category | What's archived | How to recreate in Hermes | +|----------|----------------|--------------------------| +| **Cron / scheduled tasks** | Job definitions | Recreate with `hermes cron create` | +| **Plugins** | Plugin configuration, installed extensions | Check the [plugins guide](../user-guide/features/hooks.md) | +| **Hooks and webhooks** | Internal hooks, webhooks, Gmail integration | Use `hermes webhook` or gateway hooks | +| **Memory backend** | QMD, vector search, citation settings | Configure Honcho via `hermes honcho` | +| **Skills registry** | Per-skill enabled/config/env settings | Use `hermes skills config` | +| **UI and identity** | Theme, assistant identity, display prefs | Use `/skin` command or `config.yaml` | +| **Logging** | Diagnostics configuration | Set in `config.yaml` logging section | + +### Security + +API keys are **not migrated by default**. The `--preset full` preset enables secret migration, but only for an allowlist of known keys: `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `ELEVENLABS_API_KEY`, `TELEGRAM_BOT_TOKEN`, and `VOICE_TOOLS_OPENAI_KEY`. All other secrets are skipped. + +### Examples + +```bash +# Preview what would be migrated +hermes claw migrate --dry-run + +# Full migration including API keys +hermes claw migrate --preset full + +# Migrate user data only (no secrets), overwrite conflicts +hermes claw migrate --preset user-data --overwrite + +# Migrate from a custom OpenClaw path +hermes claw migrate --source /home/user/old-openclaw + +# Migrate and place AGENTS.md in a specific project +hermes claw migrate --workspace-target /home/user/my-project +``` ## Maintenance commands From 2ff2cd3a59bf27a51f7e474dfe3707f32e12e984 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:27:03 -0700 Subject: [PATCH 21/46] add .aac audio file format support to transcription tool (#3865) Co-authored-by: Adrian Scott --- tools/transcription_tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From e296efbf2497a4f69bd8d284c095312224742ab5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:29:00 -0700 Subject: [PATCH 22/46] fix: add INFO-level logging for auxiliary provider resolution (#3866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auxiliary client's auto-detection chain was a black box — when compression, summarization, or memory flush failed, the only clue was a generic 'Request timed out' with no indication of which provider was tried or why it was skipped. Now logs at INFO level: - 'Auxiliary auto-detect: using local/custom (qwen3.5-9b) — skipped: openrouter, nous' when auto-detection picks a provider - 'Auxiliary compression: using auto (qwen3.5-9b) at http://localhost:11434/v1' before each auxiliary call - 'Auxiliary compression: provider custom unavailable, falling back to openrouter' on fallback - Clear warning with actionable guidance when NO provider is available: 'Set OPENROUTER_API_KEY or configure a local model in config.yaml' --- agent/auxiliary_client.py | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index c4a5a184e665..0de263c415d6 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -742,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 @@ -1618,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: @@ -1629,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, From ce2841f3c9af95791755513b279c69e2c170090a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:29:13 -0700 Subject: [PATCH 23/46] feat(gateway): add WeCom (Enterprise WeChat) platform support (#3847) Adds WeCom as a gateway platform adapter using the AI Bot WebSocket gateway for real-time bidirectional communication. No public endpoint or new pip dependencies needed (uses existing aiohttp + httpx). Features: - WebSocket persistent connection with auto-reconnect (exponential backoff) - DM and group messaging with configurable access policies - Media upload/download with AES decryption for encrypted attachments - Markdown rendering, quote context preservation - Proactive + passive reply message modes - Chunked media upload pipeline (512KB chunks) Cherry-picked from PR #1898 by EvilRan with: - Moved to current main (PR was 300 commits behind) - Skipped base.py regressions (reply_to additions are good but belong in a separate PR since they affect all platforms) - Fixed test assertions to match current base class send() signature (reply_to=None kwarg now explicit) - All 16 integration points added surgically to current main - No new pip dependencies (aiohttp + httpx already installed) Fixes #1898 Co-authored-by: EvilRan --- cron/scheduler.py | 1 + gateway/config.py | 26 + gateway/platforms/wecom.py | 1338 +++++++++++++++++ gateway/run.py | 13 +- hermes_cli/config.py | 1 + hermes_cli/gateway.py | 24 + hermes_cli/skills_config.py | 1 + hermes_cli/status.py | 3 + hermes_cli/tools_config.py | 1 + tests/gateway/test_allowlist_startup_check.py | 4 +- .../gateway/test_unauthorized_dm_behavior.py | 4 +- tests/gateway/test_wecom.py | 596 ++++++++ tools/cronjob_tools.py | 2 +- tools/send_message_tool.py | 30 + toolsets.py | 8 +- .../docs/reference/environment-variables.md | 13 + website/docs/reference/toolsets-reference.md | 1 + website/docs/user-guide/messaging/index.md | 5 +- website/docs/user-guide/messaging/wecom.md | 86 ++ website/sidebars.ts | 1 + 20 files changed, 2150 insertions(+), 8 deletions(-) create mode 100644 gateway/platforms/wecom.py create mode 100644 tests/gateway/test_wecom.py create mode 100644 website/docs/user-guide/messaging/wecom.md diff --git a/cron/scheduler.py b/cron/scheduler.py index f73d658521ac..e4299836f41c 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -163,6 +163,7 @@ def _deliver_result(job: dict, content: str) -> None: "homeassistant": Platform.HOMEASSISTANT, "dingtalk": Platform.DINGTALK, "feishu": Platform.FEISHU, + "wecom": Platform.WECOM, "email": Platform.EMAIL, "sms": Platform.SMS, } diff --git a/gateway/config.py b/gateway/config.py index 75db564a4a34..a4ec65ccc955 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -58,6 +58,7 @@ class Platform(Enum): API_SERVER = "api_server" WEBHOOK = "webhook" FEISHU = "feishu" + WECOM = "wecom" @dataclass @@ -278,6 +279,9 @@ class GatewayConfig: # 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]: @@ -841,6 +845,28 @@ def _apply_env_overrides(config: GatewayConfig) -> None: 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/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 97d853646a82..3c0ca1818165 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1027,6 +1027,7 @@ class GatewayRunner: "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( @@ -1036,7 +1037,8 @@ class GatewayRunner: "SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS", "SMS_ALLOW_ALL_USERS", "MATTERMOST_ALLOW_ALL_USERS", "MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS", - "FEISHU_ALLOW_ALL_USERS") + "FEISHU_ALLOW_ALL_USERS", + "WECOM_ALLOW_ALL_USERS") ) if not _any_allowlist and not _allow_all: logger.warning( @@ -1486,6 +1488,13 @@ class GatewayRunner: 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(): @@ -1553,6 +1562,7 @@ class GatewayRunner: 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", @@ -1566,6 +1576,7 @@ class GatewayRunner: 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) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c2fc7a3be9d7..4cdbb0af986b 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -35,6 +35,7 @@ _EXTRA_ENV_KEYS = frozenset({ "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", diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index db15fcc750c7..ba2922771a07 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1351,6 +1351,30 @@ _PLATFORMS = [ "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/skills_config.py b/hermes_cli/skills_config.py index 2fd0227aad71..07ccd0af91f8 100644 --- a/hermes_cli/skills_config.py +++ b/hermes_cli/skills_config.py @@ -29,6 +29,7 @@ PLATFORMS = { "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 d8fa47cee45e..91496d45dbaf 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -142,6 +142,7 @@ PLATFORMS = { "matrix": {"label": "💬 Matrix", "default_toolset": "hermes-matrix"}, "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/tests/gateway/test_allowlist_startup_check.py b/tests/gateway/test_allowlist_startup_check.py index 24df941af1c9..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", "FEISHU_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", "FEISHU_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_unauthorized_dm_behavior.py b/tests/gateway/test_unauthorized_dm_behavior.py index 6f4a9ff02416..02aae301c161 100644 --- a/tests/gateway/test_unauthorized_dm_behavior.py +++ b/tests/gateway/test_unauthorized_dm_behavior.py @@ -20,7 +20,7 @@ def _clear_auth_env(monkeypatch) -> None: "SMS_ALLOWED_USERS", "MATTERMOST_ALLOWED_USERS", "MATRIX_ALLOWED_USERS", - "DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS", + "DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS", "WECOM_ALLOWED_USERS", "GATEWAY_ALLOWED_USERS", "TELEGRAM_ALLOW_ALL_USERS", "DISCORD_ALLOW_ALL_USERS", @@ -31,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", "FEISHU_ALLOW_ALL_USERS", + "DINGTALK_ALLOW_ALL_USERS", "FEISHU_ALLOW_ALL_USERS", "WECOM_ALLOW_ALL_USERS", "GATEWAY_ALLOW_ALL_USERS", ): monkeypatch.delenv(key, raising=False) 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/tools/cronjob_tools.py b/tools/cronjob_tools.py index 5f209e16a4e5..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, feishu, 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/send_message_tool.py b/tools/send_message_tool.py index e0384735a78b..d12eed5099cb 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -130,6 +130,7 @@ def _handle_send(args): "homeassistant": Platform.HOMEASSISTANT, "dingtalk": Platform.DINGTALK, "feishu": Platform.FEISHU, + "wecom": Platform.WECOM, "email": Platform.EMAIL, "sms": Platform.SMS, } @@ -368,6 +369,8 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, 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}"} @@ -794,6 +797,33 @@ 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: diff --git a/toolsets.py b/toolsets.py index a08fe38c0fc0..ad762555bdb0 100644 --- a/toolsets.py +++ b/toolsets.py @@ -357,6 +357,12 @@ TOOLSETS = { "includes": [] }, + "hermes-wecom": { + "description": "WeCom bot toolset - enterprise WeChat messaging (full access)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + "hermes-sms": { "description": "SMS bot toolset - interact with Hermes via SMS (Twilio)", "tools": _HERMES_CORE_TOOLS, @@ -366,7 +372,7 @@ TOOLSETS = { "hermes-gateway": { "description": "Gateway toolset - union of all messaging platform tools", "tools": [], - "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu"] + "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom"] } } diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index b60b05982acf..715c9fbc1507 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -196,6 +196,19 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `DINGTALK_CLIENT_ID` | DingTalk bot AppKey from developer portal ([open.dingtalk.com](https://open.dingtalk.com)) | | `DINGTALK_CLIENT_SECRET` | DingTalk bot AppSecret from developer portal | | `DINGTALK_ALLOWED_USERS` | Comma-separated DingTalk user IDs allowed to message the bot | +| `FEISHU_APP_ID` | Feishu/Lark bot App ID from [open.feishu.cn](https://open.feishu.cn/) | +| `FEISHU_APP_SECRET` | Feishu/Lark bot App Secret | +| `FEISHU_DOMAIN` | `feishu` (China) or `lark` (international). Default: `feishu` | +| `FEISHU_CONNECTION_MODE` | `websocket` (recommended) or `webhook`. Default: `websocket` | +| `FEISHU_ENCRYPT_KEY` | Optional encryption key for webhook mode | +| `FEISHU_VERIFICATION_TOKEN` | Optional verification token for webhook mode | +| `FEISHU_ALLOWED_USERS` | Comma-separated Feishu user IDs allowed to message the bot | +| `FEISHU_HOME_CHANNEL` | Feishu chat ID for cron delivery and notifications | +| `WECOM_BOT_ID` | WeCom AI Bot ID from admin console | +| `WECOM_SECRET` | WeCom AI Bot secret | +| `WECOM_WEBSOCKET_URL` | Custom WebSocket URL (default: `wss://openws.work.weixin.qq.com`) | +| `WECOM_ALLOWED_USERS` | Comma-separated WeCom user IDs allowed to message the bot | +| `WECOM_HOME_CHANNEL` | WeCom chat ID for cron delivery and notifications | | `MATTERMOST_URL` | Mattermost server URL (e.g. `https://mm.example.com`) | | `MATTERMOST_TOKEN` | Bot token or personal access token for Mattermost | | `MATTERMOST_ALLOWED_USERS` | Comma-separated Mattermost user IDs allowed to message the bot | diff --git a/website/docs/reference/toolsets-reference.md b/website/docs/reference/toolsets-reference.md index 133870eb9f46..83cf92e4cef8 100644 --- a/website/docs/reference/toolsets-reference.md +++ b/website/docs/reference/toolsets-reference.md @@ -22,6 +22,7 @@ Toolsets are named bundles of tools that you can enable with `hermes chat --tool | `hermes-api-server` | platform | _(same as hermes-cli)_ | | `hermes-dingtalk` | platform | _(same as hermes-cli)_ | | `hermes-feishu` | platform | _(same as hermes-cli)_ | +| `hermes-wecom` | platform | _(same as hermes-cli)_ | | `hermes-discord` | platform | _(same as hermes-cli)_ | | `hermes-email` | platform | _(same as hermes-cli)_ | | `hermes-gateway` | composite | Union of all messaging platform toolsets | diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index 3dc0e9cd7b6d..9073e45ffaf6 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -6,7 +6,7 @@ description: "Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, # Messaging Gateway -Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages. +Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, WeCom, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages. For the full voice feature set — including CLI microphone mode, spoken replies in messaging, and Discord voice-channel conversations — see [Voice Mode](/docs/user-guide/features/voice-mode) and [Use Voice Mode with Hermes](/docs/guides/use-voice-mode-with-hermes). @@ -28,6 +28,7 @@ flowchart TB mx[Matrix] dt[DingTalk] fs[Feishu/Lark] + wc[WeCom] api["API Server
(OpenAI-compatible)"] wh[Webhooks] end @@ -330,6 +331,7 @@ Each platform has its own toolset: | Matrix | `hermes-matrix` | Full tools including terminal | | DingTalk | `hermes-dingtalk` | Full tools including terminal | | Feishu/Lark | `hermes-feishu` | Full tools including terminal | +| WeCom | `hermes-wecom` | Full tools including terminal | | API Server | `hermes` (default) | Full tools including terminal | | Webhooks | `hermes-webhook` | Full tools including terminal | @@ -347,5 +349,6 @@ Each platform has its own toolset: - [Matrix Setup](matrix.md) - [DingTalk Setup](dingtalk.md) - [Feishu/Lark Setup](feishu.md) +- [WeCom Setup](wecom.md) - [Open WebUI + API Server](open-webui.md) - [Webhooks](webhooks.md) diff --git a/website/docs/user-guide/messaging/wecom.md b/website/docs/user-guide/messaging/wecom.md new file mode 100644 index 000000000000..e5a551b8fd7b --- /dev/null +++ b/website/docs/user-guide/messaging/wecom.md @@ -0,0 +1,86 @@ +--- +sidebar_position: 14 +title: "WeCom (Enterprise WeChat)" +description: "Connect Hermes Agent to WeCom via the AI Bot WebSocket gateway" +--- + +# WeCom (Enterprise WeChat) + +Connect Hermes to [WeCom](https://work.weixin.qq.com/) (企业微信), Tencent's enterprise messaging platform. The adapter uses WeCom's AI Bot WebSocket gateway for real-time bidirectional communication — no public endpoint or webhook needed. + +## Prerequisites + +- A WeCom organization account +- An AI Bot created in the WeCom Admin Console +- The Bot ID and Secret from the bot's credentials page + +## Setup + +### 1. Create an AI Bot + +1. Log in to the [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame) +2. Navigate to **Applications** → **Create Application** → **AI Bot** +3. Configure the bot name and description +4. Copy the **Bot ID** and **Secret** from the credentials page + +### 2. Configure Hermes + +Run the interactive setup: + +```bash +hermes gateway setup +``` + +Select **WeCom** and enter your Bot ID and Secret. + +Or set environment variables in `~/.hermes/.env`: + +```bash +WECOM_BOT_ID=your-bot-id +WECOM_SECRET=your-secret + +# Optional: restrict access +WECOM_ALLOWED_USERS=user_id_1,user_id_2 + +# Optional: home channel for cron/notifications +WECOM_HOME_CHANNEL=chat_id +``` + +### 3. Start the gateway + +```bash +hermes gateway start +``` + +## Features + +- **WebSocket transport** — persistent connection, no public endpoint needed +- **DM and group messaging** — configurable access policies +- **Media support** — images, files, voice, video upload and download +- **AES-encrypted media** — automatic decryption for inbound attachments +- **Quote context** — preserves reply threading +- **Markdown rendering** — rich text responses +- **Auto-reconnect** — exponential backoff on connection drops + +## Configuration Options + +Set these in `config.yaml` under `platforms.wecom.extra`: + +| Key | Default | Description | +|-----|---------|-------------| +| `bot_id` | — | WeCom AI Bot ID (required) | +| `secret` | — | WeCom AI Bot Secret (required) | +| `websocket_url` | `wss://openws.work.weixin.qq.com` | WebSocket gateway URL | +| `dm_policy` | `open` | DM access: `open`, `allowlist`, `disabled`, `pairing` | +| `group_policy` | `open` | Group access: `open`, `allowlist`, `disabled` | +| `allow_from` | `[]` | User IDs allowed for DMs (when dm_policy=allowlist) | +| `group_allow_from` | `[]` | Group IDs allowed (when group_policy=allowlist) | + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| "WECOM_BOT_ID and WECOM_SECRET are required" | Set both env vars or configure in setup wizard | +| "invalid secret (errcode=40013)" | Verify the secret matches your bot's credentials | +| "Timed out waiting for subscribe acknowledgement" | Check network connectivity to `openws.work.weixin.qq.com` | +| Bot doesn't respond in groups | Check `group_policy` setting and group allowlist | diff --git a/website/sidebars.ts b/website/sidebars.ts index a2fafdfce95c..73c9430318b1 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -55,6 +55,7 @@ const sidebars: SidebarsConfig = { 'user-guide/messaging/matrix', 'user-guide/messaging/dingtalk', 'user-guide/messaging/feishu', + 'user-guide/messaging/wecom', 'user-guide/messaging/open-webui', 'user-guide/messaging/webhooks', ], From ccf7bb1102e7adb3cda323f4163d2fe918b87a2a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:38:10 -0700 Subject: [PATCH 24/46] fix(nous): use curated model list instead of full API dump for Nous Portal (#3867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three Nous Portal model selection paths (hermes model, first-time login, setup wizard) were hitting the live /models endpoint and showing every model available — potentially hundreds. Now uses the curated _PROVIDER_MODELS['nous'] list (25 agentic models matching OpenRouter defaults) with 'Enter custom model name' for anything else. Fixed in: - hermes_cli/main.py: _model_flow_nous() - hermes_cli/auth.py: _login_nous() model selection - hermes_cli/setup.py: post-login model selection --- hermes_cli/auth.py | 13 ++++++------- hermes_cli/main.py | 24 +++++++++++++----------- hermes_cli/setup.py | 7 +++---- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 0c503701982d..940a155645f4 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2310,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/main.py b/hermes_cli/main.py index cf445d845294..39d233ad46fb 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1084,14 +1084,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) @@ -1108,11 +1114,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) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index e00aaa4b9724..63e85a66269a 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) From b4ceb541a71e36314426a4040145f061dc125bb9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:51:44 -0700 Subject: [PATCH 25/46] fix(terminal): preserve partial output when command times out (#3868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a command timed out, all captured output was discarded — the agent only saw 'Command timed out after Xs' with zero context. Now returns the buffered output followed by a timeout marker, matching the existing interrupt path behavior. Salvaged from PR #3286 by @binhnt92. Co-authored-by: nguyen binh --- tests/tools/test_terminal_timeout_output.py | 27 +++++++++++++++++++++ tools/environments/local.py | 7 +++++- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 tests/tools/test_terminal_timeout_output.py 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/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) From 366bfc3c76e9fd43ab5195f4a8670928f0440bc9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:53:28 -0700 Subject: [PATCH 26/46] fix(setup): auto-install matrix-nio during hermes setup (#3873) Setup previously only printed a manual install hint for matrix-nio, causing the gateway to crash with 'matrix-nio not installed' after configuring Matrix. Now auto-installs matrix-nio (or matrix-nio[e2e] when E2EE is enabled) using the same uv-first/pip-fallback pattern as Daytona and Modal backends. Also adds hermes-agent[matrix] to the [all] extra in pyproject.toml and a regression test to keep it there. Co-authored-by: Gutslabs Co-authored-by: cutepawss --- hermes_cli/setup.py | 32 ++++++++++++++++++++++++++++++-- pyproject.toml | 1 + tests/test_project_metadata.py | 18 ++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 tests/test_project_metadata.py diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 63e85a66269a..35695144d3e8 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2709,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/pyproject.toml b/pyproject.toml index ac410ff754b4..38974e3287d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ all = [ "hermes-agent[modal]", "hermes-agent[daytona]", "hermes-agent[messaging]", + "hermes-agent[matrix]", "hermes-agent[cron]", "hermes-agent[cli]", "hermes-agent[dev]", 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"] From 839f798b746770fcdd08608b6e3e49a2ede5b744 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:53:59 -0700 Subject: [PATCH 27/46] feat(telegram): add group mention gating and regex triggers (#3870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Discord-style mention gating for Telegram groups: - telegram.require_mention: gate group messages (default: false) - telegram.mention_patterns: regex wake-word triggers - telegram.free_response_chats: bypass gating for specific chats When require_mention is enabled, group messages are accepted only for: - slash commands - replies to the bot - @botusername mentions - regex wake-word pattern matches DMs remain unrestricted. @mention text is stripped before passing to the agent. Invalid regex patterns are ignored with a warning. Config bridges follow the existing Discord pattern (yaml → env vars). Cherry-picked and adapted from PR #1977 by mcleay. Fixed ChatType comparison to work without python-telegram-bot installed (uses string matching instead of enum, consistent with other entity_type checks). Co-authored-by: mcleay --- gateway/config.py | 18 ++ gateway/platforms/telegram.py | 155 +++++++++++++++++- tests/gateway/test_telegram_group_gating.py | 110 +++++++++++++ website/docs/user-guide/messaging/telegram.md | 30 +++- 4 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 tests/gateway/test_telegram_group_gating.py diff --git a/gateway/config.py b/gateway/config.py index a4ec65ccc955..c8ce89a7d637 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -515,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, {}) @@ -539,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. " diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 5f497221fca0..40aea55aba32 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,7 @@ class TelegramAdapter(BasePlatformAdapter): super().__init__(config, Platform.TELEGRAM) self._app: Optional[Application] = None self._bot: Optional[Bot] = None + 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. @@ -1325,6 +1327,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. @@ -1334,14 +1478,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) @@ -1350,6 +1499,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) @@ -1493,6 +1644,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 @@ -1516,7 +1669,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/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/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 794845effbb0..be99eaa75792 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -161,9 +161,35 @@ Configure the TTS provider in your `config.yaml` under the `tts.provider` key. Hermes Agent works in Telegram group chats with a few considerations: - **Privacy mode** determines what messages the bot can see (see [Step 3](#step-3-privacy-mode-critical-for-groups)) -- When privacy mode is on, **@mention the bot** (e.g., `@my_hermes_bot what's the weather?`) or **reply to its messages** to interact -- When privacy mode is off (or bot is admin), the bot sees all messages and can participate naturally - `TELEGRAM_ALLOWED_USERS` still applies — only authorized users can trigger the bot, even in groups +- You can keep the bot from responding to ordinary group chatter with `telegram.require_mention: true` +- With `telegram.require_mention: true`, group messages are accepted when they are: + - slash commands + - replies to one of the bot's messages + - `@botusername` mentions + - matches for one of your configured regex wake words in `telegram.mention_patterns` +- If `telegram.require_mention` is left unset or false, Hermes keeps the previous open-group behavior and responds to normal group messages it can see + +### Example group trigger configuration + +Add this to `~/.hermes/config.yaml`: + +```yaml +telegram: + require_mention: true + mention_patterns: + - "^\\s*chompy\\b" +``` + +This example allows all the usual direct triggers plus messages that begin with `chompy`, even if they do not use an `@mention`. + +### Notes on `mention_patterns` + +- Patterns use Python regular expressions +- Matching is case-insensitive +- Patterns are checked against both text messages and media captions +- Invalid regex patterns are ignored with a warning in the gateway logs rather than crashing the bot +- If you want a pattern to match only at the start of a message, anchor it with `^` ## Private Chat Topics (Bot API 9.4) From fd29933a6d44fdb1168fed5a50df07aba84c8387 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:54:36 -0700 Subject: [PATCH 28/46] fix: use argparse entrypoint in top-level launcher (#3874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ./hermes convenience script still used the legacy Fire-based cli.main wrapper, which doesn't support subcommands (gateway, cron, doctor, etc.). The installed 'hermes' command already uses hermes_cli.main:main (argparse) — this aligns the launcher. Salvaged from PR #2009 by gito369. --- hermes | 11 ++++---- tests/hermes_cli/test_launcher.py | 42 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 tests/hermes_cli/test_launcher.py 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/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"] From 227601c20067250f0b12890385153a489b1b6f42 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:55:23 -0700 Subject: [PATCH 29/46] feat(discord): add message processing reactions (salvage #1980) (#3871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds lifecycle hooks to the base platform adapter so Discord (and future platforms) can react to message processing events: 👀 when processing starts ✅ on successful completion (delivery confirmed) ❌ on failure, error, or cancellation Implementation: - base.py: on_processing_start/on_processing_complete hooks with _run_processing_hook error isolation wrapper; delivery tracking via _record_delivery closure for accurate success detection - discord.py: _add_reaction/_remove_reaction helpers + hook overrides - Tests for base hook lifecycle and Discord-specific reactions Co-authored-by: alanwilhelm --- gateway/platforms/base.py | 43 ++++++ gateway/platforms/discord.py | 35 +++++ tests/gateway/test_base_topic_sessions.py | 87 +++++++++++ tests/gateway/test_discord_reactions.py | 170 ++++++++++++++++++++++ 4 files changed, 335 insertions(+) create mode 100644 tests/gateway/test_discord_reactions.py 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/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() From 1c900c45e314c6b9580f5f05bc6e3ba967243635 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:56:07 -0700 Subject: [PATCH 30/46] fix(agent): support full context length resolution for direct Gemini API endpoints (#3876) * add .aac audio file format support to transcription tool * fix(agent): support full context length resolution for direct Gemini API endpoints Add generativelanguage.googleapis.com to _URL_TO_PROVIDER so direct Gemini API users get correct 1M+ context length instead of the 128K unknown-proxy fallback. Co-authored-by: bb873 --------- Co-authored-by: Adrian Scott Co-authored-by: bb873 --- agent/model_metadata.py | 1 + 1 file changed, 1 insertion(+) 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", From 560245879417625e41e416300b8a78d5d727c6f1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:33:47 -0700 Subject: [PATCH 31/46] security: harden dangerous command detection and add file tool path guards (#3872) Closes gaps that allowed an agent to expose Docker's Remote API to the internet by writing to /etc/docker/daemon.json. Terminal tool (approval.py): - chmod: now catches 666 and symbolic modes (o+w, a+w), not just 777 - cp/mv/install: detected when targeting /etc/ - sed -i/--in-place: detected when targeting /etc/ File tools (file_tools.py): - write_file and patch now refuse to write to sensitive system paths (/etc/, /boot/, /usr/lib/systemd/, docker.sock) - Directs users to the terminal tool (which has approval prompts) for system file modifications --- tools/approval.py | 8 ++++++-- tools/file_tools.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/tools/approval.py b/tools/approval.py index 8ae52407f9c6..e7313b002fe5 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -41,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"), @@ -71,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)"), ] 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) From 649d149438eadf72ce3f9cac56177796f338a5d2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:36:07 -0700 Subject: [PATCH 32/46] feat(telegram): add webhook mode as alternative to polling (#3880) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When TELEGRAM_WEBHOOK_URL is set, the adapter starts an HTTP webhook server (via python-telegram-bot's start_webhook()) instead of long polling. This enables cloud platforms like Fly.io and Railway to auto-wake suspended machines on inbound HTTP traffic. Polling remains the default — no behavior change unless the env var is set. Env vars: TELEGRAM_WEBHOOK_URL Public HTTPS URL for Telegram to push to TELEGRAM_WEBHOOK_PORT Local listen port (default 8443) TELEGRAM_WEBHOOK_SECRET Secret token for update verification Cherry-picked and adapted from PR #2022 by SHL0MS. Preserved all current main enhancements (network error recovery, polling conflict detection, DM topics setup). Co-authored-by: SHL0MS --- gateway/platforms/telegram.py | 86 ++++++++++++++++++++++++++--------- 1 file changed, 65 insertions(+), 21 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 40aea55aba32..e17d104a68ae 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -123,6 +123,7 @@ 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 @@ -458,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", @@ -552,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 @@ -592,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. @@ -620,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() From 09def65effe9ceed781a5e33e8948538a656a26b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:49:34 -0700 Subject: [PATCH 33/46] fix(migration): expand OpenClaw migration to cover full data footprint (#3869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-referenced the OpenClaw Zod schema and TypeScript source against our migration script. Found and fixed: Expanded data sources: - Legacy config fallback: clawdbot.json, moldbot.json - Legacy dir fallback: ~/.clawdbot/, ~/.moldbot/ - API keys from ~/.openclaw/.env and auth-profiles.json - Personal skills from ~/.agents/skills/ - Project skills from workspace/.agents/skills/ - BOOTSTRAP.md archived (was silently skipped) - Expanded env key allowlist: DEEPSEEK, GEMINI, ZAI, MINIMAX Fixed wrong config paths (verified against Zod schema): - humanDelay.enabled → humanDelay.mode (field doesn't exist as .enabled) - agents.defaults.exec.timeout → tools.exec.timeoutSec (wrong path + name) - messages.tts.elevenlabs.voiceId → messages.tts.providers.elevenlabs.voiceId - session.resetTriggers (string[]) → session.reset (structured object) - approvals.mode → approvals.exec.mode (no top-level mode) - browser.inactivityTimeoutMs → doesn't exist; map cdpUrl+headless instead - tools.webSearch.braveApiKey → tools.web.search.brave.apiKey - tools.exec.timeout → tools.exec.timeoutSec Added SecretRef resolution: - All token/apiKey fields in OpenClaw can be strings, env templates (${VAR}), or SecretRef objects ({source:'env',id:'VAR'}). Added resolve_secret_input() to handle all three forms. Fixed auth-profiles.json: - Canonical field is 'key' not 'apiKey' (though alias accepted) - File wraps entries in a 'profiles' key — now handled Fixed TTS config: - Provider settings at messages.tts.providers.{name} (not flat) - Also checks top-level 'talk' config as fallback source Docs updated with new sources and key list. --- hermes_cli/claw.py | 14 +- .../scripts/openclaw_to_hermes.py | 259 +++++++++++++----- website/docs/reference/cli-commands.md | 8 +- 3 files changed, 216 insertions(+), 65 deletions(-) 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/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py index f607ee56ba8c..f2e3d7af68dc 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: @@ -1244,22 +1323,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 +1369,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 +1403,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 +1433,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 +1448,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 +1637,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 +1909,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 +1925,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 +2004,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 +2223,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 +2239,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 +2262,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 +2305,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/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index d27a34f2ccad..eeec7459ded9 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -466,7 +466,7 @@ hermes insights [--days N] [--source platform] hermes claw migrate [options] ``` -Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom path) and writes to `~/.hermes`. +Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom path) and writes to `~/.hermes`. Automatically detects legacy directory names (`~/.clawdbot`, `~/.moldbot`) and config filenames (`clawdbot.json`, `moldbot.json`). | Option | Description | |--------|-------------| @@ -497,6 +497,8 @@ The migration covers your entire OpenClaw footprint. Items are either **directly | **MCP servers** | MCP server definitions | `config.yaml` mcp\_servers | | **User skills** | Workspace skills | `~/.hermes/skills/openclaw-imports/` | | **Shared skills** | `~/.openclaw/skills/` | `~/.hermes/skills/openclaw-imports/` | +| **Personal skills** | `~/.agents/skills/` (cross-project) | `~/.hermes/skills/openclaw-imports/` | +| **Project skills** | `workspace/.agents/skills/` | `~/.hermes/skills/openclaw-imports/` | | **Command allowlist** | Exec approval patterns | `config.yaml` command\_allowlist | | **Messaging settings** | Allowlists, working directory | `config.yaml` messaging section | | **Session policies** | Daily/idle reset policies | `config.yaml` session\_reset | @@ -513,7 +515,7 @@ The migration covers your entire OpenClaw footprint. Items are either **directly | **WhatsApp settings** | Allowlist | `~/.hermes/.env` | | **Signal settings** | Account, HTTP URL, allowlist | `~/.hermes/.env` | | **Channel config** | Matrix, Mattermost, IRC, group settings | `config.yaml` + archive | -| **Provider API keys** | OPENROUTER\_API\_KEY, OPENAI\_API\_KEY, ANTHROPIC\_API\_KEY, etc. | `~/.hermes/.env` (requires `--migrate-secrets`) | +| **Provider API keys** | Config, `~/.openclaw/.env`, and `auth-profiles.json` | `~/.hermes/.env` (requires `--migrate-secrets`) | #### Archived for manual review @@ -531,7 +533,7 @@ These OpenClaw features don't have direct Hermes equivalents. They're saved to a ### Security -API keys are **not migrated by default**. The `--preset full` preset enables secret migration, but only for an allowlist of known keys: `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `ELEVENLABS_API_KEY`, `TELEGRAM_BOT_TOKEN`, and `VOICE_TOOLS_OPENAI_KEY`. All other secrets are skipped. +API keys are **not migrated by default**. The `--preset full` preset enables secret migration. Keys are collected from three sources (config values take priority, then `.env`, then `auth-profiles.json`) for these targets: `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `DEEPSEEK_API_KEY`, `GEMINI_API_KEY`, `ZAI_API_KEY`, `MINIMAX_API_KEY`, `ELEVENLABS_API_KEY`, `TELEGRAM_BOT_TOKEN`, and `VOICE_TOOLS_OPENAI_KEY`. All other secrets are skipped. ### Examples From a347921314a0ad5faaeb0c40caed692bb98b737d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 23:58:12 -0700 Subject: [PATCH 34/46] docs: comprehensive OpenClaw migration guide (#3900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New standalone guide at guides/migrate-from-openclaw.md with: - Complete config key mapping tables for every category - Agent behavior mappings (thinkingDefault → reasoning_effort, etc.) - Session reset policy mapping (session.reset vs resetTriggers) - TTS dual-source explanation (messages.tts.providers + talk config) - MCP server field-by-field mapping - Messaging platform table with exact config paths and env vars - API key resolution: 3 sources, priority order, supported targets - SecretRef handling: plain strings, env templates, SecretRef objects - Post-migration checklist (6 steps) - Troubleshooting section - Complete archived items table with recreation guidance CLI commands reference condensed to summary + link to full guide. Added to sidebar under Guides & Tutorials. --- website/docs/guides/migrate-from-openclaw.md | 242 +++++++++++++++++++ website/docs/reference/cli-commands.md | 57 +---- website/sidebars.ts | 1 + 3 files changed, 248 insertions(+), 52 deletions(-) create mode 100644 website/docs/guides/migrate-from-openclaw.md diff --git a/website/docs/guides/migrate-from-openclaw.md b/website/docs/guides/migrate-from-openclaw.md new file mode 100644 index 000000000000..6c8304a6e43c --- /dev/null +++ b/website/docs/guides/migrate-from-openclaw.md @@ -0,0 +1,242 @@ +--- +sidebar_position: 7 +title: "Migrate from OpenClaw" +description: "Complete guide to migrating your OpenClaw / Clawdbot setup to Hermes Agent — what gets migrated, how config maps, and what to check after." +--- + +# Migrate from OpenClaw + +`hermes claw migrate` imports your OpenClaw (or legacy Clawdbot/Moldbot) setup into Hermes. This guide covers exactly what gets migrated, the config key mappings, and what to verify after migration. + +## Quick start + +```bash +# Preview what would happen (no files changed) +hermes claw migrate --dry-run + +# Run the migration (secrets excluded by default) +hermes claw migrate + +# Full migration including API keys +hermes claw migrate --preset full +``` + +The migration reads from `~/.openclaw/` by default. If you still have a legacy `~/.clawdbot/` or `~/.moldbot/` directory, it's detected automatically. Same for legacy config filenames (`clawdbot.json`, `moldbot.json`). + +## Options + +| Option | Description | +|--------|-------------| +| `--dry-run` | Preview what would be migrated without writing anything. | +| `--preset ` | `full` (default, includes secrets) or `user-data` (excludes API keys). | +| `--overwrite` | Overwrite existing Hermes files on conflicts (default: skip). | +| `--migrate-secrets` | Include API keys (on by default with `--preset full`). | +| `--source ` | Custom OpenClaw directory. | +| `--workspace-target ` | Where to place `AGENTS.md`. | +| `--skill-conflict ` | `skip` (default), `overwrite`, or `rename`. | +| `--yes` | Skip confirmation prompt. | + +## What gets migrated + +### Persona, memory, and instructions + +| What | OpenClaw source | Hermes destination | Notes | +|------|----------------|-------------------|-------| +| Persona | `workspace/SOUL.md` | `~/.hermes/SOUL.md` | Direct copy | +| Workspace instructions | `workspace/AGENTS.md` | `AGENTS.md` in `--workspace-target` | Requires `--workspace-target` flag | +| Long-term memory | `workspace/MEMORY.md` | `~/.hermes/memories/MEMORY.md` | Parsed into entries, merged with existing, deduped. Uses `§` delimiter. | +| User profile | `workspace/USER.md` | `~/.hermes/memories/USER.md` | Same entry-merge logic as memory. | +| Daily memory files | `workspace/memory/*.md` | `~/.hermes/memories/MEMORY.md` | All daily files merged into main memory. | + +All workspace files also check `workspace.default/` as a fallback path. + +### Skills (4 sources) + +| Source | OpenClaw location | Hermes destination | +|--------|------------------|-------------------| +| Workspace skills | `workspace/skills/` | `~/.hermes/skills/openclaw-imports/` | +| Managed/shared skills | `~/.openclaw/skills/` | `~/.hermes/skills/openclaw-imports/` | +| Personal cross-project | `~/.agents/skills/` | `~/.hermes/skills/openclaw-imports/` | +| Project-level shared | `workspace/.agents/skills/` | `~/.hermes/skills/openclaw-imports/` | + +Skill conflicts are handled by `--skill-conflict`: `skip` leaves the existing Hermes skill, `overwrite` replaces it, `rename` creates a `-imported` copy. + +### Model and provider configuration + +| What | OpenClaw config path | Hermes destination | Notes | +|------|---------------------|-------------------|-------| +| Default model | `agents.defaults.model` | `config.yaml` → `model` | Can be a string or `{primary, fallbacks}` object | +| Custom providers | `models.providers.*` | `config.yaml` → `custom_providers` | Maps `baseUrl`, `apiType` ("openai"→"chat_completions", "anthropic"→"anthropic_messages") | +| Provider API keys | `models.providers.*.apiKey` | `~/.hermes/.env` | Requires `--migrate-secrets`. See [API key resolution](#api-key-resolution) below. | + +### Agent behavior + +| What | OpenClaw config path | Hermes config path | Mapping | +|------|---------------------|-------------------|---------| +| Max turns | `agents.defaults.timeoutSeconds` | `agent.max_turns` | `timeoutSeconds / 10`, capped at 200 | +| Verbose mode | `agents.defaults.verboseDefault` | `agent.verbose` | "off" / "on" / "full" | +| Reasoning effort | `agents.defaults.thinkingDefault` | `agent.reasoning_effort` | "always"/"high" → "high", "auto"/"medium" → "medium", "off"/"low"/"none"/"minimal" → "low" | +| Compression | `agents.defaults.compaction.mode` | `compression.enabled` | "off" → false, anything else → true | +| Compression model | `agents.defaults.compaction.model` | `compression.summary_model` | Direct string copy | +| Human delay | `agents.defaults.humanDelay.mode` | `human_delay.mode` | "natural" / "custom" / "off" | +| Human delay timing | `agents.defaults.humanDelay.minMs` / `.maxMs` | `human_delay.min_ms` / `.max_ms` | Direct copy | +| Timezone | `agents.defaults.userTimezone` | `timezone` | Direct string copy | +| Exec timeout | `tools.exec.timeoutSec` | `terminal.timeout` | Direct copy (field is `timeoutSec`, not `timeout`) | +| Docker sandbox | `agents.defaults.sandbox.backend` | `terminal.backend` | "docker" → "docker" | +| Docker image | `agents.defaults.sandbox.docker.image` | `terminal.docker_image` | Direct copy | + +### Session reset policies + +| OpenClaw config path | Hermes config path | Notes | +|---------------------|-------------------|-------| +| `session.reset.mode` | `session_reset.mode` | "daily", "idle", or both | +| `session.reset.atHour` | `session_reset.at_hour` | Hour (0–23) for daily reset | +| `session.reset.idleMinutes` | `session_reset.idle_minutes` | Minutes of inactivity | + +Note: OpenClaw also has `session.resetTriggers` (a simple string array like `["daily", "idle"]`). If the structured `session.reset` isn't present, the migration falls back to inferring from `resetTriggers`. + +### MCP servers + +| OpenClaw field | Hermes field | Notes | +|----------------|-------------|-------| +| `mcp.servers.*.command` | `mcp_servers.*.command` | Stdio transport | +| `mcp.servers.*.args` | `mcp_servers.*.args` | | +| `mcp.servers.*.env` | `mcp_servers.*.env` | | +| `mcp.servers.*.cwd` | `mcp_servers.*.cwd` | | +| `mcp.servers.*.url` | `mcp_servers.*.url` | HTTP/SSE transport | +| `mcp.servers.*.tools.include` | `mcp_servers.*.tools.include` | Tool filtering | +| `mcp.servers.*.tools.exclude` | `mcp_servers.*.tools.exclude` | | + +### TTS (text-to-speech) + +TTS settings are read from **two** OpenClaw config locations with this priority: + +1. `messages.tts.providers.{provider}.*` (canonical location) +2. Top-level `talk.providers.{provider}.*` (fallback) +3. Legacy flat keys `messages.tts.{provider}.*` (oldest format) + +| What | Hermes destination | +|------|-------------------| +| Provider name | `config.yaml` → `tts.provider` | +| ElevenLabs voice ID | `config.yaml` → `tts.elevenlabs.voice_id` | +| ElevenLabs model ID | `config.yaml` → `tts.elevenlabs.model_id` | +| OpenAI model | `config.yaml` → `tts.openai.model` | +| OpenAI voice | `config.yaml` → `tts.openai.voice` | +| Edge TTS voice | `config.yaml` → `tts.edge.voice` | +| TTS assets | `~/.hermes/tts/` (file copy) | + +### Messaging platforms + +| Platform | OpenClaw config path | Hermes `.env` variable | Notes | +|----------|---------------------|----------------------|-------| +| Telegram | `channels.telegram.botToken` | `TELEGRAM_BOT_TOKEN` | Token can be string or [SecretRef](#secretref-handling) | +| Telegram | `credentials/telegram-default-allowFrom.json` | `TELEGRAM_ALLOWED_USERS` | Comma-joined from `allowFrom[]` array | +| Discord | `channels.discord.token` | `DISCORD_BOT_TOKEN` | | +| Discord | `channels.discord.allowFrom` | `DISCORD_ALLOWED_USERS` | | +| Slack | `channels.slack.botToken` | `SLACK_BOT_TOKEN` | | +| Slack | `channels.slack.appToken` | `SLACK_APP_TOKEN` | | +| Slack | `channels.slack.allowFrom` | `SLACK_ALLOWED_USERS` | | +| WhatsApp | `channels.whatsapp.allowFrom` | `WHATSAPP_ALLOWED_USERS` | Auth via Baileys QR pairing (not a token) | +| Signal | `channels.signal.account` | `SIGNAL_ACCOUNT` | | +| Signal | `channels.signal.httpUrl` | `SIGNAL_HTTP_URL` | | +| Signal | `channels.signal.allowFrom` | `SIGNAL_ALLOWED_USERS` | | +| Matrix | `channels.matrix.botToken` | `MATRIX_ACCESS_TOKEN` | Via deep-channels migration | +| Mattermost | `channels.mattermost.botToken` | `MATTERMOST_BOT_TOKEN` | Via deep-channels migration | + +### Other config + +| What | OpenClaw path | Hermes path | Notes | +|------|-------------|-------------|-------| +| Approval mode | `approvals.exec.mode` | `config.yaml` → `approvals.mode` | "auto"→"off", "always"→"manual", "smart"→"smart" | +| Command allowlist | `exec-approvals.json` | `config.yaml` → `command_allowlist` | Patterns merged and deduped | +| Browser CDP URL | `browser.cdpUrl` | `config.yaml` → `browser.cdp_url` | | +| Browser headless | `browser.headless` | `config.yaml` → `browser.headless` | | +| Brave search key | `tools.web.search.brave.apiKey` | `.env` → `BRAVE_API_KEY` | Requires `--migrate-secrets` | +| Gateway auth token | `gateway.auth.token` | `.env` → `HERMES_GATEWAY_TOKEN` | Requires `--migrate-secrets` | +| Working directory | `agents.defaults.workspace` | `.env` → `MESSAGING_CWD` | | + +### Archived (no direct Hermes equivalent) + +These are saved to `~/.hermes/migration/openclaw//archive/` for manual review: + +| What | Archive file | How to recreate in Hermes | +|------|-------------|--------------------------| +| `IDENTITY.md` | `archive/workspace/IDENTITY.md` | Merge into `SOUL.md` | +| `TOOLS.md` | `archive/workspace/TOOLS.md` | Hermes has built-in tool instructions | +| `HEARTBEAT.md` | `archive/workspace/HEARTBEAT.md` | Use cron jobs for periodic tasks | +| `BOOTSTRAP.md` | `archive/workspace/BOOTSTRAP.md` | Use context files or skills | +| Cron jobs | `archive/cron-config.json` | Recreate with `hermes cron create` | +| Plugins | `archive/plugins-config.json` | See [plugins guide](../user-guide/features/hooks.md) | +| Hooks/webhooks | `archive/hooks-config.json` | Use `hermes webhook` or gateway hooks | +| Memory backend | `archive/memory-backend-config.json` | Configure via `hermes honcho` | +| Skills registry | `archive/skills-registry-config.json` | Use `hermes skills config` | +| UI/identity | `archive/ui-identity-config.json` | Use `/skin` command | +| Logging | `archive/logging-diagnostics-config.json` | Set in `config.yaml` logging section | +| Multi-agent list | `archive/agents-list.json` | Use Hermes profiles | +| Channel bindings | `archive/bindings.json` | Manual setup per platform | +| Complex channels | `archive/channels-deep-config.json` | Manual platform config | + +## API key resolution + +When `--migrate-secrets` is enabled, API keys are collected from **three sources** in priority order: + +1. **Config values** — `models.providers.*.apiKey` and TTS provider keys in `openclaw.json` +2. **Environment file** — `~/.openclaw/.env` (keys like `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, etc.) +3. **Auth profiles** — `~/.openclaw/agents/main/agent/auth-profiles.json` (per-agent credentials) + +Config values take priority. The `.env` fills any gaps. Auth profiles fill whatever remains. + +### Supported key targets + +`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `DEEPSEEK_API_KEY`, `GEMINI_API_KEY`, `ZAI_API_KEY`, `MINIMAX_API_KEY`, `ELEVENLABS_API_KEY`, `TELEGRAM_BOT_TOKEN`, `VOICE_TOOLS_OPENAI_KEY` + +Keys not in this allowlist are never copied. + +## SecretRef handling + +OpenClaw config values for tokens and API keys can be in three formats: + +```json +// Plain string +"channels": { "telegram": { "botToken": "123456:ABC-DEF..." } } + +// Environment template +"channels": { "telegram": { "botToken": "${TELEGRAM_BOT_TOKEN}" } } + +// SecretRef object +"channels": { "telegram": { "botToken": { "source": "env", "id": "TELEGRAM_BOT_TOKEN" } } } +``` + +The migration resolves all three formats. For env templates and SecretRef objects with `source: "env"`, it looks up the value in `~/.openclaw/.env`. SecretRef objects with `source: "file"` or `source: "exec"` can't be resolved automatically — those values must be added to Hermes manually after migration. + +## After migration + +1. **Check the migration report** — printed on completion with counts of migrated, skipped, and conflicting items. + +2. **Review archived files** — anything in `~/.hermes/migration/openclaw//archive/` needs manual attention. + +3. **Verify API keys** — run `hermes status` to check provider authentication. + +4. **Test messaging** — if you migrated platform tokens, restart the gateway: `systemctl --user restart hermes-gateway` + +5. **Check session policies** — verify `hermes config get session_reset` matches your expectations. + +6. **Re-pair WhatsApp** — WhatsApp uses QR code pairing (Baileys), not token migration. Run `hermes whatsapp` to pair. + +## Troubleshooting + +### "OpenClaw directory not found" + +The migration checks `~/.openclaw/`, then `~/.clawdbot/`, then `~/.moldbot/`. If your installation is elsewhere, use `--source /path/to/your/openclaw`. + +### "No provider API keys found" + +Keys might be in your `.env` file instead of `openclaw.json`. The migration checks both — make sure `~/.openclaw/.env` exists and has the keys. If keys use `source: "file"` or `source: "exec"` SecretRefs, they can't be resolved automatically. + +### Skills not appearing after migration + +Imported skills land in `~/.hermes/skills/openclaw-imports/`. Start a new session for them to take effect, or run `/skills` to verify they're loaded. + +### TTS voice not migrated + +OpenClaw stores TTS settings in two places: `messages.tts.providers.*` and the top-level `talk` config. The migration checks both. If your voice ID was set via the OpenClaw UI (stored in a different path), you may need to set it manually: `hermes config set tts.elevenlabs.voice_id YOUR_VOICE_ID`. diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index eeec7459ded9..a9f12d76be55 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -481,59 +481,15 @@ Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom pat ### What gets migrated -The migration covers your entire OpenClaw footprint. Items are either **directly imported** into Hermes equivalents or **archived** for manual review when there's no direct mapping. +The migration covers 30+ categories across persona, memory, skills, model providers, messaging platforms, agent behavior, session policies, MCP servers, TTS, and more. Items are either **directly imported** into Hermes equivalents or **archived** for manual review. -#### Directly imported +**Directly imported:** SOUL.md, MEMORY.md, USER.md, AGENTS.md, skills (4 source directories), default model, custom providers, MCP servers, messaging platform tokens and allowlists (Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Mattermost), agent defaults (reasoning effort, compression, human delay, timezone, sandbox), session reset policies, approval rules, TTS config, browser settings, tool settings, exec timeout, command allowlist, gateway config, and API keys from 3 sources. -| Category | OpenClaw source | Hermes destination | -|----------|----------------|-------------------| -| **Persona** | `SOUL.md` | `~/.hermes/SOUL.md` | -| **Workspace instructions** | `AGENTS.md` | `AGENTS.md` in target workspace | -| **Long-term memory** | `MEMORY.md` | `~/.hermes/MEMORY.md` (merged with existing entries) | -| **User profile** | `USER.md` | `~/.hermes/USER.md` (merged with existing entries) | -| **Daily memory files** | `workspace/memory/` | Merged into `~/.hermes/MEMORY.md` | -| **Default model** | Config model setting | `config.yaml` model section | -| **Custom providers** | Provider definitions (baseUrl, apiType, headers) | `config.yaml` custom\_providers | -| **MCP servers** | MCP server definitions | `config.yaml` mcp\_servers | -| **User skills** | Workspace skills | `~/.hermes/skills/openclaw-imports/` | -| **Shared skills** | `~/.openclaw/skills/` | `~/.hermes/skills/openclaw-imports/` | -| **Personal skills** | `~/.agents/skills/` (cross-project) | `~/.hermes/skills/openclaw-imports/` | -| **Project skills** | `workspace/.agents/skills/` | `~/.hermes/skills/openclaw-imports/` | -| **Command allowlist** | Exec approval patterns | `config.yaml` command\_allowlist | -| **Messaging settings** | Allowlists, working directory | `config.yaml` messaging section | -| **Session policies** | Daily/idle reset policies | `config.yaml` session\_reset | -| **Agent defaults** | Compaction, context, thinking settings | `config.yaml` agent section | -| **Browser settings** | Browser automation config | `config.yaml` browser section | -| **Tool settings** | Exec timeout, sandbox, web search | `config.yaml` tools section | -| **Approval rules** | Approval mode and rules | `config.yaml` approvals section | -| **TTS config** | TTS provider and voice | `config.yaml` tts section | -| **TTS assets** | Workspace TTS files | `~/.hermes/tts/` | -| **Gateway config** | Gateway port and auth | `config.yaml` gateway section | -| **Telegram settings** | Bot token, allowlist | `~/.hermes/.env` | -| **Discord settings** | Bot token, allowlist | `~/.hermes/.env` | -| **Slack settings** | Bot/app tokens, allowlist | `~/.hermes/.env` | -| **WhatsApp settings** | Allowlist | `~/.hermes/.env` | -| **Signal settings** | Account, HTTP URL, allowlist | `~/.hermes/.env` | -| **Channel config** | Matrix, Mattermost, IRC, group settings | `config.yaml` + archive | -| **Provider API keys** | Config, `~/.openclaw/.env`, and `auth-profiles.json` | `~/.hermes/.env` (requires `--migrate-secrets`) | +**Archived for manual review:** Cron jobs, plugins, hooks/webhooks, memory backend (QMD), skills registry config, UI/identity, logging, multi-agent setup, channel bindings, IDENTITY.md, TOOLS.md, HEARTBEAT.md, BOOTSTRAP.md. -#### Archived for manual review +**API key resolution** checks three sources in priority order: config values → `~/.openclaw/.env` → `auth-profiles.json`. All token fields handle plain strings, env templates (`${VAR}`), and SecretRef objects. -These OpenClaw features don't have direct Hermes equivalents. They're saved to an archive directory for you to review and recreate manually. - -| Category | What's archived | How to recreate in Hermes | -|----------|----------------|--------------------------| -| **Cron / scheduled tasks** | Job definitions | Recreate with `hermes cron create` | -| **Plugins** | Plugin configuration, installed extensions | Check the [plugins guide](../user-guide/features/hooks.md) | -| **Hooks and webhooks** | Internal hooks, webhooks, Gmail integration | Use `hermes webhook` or gateway hooks | -| **Memory backend** | QMD, vector search, citation settings | Configure Honcho via `hermes honcho` | -| **Skills registry** | Per-skill enabled/config/env settings | Use `hermes skills config` | -| **UI and identity** | Theme, assistant identity, display prefs | Use `/skin` command or `config.yaml` | -| **Logging** | Diagnostics configuration | Set in `config.yaml` logging section | - -### Security - -API keys are **not migrated by default**. The `--preset full` preset enables secret migration. Keys are collected from three sources (config values take priority, then `.env`, then `auth-profiles.json`) for these targets: `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `DEEPSEEK_API_KEY`, `GEMINI_API_KEY`, `ZAI_API_KEY`, `MINIMAX_API_KEY`, `ELEVENLABS_API_KEY`, `TELEGRAM_BOT_TOKEN`, and `VOICE_TOOLS_OPENAI_KEY`. All other secrets are skipped. +For the complete config key mapping, SecretRef handling details, and post-migration checklist, see the **[full migration guide](../guides/migrate-from-openclaw.md)**. ### Examples @@ -549,9 +505,6 @@ hermes claw migrate --preset user-data --overwrite # Migrate from a custom OpenClaw path hermes claw migrate --source /home/user/old-openclaw - -# Migrate and place AGENTS.md in a specific project -hermes claw migrate --workspace-target /home/user/my-project ``` ## Maintenance commands diff --git a/website/sidebars.ts b/website/sidebars.ts index 73c9430318b1..082b9ce8fd3e 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -26,6 +26,7 @@ const sidebars: SidebarsConfig = { 'guides/use-mcp-with-hermes', 'guides/use-soul-with-hermes', 'guides/use-voice-mode-with-hermes', + 'guides/migrate-from-openclaw', ], }, { From c288bbfb57f31ef448796c227af2a1e7acf4cd13 Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:29:07 +0530 Subject: [PATCH 35/46] fix(cli): prevent status bar wrapping into duplicate rows (#3883) - measure status bar display width using prompt_toolkit cell widths - trim rendered status text when fragments would overflow - add a final single-fragment fallback to prevent wrapping - update width assertions to validate display cells instead of len() --- cli.py | 129 +++++++++++++++++++++++------------ tests/test_cli_status_bar.py | 5 +- 2 files changed, 90 insertions(+), 44 deletions(-) diff --git a/cli.py b/cli.py index a601878f244b..3371b982133f 100644 --- a/cli.py +++ b/cli.py @@ -1355,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() @@ -1369,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"]) @@ -1384,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'}" @@ -1406,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()} ")] 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})" ) From 947faed3bc3961f6d6f6a3af4cf8dc2424a92877 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 00:02:02 -0700 Subject: [PATCH 36/46] feat(approvals): make dangerous command approval timeout configurable (#3886) * feat(approvals): make dangerous command approval timeout configurable Read `approvals.timeout` from config.yaml (default 60s) instead of hardcoding 60 seconds in both the fallback CLI prompt and the TUI prompt_toolkit callback. Follows the same pattern as `clarify.timeout` which is already configurable via CLI_CONFIG. Closes #3765 * fix: add timeout default to approvals section in DEFAULT_CONFIG --------- Co-authored-by: acsezen --- hermes_cli/callbacks.py | 3 ++- hermes_cli/config.py | 1 + tools/approval.py | 28 ++++++++++++++++++++++------ 3 files changed, 25 insertions(+), 7 deletions(-) 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/config.py b/hermes_cli/config.py index 4cdbb0af986b..69530761dc90 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -407,6 +407,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/tools/approval.py b/tools/approval.py index e7313b002fe5..95011173fdc4 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -241,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). @@ -256,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, @@ -336,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: From ee61485cac5590ca3aeb7a42bcdd763ea1a3c588 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 00:02:51 -0700 Subject: [PATCH 37/46] feat(matrix): support native voice messages via MSC3245 (#3877) * feat(matrix): support native voice messages * fix: skip matrix voice tests when matrix-nio not installed --------- Co-authored-by: Carlos Alberto Pereira Gomes --- gateway/platforms/matrix.py | 58 ++++- tests/gateway/test_matrix_voice.py | 340 +++++++++++++++++++++++++++++ 2 files changed, 392 insertions(+), 6 deletions(-) create mode 100644 tests/gateway/test_matrix_voice.py 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/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) From 138ea3fbe8b8af57772c294816d30ac79a394953 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 00:09:30 -0700 Subject: [PATCH 38/46] fix(docs): escape angle-bracket URLs in feishu.md breaking MDX build (#3902) --- website/docs/user-guide/messaging/feishu.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/docs/user-guide/messaging/feishu.md b/website/docs/user-guide/messaging/feishu.md index f515648b867e..1b7141e78c0a 100644 --- a/website/docs/user-guide/messaging/feishu.md +++ b/website/docs/user-guide/messaging/feishu.md @@ -32,8 +32,8 @@ Set it to `false` only if you explicitly want one shared conversation per chat. ## Step 1: Create a Feishu / Lark App 1. Open the Feishu or Lark developer console: - - Feishu: - - Lark: + - Feishu: [https://open.feishu.cn/](https://open.feishu.cn/) + - Lark: [https://open.larksuite.com/](https://open.larksuite.com/) 2. Create a new app. 3. In **Credentials & Basic Info**, copy the **App ID** and **App Secret**. 4. Enable the **Bot** capability for the app. From a4b064763d2fd4ce26b4d617c488fcdd57a50e76 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 00:11:00 -0700 Subject: [PATCH 39/46] fix(cron): tighten [SILENT] instruction to prevent report-with-silent-prefix (#3901) The model was interpreting [SILENT] as a metadata prefix and writing full reports with [SILENT] slapped at the front. The old instruction said 'optionally followed by a brief internal note' which gave too much room. New instruction explicitly says: [SILENT] means nothing else, do NOT combine it with a report. --- cron/scheduler.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index e4299836f41c..a03f00b76d26 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -236,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: From 791f4e94b27ccf6bdbcb02f6358e5d7e029f2209 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:51:48 -0700 Subject: [PATCH 40/46] feat(slack): multi-workspace support via OAuth token file (#3903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvaged from PR #2033 by yoannes. Adds multi-workspace Slack support so a single Hermes instance can serve multiple Slack workspaces after OAuth installs. Changes: - Support comma-separated bot tokens in SLACK_BOT_TOKEN env var - Load additional OAuth-persisted tokens from HERMES_HOME/slack_tokens.json - Route all Slack API calls through workspace-aware _get_client(chat_id) instead of always using the primary app client - Track channel → workspace mapping from incoming events - Per-workspace bot_user_id for correct mention detection - Workspace-aware file downloads (correct auth token per workspace) Backward compatible: single-token setups work identically. Token file format (slack_tokens.json): {"T12345": {"token": "xoxb-...", "team_name": "My Workspace"}} Fixed from original PR: - Uses get_hermes_home() instead of hardcoded ~/.hermes/ path Co-authored-by: yoannes --- gateway/platforms/slack.py | 125 ++++++++++++++++++++++++++++--------- 1 file changed, 94 insertions(+), 31 deletions(-) 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: From 5148682b432e8df38273af0381d14ee812dfee46 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:45:41 -0700 Subject: [PATCH 41/46] feat: mount skills directory into all remote backends with live sync (#3890) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills with scripts/, templates/, and references/ subdirectories need those files available inside sandboxed execution environments. Previously the skills directory was missing entirely from remote backends. Live sync — files stay current as credentials refresh and skills update: - Docker/Singularity: bind mounts are inherently live (host changes visible immediately) - Modal: _sync_files() runs before each command with mtime+size caching, pushing only changed credential and skill files (~13μs no-op overhead) - SSH: rsync --safe-links before each command (naturally incremental) - Daytona: _upload_if_changed() with mtime+size caching before each command Security — symlink filtering: - Docker/Singularity: sanitized temp copy when symlinks detected - Modal/Daytona: iter_skills_files() skips symlinks - SSH: rsync --safe-links skips symlinks pointing outside source tree - Temp dir cleanup via atexit + reuse across calls Non-root user support: - SSH: detects remote home via echo $HOME, syncs to $HOME/.hermes/ - Daytona: detects sandbox home before sync, uploads to $HOME/.hermes/ - Docker/Modal/Singularity: run as root, /root/.hermes/ is correct Also: - credential_files.py: fix name/path key fallback in required_credential_files - Singularity, SSH, Daytona: gained credential file support - 14 tests covering symlink filtering, name/path fallback, iter_skills_files --- tests/tools/test_credential_files.py | 275 ++++++++++++++---------- tests/tools/test_daytona_environment.py | 4 + tools/credential_files.py | 103 ++++++++- tools/environments/daytona.py | 65 +++++- tools/environments/docker.py | 16 +- tools/environments/modal.py | 113 +++++----- tools/environments/singularity.py | 22 ++ tools/environments/ssh.py | 75 +++++++ 8 files changed, 494 insertions(+), 179 deletions(-) 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/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/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/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 From efae525dc5c70523527bcd3ed2c15630f0744daa Mon Sep 17 00:00:00 2001 From: Wing Lian Date: Mon, 30 Mar 2026 05:48:06 -0400 Subject: [PATCH 42/46] feat(plugins): add inject_message interface for remote message injection (#3778) --- cli.py | 5 +++++ hermes_cli/plugins.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/cli.py b/cli.py index 3371b982133f..4c76ff142a26 100644 --- a/cli.py +++ b/cli.py @@ -6210,6 +6210,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/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 From 0e592aa5b4d38680233f499c426b9578a2765a1a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:53:21 -0700 Subject: [PATCH 43/46] fix(cli): remove input() from /tools disable that freezes the terminal (#3918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit input() hangs inside prompt_toolkit's TUI event loop — this is a known pitfall (AGENTS.md). The /tools disable and /tools enable commands used input() for a Y/N confirmation prompt, causing the terminal to freeze with no way to type a response. Fix: remove the confirmation prompt. The user typing '/tools disable web' is implicit consent. The change is applied directly with a status message. --- cli.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/cli.py b/cli.py index 4c76ff142a26..706221506c63 100644 --- a/cli.py +++ b/cli.py @@ -2789,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")) From d028a94b83e4c696f0aee5ed0739574fa4516a59 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:55:33 -0700 Subject: [PATCH 44/46] =?UTF-8?q?fix(whatsapp):=20skip=20reply=20prefix=20?= =?UTF-8?q?in=20bot=20mode=20=E2=80=94=20only=20needed=20for=20self-chat?= =?UTF-8?q?=20(#3931)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WhatsApp bridge prepends '⚕ *Hermes Agent*\n────────────\n' to every outgoing message. In self-chat mode this is necessary to distinguish the bot's responses from the user's own messages. In bot mode the messages already come from a different number, making the prefix redundant and cluttered. Now only prepends the prefix when WHATSAPP_MODE is 'self-chat' (the default). Bot mode messages are sent clean. --- scripts/whatsapp-bridge/bridge.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 46cc5c339e59..5f0cb729f693 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -55,6 +55,10 @@ const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined : 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; } From b4496b33b59de8b66b6b681581d2e21ab6b4deb9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:59:39 -0700 Subject: [PATCH 45/46] fix: background task media delivery + vision download timeout (#3919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(telegram): add webhook mode as alternative to polling When TELEGRAM_WEBHOOK_URL is set, the adapter starts an HTTP webhook server (via python-telegram-bot's start_webhook()) instead of long polling. This enables cloud platforms like Fly.io and Railway to auto-wake suspended machines on inbound HTTP traffic. Polling remains the default — no behavior change unless the env var is set. Env vars: TELEGRAM_WEBHOOK_URL Public HTTPS URL for Telegram to push to TELEGRAM_WEBHOOK_PORT Local listen port (default 8443) TELEGRAM_WEBHOOK_SECRET Secret token for update verification Cherry-picked and adapted from PR #2022 by SHL0MS. Preserved all current main enhancements (network error recovery, polling conflict detection, DM topics setup). Co-authored-by: SHL0MS * fix: send_document call in background task delivery + vision download timeout Two fixes salvaged from PR #2269 by amethystani: 1. gateway/run.py: adapter.send_file() → adapter.send_document() send_file() doesn't exist on BasePlatformAdapter. Background task media files were silently never delivered (AttributeError swallowed by except Exception: pass). 2. tools/vision_tools.py: configurable image download timeout via HERMES_VISION_DOWNLOAD_TIMEOUT env var (default 30s), plus guard against raise None when max_retries=0. The third fix in #2269 (opencode-go auth config) was already resolved on main. Co-authored-by: amethystani --------- Co-authored-by: SHL0MS Co-authored-by: amethystani --- cli-config.yaml.example | 3 +++ gateway/run.py | 2 +- hermes_cli/config.py | 3 ++- tools/vision_tools.py | 28 +++++++++++++++++++++++- website/docs/user-guide/configuration.md | 5 +++-- 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 504b2178d8b5..922807f17a2e 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -324,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/gateway/run.py b/gateway/run.py index 3c0ca1818165..3b51930421fd 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3891,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, ) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 69530761dc90..e2503ebec260 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -223,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", diff --git a/tools/vision_tools.py b/tools/vision_tools.py index 47b406846e5d..404d06a50040 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -45,6 +45,28 @@ 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: """ @@ -146,7 +168,7 @@ async def _download_image(image_url: str, destination: Path, max_retries: int = # Enable follow_redirects to handle image CDNs that redirect (e.g., Imgur, Picsum) # SSRF: event_hooks validates each redirect target against private IP ranges async with httpx.AsyncClient( - timeout=30.0, + timeout=_VISION_DOWNLOAD_TIMEOUT, follow_redirects=True, event_hooks={"response": [_ssrf_redirect_guard]}, ) as client: @@ -183,6 +205,10 @@ async def _download_image(image_url: str, destination: Path, max_retries: int = exc_info=True, ) + if last_error is None: + raise RuntimeError( + f"_download_image exited retry loop without attempting (max_retries={max_retries})" + ) raise last_error diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 17f15dc5c452..48d76dd80b27 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1018,7 +1018,8 @@ auxiliary: model: "" # e.g. "openai/gpt-4o", "google/gemini-2.5-flash" base_url: "" # Custom OpenAI-compatible endpoint (overrides 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; increase for slow local vision models + download_timeout: 30 # seconds — image HTTP download; increase for slow connections # Web page summarization + browser page text extraction web_extract: @@ -1042,7 +1043,7 @@ auxiliary: ``` :::tip -Each auxiliary task has a configurable `timeout` (in seconds). Defaults: vision 30s, web_extract 30s, approval 30s, compression 120s. Increase these if you use slow local models for auxiliary tasks. +Each auxiliary task has a configurable `timeout` (in seconds). Defaults: vision 30s, web_extract 30s, approval 30s, compression 120s. Increase these if you use slow local models for auxiliary tasks. Vision also has a separate `download_timeout` (default 30s) for the HTTP image download — increase this for slow connections or self-hosted image servers. ::: :::info From 0b0c1b326c4db8c7a421473863c2a3fcbad76aea Mon Sep 17 00:00:00 2001 From: 0xbyt4 <35742124+0xbyt4@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:02:28 +0300 Subject: [PATCH 46/46] fix: openclaw migration overwrites model config dict with string (#3924) migrate_model_config() was writing `config["model"] = model_str` which replaces the entire model dict (default, provider, base_url) with a bare string. This causes 'str' object has no attribute 'get' errors throughout Hermes when any code does model_cfg.get("default"). Now preserves the existing model dict and only updates the "default" key, keeping provider/base_url intact. --- .../openclaw-migration/scripts/openclaw_to_hermes.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 f2e3d7af68dc..ac99e2a6fdb4 100644 --- a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +++ b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py @@ -1297,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: