diff --git a/gateway/config.py b/gateway/config.py index b558ea59f..72fde982a 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -554,7 +554,7 @@ def load_gateway_config() -> GatewayConfig: bridged["mention_patterns"] = platform_cfg["mention_patterns"] if plat == Platform.DISCORD and "channel_skill_bindings" in platform_cfg: bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"] - if plat == Platform.DISCORD and "channel_prompts" in platform_cfg: + if "channel_prompts" in platform_cfg: channel_prompts = platform_cfg["channel_prompts"] if isinstance(channel_prompts, dict): bridged["channel_prompts"] = {str(k): v for k, v in channel_prompts.items()} diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 2d3e54698..c718cce89 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -780,6 +780,36 @@ _RETRYABLE_ERROR_PATTERNS = ( MessageHandler = Callable[[MessageEvent], Awaitable[Optional[str]]] +def resolve_channel_prompt( + config_extra: dict, + channel_id: str, + parent_id: str | None = None, +) -> str | None: + """Resolve a per-channel ephemeral prompt from platform config. + + Looks up ``channel_prompts`` in the adapter's ``config.extra`` dict. + Prefers an exact match on *channel_id*; falls back to *parent_id* + (useful for forum threads / child channels inheriting a parent prompt). + + Returns the prompt string, or None if no match is found. Blank/whitespace- + only prompts are treated as absent. + """ + prompts = config_extra.get("channel_prompts") or {} + if not isinstance(prompts, dict): + return None + + for key in (channel_id, parent_id): + if not key: + continue + prompt = prompts.get(key) + if prompt is None: + continue + prompt = str(prompt).strip() + if prompt: + return prompt + return None + + class BasePlatformAdapter(ABC): """ Base class for platform adapters. diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index bf48fc7d1..37890f99f 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -2110,29 +2110,9 @@ class DiscordAdapter(BasePlatformAdapter): return None def _resolve_channel_prompt(self, channel_id: str, parent_id: str | None = None) -> str | None: - """Resolve a Discord per-channel prompt, preferring the exact channel over its parent. - - Config format (in platform extra): - channel_prompts: - "123456": "Prompt text" - - Forum/thread messages inherit the parent forum/channel prompt when the - thread itself has no explicit override. - """ - prompts = self.config.extra.get("channel_prompts") or {} - if not isinstance(prompts, dict): - return None - - for key in (channel_id, parent_id): - if not key: - continue - prompt = prompts.get(key) - if prompt is None: - continue - prompt = str(prompt).strip() - if prompt: - return prompt - return None + """Resolve a Discord per-channel prompt, preferring the exact channel over its parent.""" + from gateway.platforms.base import resolve_channel_prompt + return resolve_channel_prompt(self.config.extra, channel_id, parent_id) def _thread_parent_channel(self, channel: Any) -> Any: """Return the parent text channel when invoked from a thread.""" diff --git a/gateway/platforms/mattermost.py b/gateway/platforms/mattermost.py index 23a86f02b..18367a8e4 100644 --- a/gateway/platforms/mattermost.py +++ b/gateway/platforms/mattermost.py @@ -718,6 +718,12 @@ class MattermostAdapter(BasePlatformAdapter): thread_id=thread_id, ) + # Per-channel ephemeral prompt + from gateway.platforms.base import resolve_channel_prompt + _channel_prompt = resolve_channel_prompt( + self.config.extra, channel_id, None, + ) + msg_event = MessageEvent( text=message_text, message_type=msg_type, @@ -726,6 +732,7 @@ class MattermostAdapter(BasePlatformAdapter): message_id=post_id, media_urls=media_urls if media_urls else None, media_types=media_types if media_types else None, + channel_prompt=_channel_prompt, ) await self.handle_message(msg_event) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 8f9934cf7..3421d7cf7 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -1167,6 +1167,12 @@ class SlackAdapter(BasePlatformAdapter): thread_id=thread_ts, ) + # Per-channel ephemeral prompt + from gateway.platforms.base import resolve_channel_prompt + _channel_prompt = resolve_channel_prompt( + self.config.extra, channel_id, None, + ) + msg_event = MessageEvent( text=text, message_type=msg_type, @@ -1176,6 +1182,7 @@ class SlackAdapter(BasePlatformAdapter): media_urls=media_urls, media_types=media_types, reply_to_message_id=thread_ts if thread_ts != ts else None, + channel_prompt=_channel_prompt, ) # Only react when bot is directly addressed (DM or @mention). diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 0806362b3..09af14f34 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -2775,6 +2775,15 @@ class TelegramAdapter(BasePlatformAdapter): reply_to_id = str(message.reply_to_message.message_id) reply_to_text = message.reply_to_message.text or message.reply_to_message.caption or None + # Per-channel/topic ephemeral prompt + from gateway.platforms.base import resolve_channel_prompt + _chat_id_str = str(chat.id) + _channel_prompt = resolve_channel_prompt( + self.config.extra, + thread_id_str or _chat_id_str, + _chat_id_str if thread_id_str else None, + ) + return MessageEvent( text=message.text or "", message_type=msg_type, @@ -2784,6 +2793,7 @@ class TelegramAdapter(BasePlatformAdapter): reply_to_message_id=reply_to_id, reply_to_text=reply_to_text, auto_skill=topic_skill, + channel_prompt=_channel_prompt, timestamp=message.date, ) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 99c7f003f..4794e74c7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -670,6 +670,21 @@ DEFAULT_CONFIG = { # Supports \n for newlines, e.g. "šŸ¤– *My Bot*\n──────\n" }, + # Telegram platform settings (gateway mode) + "telegram": { + "channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group) + }, + + # Slack platform settings (gateway mode) + "slack": { + "channel_prompts": {}, # Per-channel ephemeral system prompts + }, + + # Mattermost platform settings (gateway mode) + "mattermost": { + "channel_prompts": {}, # Per-channel ephemeral system prompts + }, + # Approval mode for dangerous commands: # manual — always prompt the user (default) # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 7b64331b9..1496c6766 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -214,6 +214,46 @@ class TestLoadGatewayConfig: "456": "Therapist mode", } + def test_bridges_telegram_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "telegram:\n" + " channel_prompts:\n" + ' "-1001234567": Research assistant\n' + " 789: Creative writing\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.platforms[Platform.TELEGRAM].extra["channel_prompts"] == { + "-1001234567": "Research assistant", + "789": "Creative writing", + } + + def test_bridges_slack_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "slack:\n" + " channel_prompts:\n" + ' "C01ABC": Code review mode\n', + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.platforms[Platform.SLACK].extra["channel_prompts"] == { + "C01ABC": "Code review mode", + } + def test_invalid_quick_commands_in_config_yaml_are_ignored(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() diff --git a/website/docs/user-guide/messaging/mattermost.md b/website/docs/user-guide/messaging/mattermost.md index cff50e94d..6d4540154 100644 --- a/website/docs/user-guide/messaging/mattermost.md +++ b/website/docs/user-guide/messaging/mattermost.md @@ -281,6 +281,23 @@ If this returns your bot's user info, the token is valid. If it returns an error **Fix**: Add your User ID to `MATTERMOST_ALLOWED_USERS` in `~/.hermes/.env` and restart the gateway. Remember: the User ID is a 26-character alphanumeric string, not your `@username`. +## Per-Channel Prompts + +Assign ephemeral system prompts to specific Mattermost channels. The prompt is injected at runtime on every turn — never persisted to transcript history — so changes take effect immediately. + +```yaml +mattermost: + channel_prompts: + "channel_id_abc123": | + You are a research assistant. Focus on academic sources, + citations, and concise synthesis. + "channel_id_def456": | + Code review mode. Be precise about edge cases and + performance implications. +``` + +Keys are Mattermost channel IDs (find them in the channel URL or via the API). All messages in the matching channel get the prompt injected as an ephemeral system instruction. + ## Security :::warning diff --git a/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index b266535a3..5f6492216 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -418,6 +418,23 @@ Hermes supports voice on Slack: --- +## Per-Channel Prompts + +Assign ephemeral system prompts to specific Slack channels. The prompt is injected at runtime on every turn — never persisted to transcript history — so changes take effect immediately. + +```yaml +slack: + channel_prompts: + "C01RESEARCH": | + You are a research assistant. Focus on academic sources, + citations, and concise synthesis. + "C02ENGINEERING": | + Code review mode. Be precise about edge cases and + performance implications. +``` + +Keys are Slack channel IDs (find them via channel details → "About" → scroll to bottom). All messages in the matching channel get the prompt injected as an ephemeral system instruction. + ## Troubleshooting | Problem | Solution | diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 4e4495ad2..7fc965bcb 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -526,6 +526,29 @@ Unlike Discord (where reactions are additive), Telegram's Bot API replaces all b If the bot doesn't have permission to add reactions in a group, the reaction calls fail silently and message processing continues normally. ::: +## Per-Channel Prompts + +Assign ephemeral system prompts to specific Telegram groups or forum topics. The prompt is injected at runtime on every turn — never persisted to transcript history — so changes take effect immediately. + +```yaml +telegram: + channel_prompts: + "-1001234567890": | + You are a research assistant. Focus on academic sources, + citations, and concise synthesis. + "42": | + This topic is for creative writing feedback. Be warm and + constructive. +``` + +Keys are chat IDs (groups/supergroups) or forum topic IDs. For forum groups, topic-level prompts override the group-level prompt: + +- Message in topic `42` inside group `-1001234567890` → uses topic `42`'s prompt +- Message in topic `99` (no explicit entry) → falls back to group `-1001234567890`'s prompt +- Message in a group with no entry → no channel prompt applied + +Numeric YAML keys are automatically normalized to strings. + ## Troubleshooting | Problem | Solution |