feat: extend channel_prompts to Telegram, Slack, and Mattermost
Extract resolve_channel_prompt() shared helper into gateway/platforms/base.py. Refactor Discord to use it. Wire channel_prompts into Telegram (groups + forum topics), Slack (channels), and Mattermost (channels). Config bridging now applies to all platforms (not just Discord). Added channel_prompts defaults to telegram/slack/mattermost config sections. Docs added to all four platform pages with platform-specific examples (topic inheritance for Telegram, channel IDs for Slack, etc.).
This commit is contained in:
@@ -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()}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user