feat(discord): optionally mention approval owners on exec prompts
Opt-in discord.approval_mentions (config.yaml, bridged to DISCORD_APPROVAL_MENTIONS) prepends <@id> mentions for numeric allowlist entries to exec-approval prompts, with a scoped AllowedMentions override (users only). Default off - no surprise pings. Reapplied onto the content-mirror layout from #60245: mentions prepend to the visible content block and its truncation budget. Original implementation from PR #39719; commits arrived bot-authored, re-attributed to the contributor.
This commit is contained in:
parent
f76899facf
commit
e0176cbd47
4 changed files with 148 additions and 222 deletions
|
|
@ -13,7 +13,7 @@ model:
|
|||
# Inference provider selection:
|
||||
# "auto" - Auto-detect from credentials (default)
|
||||
# "openrouter" - OpenRouter (requires: OPENROUTER_API_KEY or OPENAI_API_KEY)
|
||||
# "nous" - Nous Portal OAuth (requires: hermes auth add nous)
|
||||
# "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 auth)
|
||||
|
|
@ -85,25 +85,6 @@ model:
|
|||
#
|
||||
# default_headers:
|
||||
# User-Agent: "curl/8.7.1"
|
||||
#
|
||||
# extra_headers: accepted as an alias of default_headers (merged, with
|
||||
# extra_headers winning when both are set) — matches the per-provider
|
||||
# extra_headers key below.
|
||||
#
|
||||
# Per-provider variant: named providers / custom_providers entries accept an
|
||||
# extra_headers dict scoped to that endpoint only — for reverse proxies,
|
||||
# gateways, or custom auth (e.g. Cloudflare Access service tokens).
|
||||
# Merged onto SDK/provider defaults with the entry's values winning.
|
||||
# Header values are treated as secrets and are never logged.
|
||||
#
|
||||
# providers:
|
||||
# my-proxy:
|
||||
# base_url: "https://llm.internal.example.com/v1"
|
||||
# key_env: "MY_PROXY_API_KEY"
|
||||
# extra_headers:
|
||||
# CF-Access-Client-Id: "xxxx.access"
|
||||
# CF-Access-Client-Secret: "${CF_ACCESS_SECRET}"
|
||||
# X-Client-Name: "hermes-agent"
|
||||
|
||||
# Named provider overrides (optional)
|
||||
# Use this for per-provider request timeouts, non-stream stale timeouts,
|
||||
|
|
@ -117,9 +98,7 @@ model:
|
|||
# ``stale_timeout_seconds`` controls the non-streaming stale-call detector and
|
||||
# wins over the legacy HERMES_API_CALL_STALE_TIMEOUT env var. Leaving these
|
||||
# unset keeps the legacy defaults (HERMES_API_TIMEOUT=1800s,
|
||||
# HERMES_API_CALL_STALE_TIMEOUT=90s, native Anthropic 900s). The
|
||||
# implicit non-stream stale detector is auto-disabled for local endpoints
|
||||
# and can scale upward for very large contexts.
|
||||
# HERMES_API_CALL_STALE_TIMEOUT=300s, native Anthropic 900s).
|
||||
#
|
||||
# Not currently wired for AWS Bedrock (bedrock_converse + AnthropicBedrock
|
||||
# SDK paths) — those use boto3 with its own timeout configuration.
|
||||
|
|
@ -185,16 +164,6 @@ model:
|
|||
#
|
||||
# worktree: true # Always create a worktree when in a git repo
|
||||
# worktree: false # Default — only create when -w flag is passed
|
||||
#
|
||||
# By default a new worktree branches from the freshly-fetched remote tip
|
||||
# (the current branch's upstream, else the remote's default branch) so it
|
||||
# starts current with the project instead of from the local clone's
|
||||
# (possibly stale) HEAD. Set worktree_sync: false to branch from local HEAD
|
||||
# instead — useful when offline or when you deliberately want the clone's
|
||||
# exact current state as the base.
|
||||
#
|
||||
# worktree_sync: true # Default — branch from the fetched remote tip
|
||||
# worktree_sync: false # Branch from local HEAD (offline / pinned base)
|
||||
|
||||
# =============================================================================
|
||||
# Terminal Tool Configuration
|
||||
|
|
@ -213,11 +182,6 @@ terminal:
|
|||
backend: "local"
|
||||
cwd: "." # For local backend: "." = current directory. Ignored for remote backends unless a backend documents otherwise.
|
||||
timeout: 180
|
||||
# HOME policy for tool subprocesses:
|
||||
# auto - default: host uses your real HOME; containers use HERMES_HOME/home
|
||||
# real - force your real OS-user HOME
|
||||
# profile - force HERMES_HOME/home for strict per-profile CLI config isolation
|
||||
home_mode: "auto"
|
||||
docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into Docker /workspace.
|
||||
lifetime_seconds: 300
|
||||
# sudo_password: "hunter2" # Optional: pipe a sudo password via sudo -S. SECURITY WARNING: plaintext.
|
||||
|
|
@ -410,11 +374,7 @@ compression:
|
|||
# Trigger compression at this % of model's context limit (default: 0.50 = 50%)
|
||||
# Lower values = more aggressive compression, higher values = compress later
|
||||
threshold: 0.50
|
||||
|
||||
# Existing Codex gpt-5.5 behavior: raise Hermes' compaction trigger to 85%
|
||||
# for the ChatGPT Codex OAuth route. Set false to opt back down to threshold.
|
||||
codex_gpt55_autoraise: true
|
||||
|
||||
|
||||
# Fraction of the threshold to preserve as recent tail (default: 0.20 = 20%)
|
||||
# e.g. 20% of 50% threshold = 10% of total context kept as recent messages.
|
||||
# Summary output is separately capped at 12K tokens (Gemini output limit).
|
||||
|
|
@ -426,14 +386,6 @@ compression:
|
|||
# compression of older turns.
|
||||
protect_last_n: 20
|
||||
|
||||
# Codex app-server (codex CLI runtime) thread-compaction mode. The codex
|
||||
# agent owns the real thread context on this runtime, so Hermes' summarizer
|
||||
# cannot shrink it — compaction goes through the app server instead.
|
||||
# native = let Codex decide when to compact its own thread (default)
|
||||
# hermes = let Hermes threshold trigger Codex thread/compact/start
|
||||
# off = Hermes will not auto-trigger compaction; Codex may still compact natively
|
||||
codex_app_server_auto: native
|
||||
|
||||
# Number of non-system messages to protect at the head of the transcript, in
|
||||
# ADDITION to the system prompt (which is always implicitly protected).
|
||||
# Head messages are NEVER summarized — they survive every compression
|
||||
|
|
@ -482,7 +434,7 @@ prompt_caching:
|
|||
# Provider options:
|
||||
# "auto" - Best available: OpenRouter → Nous Portal → main endpoint (default)
|
||||
# "openrouter" - Force OpenRouter (requires OPENROUTER_API_KEY)
|
||||
# "nous" - Force Nous Portal (requires: hermes auth add nous)
|
||||
# "nous" - Force Nous Portal (requires: hermes login)
|
||||
# "gemini" - Force Google AI Studio direct (requires: GOOGLE_API_KEY or GEMINI_API_KEY)
|
||||
# "ollama-cloud" - Ollama Cloud (requires: OLLAMA_API_KEY)
|
||||
# "codex" - Force Codex OAuth (requires: hermes model → Codex).
|
||||
|
|
@ -526,10 +478,6 @@ prompt_caching:
|
|||
# # reasoning controls:
|
||||
# # extra_body:
|
||||
# # enable_thinking: false
|
||||
# # Some vLLM/Qwen deployments expect this nested:
|
||||
# # extra_body:
|
||||
# # chat_template_kwargs:
|
||||
# # enable_thinking: false
|
||||
|
||||
# =============================================================================
|
||||
# Persistent Memory
|
||||
|
|
@ -602,41 +550,6 @@ max_concurrent_sessions: null
|
|||
# explicitly want one shared "room brain" per group/channel.
|
||||
group_sessions_per_user: true
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# API Server — per-client model routing
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Route different API clients to different models/providers on a single
|
||||
# Hermes deployment. Clients choose a backend by sending a specific string
|
||||
# as the OpenAI ``model`` field. Unmapped model values fall back to the
|
||||
# global model configured in the ``model:`` section above, and an explicit
|
||||
# session /model override always wins over a route.
|
||||
#
|
||||
# Configure via the ``platforms.api_server.extra.model_routes`` gateway
|
||||
# config block:
|
||||
#
|
||||
# platforms:
|
||||
# api_server:
|
||||
# enabled: true
|
||||
# extra:
|
||||
# key: "your-api-server-secret"
|
||||
# model_routes:
|
||||
# # Xiaozhi clients send model="minimax-m2" → routed to MiniMax via OpenRouter
|
||||
# minimax-m2:
|
||||
# model: "minimax/minimax-m1"
|
||||
# provider: "openrouter" # optional — overrides global provider
|
||||
# # api_key: "sk-..." # optional — per-route UPSTREAM provider
|
||||
# # key (NOT caller auth; never logged)
|
||||
# # base_url: "https://..." # optional — per-route base URL
|
||||
# # GPT clients keep their own alias
|
||||
# gpt-5:
|
||||
# model: "openai/gpt-5"
|
||||
# provider: "openrouter"
|
||||
#
|
||||
# Configured aliases are automatically listed by GET /v1/models so clients
|
||||
# can discover them without manual coordination. Caller authentication is
|
||||
# unchanged: every request still authenticates with the global API server
|
||||
# key (``extra.key`` / API_SERVER_KEY).
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Gateway Streaming
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -692,10 +605,10 @@ agent:
|
|||
# gateway_timeout_warning: 900
|
||||
|
||||
# Graceful drain timeout for gateway stop/restart (seconds).
|
||||
# Default 0 = no drain: a restart interrupts in-flight agents immediately,
|
||||
# cleans up, and exits. Set a positive value only if you want a grace
|
||||
# window on /restart, and keep it well under systemd's TimeoutStopSec.
|
||||
# restart_drain_timeout: 0
|
||||
# The gateway stops accepting new work, waits for in-flight agents to
|
||||
# finish, then interrupts anything still running after this timeout.
|
||||
# 0 = no drain, interrupt immediately.
|
||||
# restart_drain_timeout: 60
|
||||
|
||||
# Max app-level retry attempts for API errors (connection drops, provider
|
||||
# timeouts, 5xx, etc.) before the agent surfaces the failure. Lower this
|
||||
|
|
@ -703,34 +616,7 @@ agent:
|
|||
# primaries (default 3). The OpenAI SDK does its own low-level retries
|
||||
# underneath this wrapper — this is the Hermes-level loop.
|
||||
# api_max_retries: 3
|
||||
|
||||
# After the agent edits code without fresh passing verification, nudge it to
|
||||
# verify before finishing. The default "auto" enables it on interactive
|
||||
# coding surfaces (CLI, TUI, desktop) and programmatic callers, and disables
|
||||
# it on conversational messaging surfaces (Telegram, Discord, etc.) where the
|
||||
# verification summary would reach a human as chat noise. Set true or false to
|
||||
# force it on or off; the HERMES_VERIFY_ON_STOP env var (1/0) takes precedence.
|
||||
# verify_on_stop: auto
|
||||
|
||||
# Standing operator instructions for the coding posture (when Hermes is in a
|
||||
# code workspace). Appended to the coding brief as an extra system block, so
|
||||
# you can pin project-wide workflow rules without editing the shipped brief.
|
||||
# Accepts a string or a list of strings. Takes effect next session.
|
||||
# coding_instructions:
|
||||
# - "For UI work, don't run tsc/lint until I approve the look."
|
||||
# - "Clean the diff before you commit and push."
|
||||
|
||||
# When verify-on-stop finds edited code without fresh verification evidence,
|
||||
# append guidance for creative UI work (avoid broad tsc/lint/test before visual
|
||||
# approval) and clean-diff expectations. Set false to keep that nudge terse.
|
||||
# verify_guidance: true
|
||||
|
||||
# A `pre_verify` hook (plugin or shell, see Event Hooks docs) can keep the
|
||||
# agent going one more turn to verify/clean before finishing. This caps how
|
||||
# many times one turn may be nudged to continue, so a hook can't trap the loop.
|
||||
# Default 3.
|
||||
# max_verify_nudges: 3
|
||||
|
||||
|
||||
# Enable verbose logging
|
||||
verbose: false
|
||||
|
||||
|
|
@ -833,19 +719,6 @@ platform_toolsets:
|
|||
# # allowed_chats: ["-1001234567890"]
|
||||
# extra:
|
||||
# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages
|
||||
# rich_messages: false # Bot API 10.1 rich messages (tables/task lists/details/math); default false for copyable legacy MarkdownV2, set true to opt in
|
||||
# rich_drafts: false # Experimental rich draft previews during Telegram DM streaming; default false because Telegram Desktop/macOS can visually overlay draft frames
|
||||
# command_menu:
|
||||
# # Telegram allows up to 100 BotCommands; Hermes defaults to 60 so
|
||||
# # all built-in commands plus common skill commands stay visible
|
||||
# # while remaining under Telegram's payload-size limit. Clamped 1..100.
|
||||
# max_commands: 60
|
||||
# # prepend = user priority first, then Hermes defaults
|
||||
# # append = Hermes defaults first, then user priority
|
||||
# # replace = only the list below defines priority
|
||||
# priority_mode: prepend
|
||||
# priority:
|
||||
# - my_plugin_command
|
||||
#
|
||||
# Discord-specific settings (config.yaml top-level, not under platforms:):
|
||||
#
|
||||
|
|
@ -856,6 +729,7 @@ platform_toolsets:
|
|||
# reactions: true # Show processing reactions (default: true)
|
||||
# history_backfill: true # Recover missed channel messages on mention (default: true)
|
||||
# history_backfill_limit: 50 # Max messages to scan backwards (default: 50)
|
||||
# approval_mentions: false # Mention numeric allowed users on approval prompts (default: false)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Available toolsets (use these names in platform_toolsets or the toolsets list)
|
||||
|
|
@ -876,6 +750,7 @@ platform_toolsets:
|
|||
# image_gen - image_generate (requires FAL_KEY)
|
||||
# skills - skills_list, skill_view
|
||||
# skills_hub - skill_hub (search/install/manage from online registries — user-driven only)
|
||||
# moa - mixture_of_agents (requires OPENROUTER_API_KEY)
|
||||
# todo - todo (in-memory task planning, no deps)
|
||||
# tts - text_to_speech (Edge TTS free, or ELEVENLABS/OPENAI/MINIMAX/MISTRAL key)
|
||||
# cronjob - cronjob (create/list/update/pause/resume/run/remove scheduled tasks)
|
||||
|
|
@ -890,7 +765,7 @@ platform_toolsets:
|
|||
#
|
||||
# COMPOSITE:
|
||||
# debugging - terminal + web + file
|
||||
# safe - web + vision (no terminal access)
|
||||
# safe - web + vision + moa (no terminal access)
|
||||
# all - Everything available
|
||||
#
|
||||
# web - Web search and content extraction (web_search, web_extract)
|
||||
|
|
@ -901,6 +776,7 @@ platform_toolsets:
|
|||
# vision - Image analysis (vision_analyze)
|
||||
# image_gen - Image generation with FLUX (image_generate)
|
||||
# skills - Load skill documents (skills_list, skill_view)
|
||||
# moa - Mixture of Agents reasoning (mixture_of_agents)
|
||||
# todo - Task planning and tracking for multi-step work
|
||||
# memory - Persistent memory across sessions (personal notes + user profile)
|
||||
# session_search - Search and recall past conversations (FTS5 + Gemini Flash summarization)
|
||||
|
|
@ -909,7 +785,7 @@ platform_toolsets:
|
|||
#
|
||||
# Composite toolsets:
|
||||
# debugging - terminal + web + file (for troubleshooting)
|
||||
# safe - web + vision (no terminal access)
|
||||
# safe - web + vision + moa (no terminal access)
|
||||
|
||||
# NOTE: The top-level "toolsets" key is deprecated and ignored.
|
||||
# Tool configuration is managed per-platform via platform_toolsets above.
|
||||
|
|
@ -922,7 +798,7 @@ platform_toolsets:
|
|||
# =============================================================================
|
||||
# Connect to external MCP servers to add tools from the MCP ecosystem.
|
||||
# Each server's tools are automatically discovered and registered.
|
||||
# See website/docs/user-guide/features/mcp.md for full documentation.
|
||||
# See docs/mcp.md for full documentation.
|
||||
#
|
||||
# Stdio servers (spawn a subprocess):
|
||||
# command: the executable to run
|
||||
|
|
@ -936,10 +812,6 @@ platform_toolsets:
|
|||
# Optional per-server settings:
|
||||
# timeout: tool call timeout in seconds (default: 120)
|
||||
# connect_timeout: initial connection timeout (default: 60)
|
||||
# keepalive_interval: liveness ping cadence in seconds (default: 180).
|
||||
# Lower it below the server's session TTL for servers that expire idle
|
||||
# sessions quickly (e.g. Unreal Engine editor MCP, ~15s), otherwise idle
|
||||
# tool calls hit an expired session and pay a slow reconnect. Floored at 5s.
|
||||
#
|
||||
# mcp_servers:
|
||||
# time:
|
||||
|
|
@ -1088,7 +960,6 @@ display:
|
|||
# new: Show a tool indicator only when the tool changes (skip repeats)
|
||||
# all: Show every tool call with a short preview (default)
|
||||
# verbose: Full args, results, and debug logs (same as /verbose)
|
||||
# log: Silent in chat; append every tool call to ~/.hermes/logs/tool_calls.log (gateway only)
|
||||
# Toggle at runtime with /verbose in the CLI
|
||||
tool_progress: all
|
||||
|
||||
|
|
@ -1362,80 +1233,3 @@ updates:
|
|||
# # default — works on Fly.io out of the box).
|
||||
# #
|
||||
# # public_url: "https://example.com/hermes"
|
||||
#
|
||||
# -----------------------------------------------------------------------------
|
||||
# Self-hosted OIDC dashboard auth (generic OpenID Connect — Authentik,
|
||||
# Keycloak, Zitadel, Authelia, Auth0, Okta, Google, …). Use this INSTEAD of the
|
||||
# nous block above when gating the dashboard with your own identity provider.
|
||||
# Each setting can be overridden by an environment variable:
|
||||
#
|
||||
# dashboard.oauth.self_hosted.issuer <- HERMES_DASHBOARD_OIDC_ISSUER
|
||||
# dashboard.oauth.self_hosted.client_id <- HERMES_DASHBOARD_OIDC_CLIENT_ID
|
||||
# dashboard.oauth.self_hosted.scopes <- HERMES_DASHBOARD_OIDC_SCOPES
|
||||
# dashboard.oauth.self_hosted.client_secret <- HERMES_DASHBOARD_OIDC_CLIENT_SECRET
|
||||
#
|
||||
# dashboard:
|
||||
# oauth:
|
||||
# provider: self-hosted
|
||||
# self_hosted:
|
||||
# issuer: "https://auth.example.com/application/o/hermes/" # required
|
||||
# client_id: "hermes-dashboard" # required
|
||||
# scopes: "openid profile email" # optional
|
||||
#
|
||||
# # OPTIONAL — set ONLY if your IDP registered the client as
|
||||
# # *confidential* (Authentik / Keycloak often default to this). When
|
||||
# # set, Hermes authenticates the client at the token endpoint
|
||||
# # (client_secret_basic or client_secret_post, auto-selected from the
|
||||
# # IDP's discovery doc) IN ADDITION to PKCE. Leave unset for a public
|
||||
# # (PKCE-only) client — the common case.
|
||||
# #
|
||||
# # This is a CREDENTIAL: prefer setting HERMES_DASHBOARD_OIDC_CLIENT_SECRET
|
||||
# # in ~/.hermes/.env over putting it here in config.yaml.
|
||||
# # client_secret: ""
|
||||
|
||||
# =============================================================================
|
||||
# External secret sources
|
||||
# =============================================================================
|
||||
# Pull provider credentials from external secret managers at process startup
|
||||
# instead of storing them in ~/.hermes/.env. Only the manager's bootstrap
|
||||
# credential (e.g. BWS_ACCESS_TOKEN / OP_SERVICE_ACCOUNT_TOKEN) lives in .env
|
||||
# (or your shell / desktop session); everything else rotates centrally.
|
||||
# Failures never block startup — Hermes warns once and continues with
|
||||
# whatever .env already had.
|
||||
#
|
||||
# Multiple sources can be enabled at once:
|
||||
# - "mapped" sources (explicit VAR -> ref bindings, e.g. 1Password's env:
|
||||
# map) beat "bulk" sources (whole-project dumps like Bitwarden BSM)
|
||||
# - within a shape, the first source to claim a var wins; later claims
|
||||
# are skipped with a startup warning (never a silent clobber)
|
||||
# - a source's override_existing lets it beat .env/shell values, but
|
||||
# never another secret source's claim
|
||||
# Docs: https://hermes-agent.nousresearch.com/docs/user-guide/secrets/
|
||||
#
|
||||
# secrets:
|
||||
# # Optional explicit ordering of enabled sources.
|
||||
# # sources: [onepassword, bitwarden]
|
||||
#
|
||||
# # ---- Bitwarden Secrets Manager (bws CLI) --------------------------------
|
||||
# bitwarden:
|
||||
# enabled: false
|
||||
# access_token_env: BWS_ACCESS_TOKEN # bootstrap token, sourced from .env
|
||||
# project_id: "" # UUID of the BSM project to sync
|
||||
# server_url: "" # "" = US Cloud; EU/self-hosted URL otherwise
|
||||
# cache_ttl_seconds: 300 # 0 disables caching
|
||||
# override_existing: true # BSM values win over existing env
|
||||
# auto_install: true # lazy-download bws into ~/.hermes/bin
|
||||
#
|
||||
# # ---- 1Password (op CLI) -------------------------------------------------
|
||||
# onepassword:
|
||||
# enabled: false
|
||||
# # Map env-var names to op:// secret references. Each is resolved with a
|
||||
# # single `op read` at startup.
|
||||
# env:
|
||||
# OPENAI_API_KEY: "op://Private/OpenAI/api key"
|
||||
# ANTHROPIC_API_KEY: "op://Private/Anthropic/credential"
|
||||
# account: "" # op --account shorthand; "" = default
|
||||
# service_account_token_env: OP_SERVICE_ACCOUNT_TOKEN # headless auth; unset = desktop session
|
||||
# binary_path: "" # "" = resolve op via PATH; else absolute path
|
||||
# cache_ttl_seconds: 300 # 0 disables BOTH cache layers
|
||||
# override_existing: true # resolved values win over existing env
|
||||
|
|
|
|||
|
|
@ -2384,6 +2384,11 @@ DEFAULT_CONFIG = {
|
|||
# real memory cost. Default 32 MiB matches the historical hardcoded
|
||||
# cap. Set to 0 for no cap. Env override: DISCORD_MAX_ATTACHMENT_BYTES.
|
||||
"max_attachment_bytes": 33554432,
|
||||
# When True, Discord approval prompts mention numeric allowed users so
|
||||
# owners notice approval requests in shared channels/threads. Env
|
||||
# override: DISCORD_APPROVAL_MENTIONS. Default false avoids surprise
|
||||
# pings.
|
||||
"approval_mentions": False,
|
||||
# Voice-channel audio effects (the continuous mixer). OFF by default.
|
||||
# When enabled, the bot installs a software mixer on the outgoing voice
|
||||
# stream so a low ambient "thinking" bed, verbal acknowledgements, and
|
||||
|
|
|
|||
|
|
@ -754,6 +754,13 @@ _DISCORD_PROMPT_TIMEOUT_MIN = 30
|
|||
_DISCORD_PROMPT_TIMEOUT_MAX = 900
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
raw = os.getenv(name, "").strip().lower()
|
||||
if not raw:
|
||||
return default
|
||||
return raw in {"true", "1", "yes", "on"}
|
||||
|
||||
|
||||
def _read_discord_prompt_timeout() -> int:
|
||||
"""Return the timeout (in seconds) for Discord button views.
|
||||
|
||||
|
|
@ -5502,6 +5509,20 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
body = body[: max(0, budget - len(truncated_suffix))] + truncated_suffix
|
||||
return f"{prefix}{body}{suffix}"
|
||||
|
||||
def _approval_mention_content(self) -> Optional[str]:
|
||||
"""Return user mentions for approval prompts when explicitly enabled.
|
||||
|
||||
Gated on ``discord.approval_mentions`` in config.yaml (bridged to the
|
||||
``DISCORD_APPROVAL_MENTIONS`` env var). Only numeric allowlist entries
|
||||
can be mentioned; default off avoids surprise pings.
|
||||
"""
|
||||
if not _env_bool("DISCORD_APPROVAL_MENTIONS", False):
|
||||
return None
|
||||
user_ids = sorted(uid for uid in self._allowed_user_ids if str(uid).isdigit())
|
||||
if not user_ids:
|
||||
return None
|
||||
return " ".join(f"<@{uid}>" for uid in user_ids)
|
||||
|
||||
async def send_exec_approval(
|
||||
self, chat_id: str, command: str, session_key: str,
|
||||
description: str = "dangerous command",
|
||||
|
|
@ -5541,6 +5562,9 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
"Do you want Hermes to run this command?\n\n"
|
||||
"**Requested command:**\n```bash\n"
|
||||
)
|
||||
mention_content = self._approval_mention_content()
|
||||
if mention_content:
|
||||
prompt_prefix = f"{mention_content}\n{prompt_prefix}"
|
||||
prompt_tail = f"\n```\n**Reason:** {reason_display}"
|
||||
truncated_suffix = "\n... [truncated]"
|
||||
command_budget = max(0, self.MAX_MESSAGE_LENGTH - len(prompt_prefix) - len(prompt_tail))
|
||||
|
|
@ -5576,7 +5600,17 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
admin_user_ids=admin_user_ids,
|
||||
)
|
||||
|
||||
msg = await channel.send(content=content, embed=embed, view=view)
|
||||
send_kwargs: Dict[str, Any] = {"content": content, "embed": embed, "view": view}
|
||||
if mention_content:
|
||||
allowed_mentions_cls = getattr(discord, "AllowedMentions", None)
|
||||
if allowed_mentions_cls is not None:
|
||||
send_kwargs["allowed_mentions"] = allowed_mentions_cls(
|
||||
users=True,
|
||||
roles=False,
|
||||
everyone=False,
|
||||
replied_user=False,
|
||||
)
|
||||
msg = await channel.send(**send_kwargs)
|
||||
view._message = msg # store for on_timeout expiration editing
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
|
||||
|
|
@ -8161,6 +8195,12 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:
|
|||
if isinstance(allowed_users_cfg, list):
|
||||
allowed_users_cfg = ",".join(str(v) for v in allowed_users_cfg)
|
||||
os.environ["DISCORD_ALLOWED_USERS"] = str(allowed_users_cfg)
|
||||
approval_mentions_cfg = (
|
||||
discord_cfg["approval_mentions"] if "approval_mentions" in discord_cfg
|
||||
else platform_extra_cfg.get("approval_mentions")
|
||||
)
|
||||
if approval_mentions_cfg is not None and not os.getenv("DISCORD_APPROVAL_MENTIONS"):
|
||||
os.environ["DISCORD_APPROVAL_MENTIONS"] = str(approval_mentions_cfg).lower()
|
||||
frc = discord_cfg.get("free_response_channels")
|
||||
if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
|
||||
if isinstance(frc, list):
|
||||
|
|
|
|||
87
tests/gateway/test_discord_approval_mentions.py
Normal file
87
tests/gateway/test_discord_approval_mentions.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Discord approval prompts can opt into owner mentions."""
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.platforms.discord.adapter import (
|
||||
DiscordAdapter,
|
||||
_apply_yaml_config,
|
||||
)
|
||||
|
||||
|
||||
class _FakeChannel:
|
||||
def __init__(self):
|
||||
self.sent_kwargs = None
|
||||
|
||||
async def send(self, **kwargs):
|
||||
self.sent_kwargs = kwargs
|
||||
return SimpleNamespace(id=12345)
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, channel):
|
||||
self.channel = channel
|
||||
|
||||
def get_channel(self, channel_id):
|
||||
return self.channel
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_approval_mentions_allowed_users_when_enabled(monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_APPROVAL_MENTIONS", "true")
|
||||
channel = _FakeChannel()
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter._client = _FakeClient(channel)
|
||||
adapter._allowed_user_ids = {"222", "111", "alice"}
|
||||
adapter._allowed_role_ids = set()
|
||||
adapter.config = SimpleNamespace(extra=None)
|
||||
|
||||
result = await adapter.send_exec_approval(
|
||||
chat_id="99",
|
||||
command="make check",
|
||||
session_key="session-1",
|
||||
description="dangerous command",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
# Mentions are prepended to the (always present) content mirror.
|
||||
assert channel.sent_kwargs["content"].startswith("<@111> <@222>\n")
|
||||
assert "make check" in channel.sent_kwargs["content"]
|
||||
assert "allowed_mentions" in channel.sent_kwargs
|
||||
assert channel.sent_kwargs["embed"].title.endswith("Command Approval Required")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_approval_does_not_mention_by_default(monkeypatch):
|
||||
monkeypatch.delenv("DISCORD_APPROVAL_MENTIONS", raising=False)
|
||||
channel = _FakeChannel()
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter._client = _FakeClient(channel)
|
||||
adapter._allowed_user_ids = {"111"}
|
||||
adapter._allowed_role_ids = set()
|
||||
adapter.config = SimpleNamespace(extra=None)
|
||||
|
||||
result = await adapter.send_exec_approval(
|
||||
chat_id="99",
|
||||
command="make check",
|
||||
session_key="session-1",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
# Content mirror is always present (embed-invisibility fix), but no
|
||||
# mention markup and no allowed_mentions override.
|
||||
assert "<@" not in channel.sent_kwargs["content"]
|
||||
assert "allowed_mentions" not in channel.sent_kwargs
|
||||
|
||||
|
||||
def test_yaml_config_bridges_approval_mentions_to_env(monkeypatch):
|
||||
monkeypatch.delenv("DISCORD_APPROVAL_MENTIONS", raising=False)
|
||||
|
||||
_apply_yaml_config(
|
||||
{"discord": {"approval_mentions": True}},
|
||||
{"approval_mentions": True},
|
||||
)
|
||||
|
||||
assert os.environ["DISCORD_APPROVAL_MENTIONS"] == "true"
|
||||
Loading…
Add table
Add a link
Reference in a new issue