Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,23 @@ def __init__(self, config: PlatformConfig):
# Tracks status bubbles owned by this adapter so subsequent calls with the
# same key edit the same message instead of appending new ones (#30045).
self._status_message_ids: Dict[tuple, str] = {}
# Topic-to-profile routing: dispatch messages from specific forum
# topics to a different Hermes profile (model, skills, memory, SOUL).
# Config: telegram.topic_profiles:
# - match: {chat_id: "-100...", thread_id: "2460"}
# profile: "fitness"
self._topic_profiles: Dict[tuple, str] = {}
_tp_cfg = self.config.extra.get("topic_profiles")
if isinstance(_tp_cfg, list):
for entry in _tp_cfg:
if not isinstance(entry, dict):
continue
match = entry.get("match", {}) or {}
chat_id = str(match.get("chat_id", ""))
thread_id = str(match.get("thread_id", ""))
profile = str(entry.get("profile", "")).strip()
if chat_id and profile:
self._topic_profiles[(chat_id, thread_id)] = profile

def _notification_kwargs(
self, metadata: Optional[Dict[str, Any]]
Expand All @@ -507,6 +524,41 @@ def _notification_kwargs(
return {}
return {"disable_notification": True}

def _resolve_topic_profile(self, msg):
"""Resolve target profile for a message based on topic_profiles routing.

Checks the message's chat_id + message_thread_id against the
configured topic_profiles routing table. Returns the target
profile name on match, or None to use the default profile.
"""
if not self._topic_profiles or not msg:
return None
chat_id = str(getattr(getattr(msg, "chat", None), "id", "") or "")
thread_id = str(
getattr(msg, "message_thread_id", None) or ""
)
# Exact match first: chat_id + thread_id
profile = self._topic_profiles.get((chat_id, thread_id))
if profile:
return profile
# Fallback: chat_id + empty thread_id (messages without thread_id)
if thread_id:
profile = self._topic_profiles.get((chat_id, ""))
if profile:
return profile
return None

def _apply_topic_profile_routing(self, event, msg):
"""Apply topic-to-profile routing to a MessageEvent.

Calls _resolve_topic_profile and attaches the target profile to
the event. The gateway runner detects this attribute and loads
the target profile config for the session.
"""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main routes profile identity through SessionSource.profile, not routing_profile; the runner scopes config, skills, memory, SOUL, and credentials only from source.profile under multiplexing (gateway/run.py:16947-16987). Please rework this against the current profile-routing contract rather than adding a parallel field.

profile = self._resolve_topic_profile(msg)
if profile:
event.source.routing_profile = profile

def _is_callback_user_authorized(
self,
user_id: str,
Expand Down Expand Up @@ -5093,6 +5145,7 @@ async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TY
event = self._build_message_event(msg, MessageType.COMMAND, update_id=update.update_id)
event.text = self._clean_bot_trigger_text(event.text)
event = self._apply_telegram_group_observe_attribution(event)
self._apply_topic_profile_routing(event, msg)
await self.handle_message(event)

async def _handle_location_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
Expand Down Expand Up @@ -5133,6 +5186,7 @@ async def _handle_location_message(self, update: Update, context: ContextTypes.D
event = self._build_message_event(msg, MessageType.LOCATION, update_id=update.update_id)
event.text = "\n".join(parts)
event = self._apply_telegram_group_observe_attribution(event)
self._apply_topic_profile_routing(event, msg)
await self.handle_message(event)

# ------------------------------------------------------------------
Expand Down Expand Up @@ -5334,6 +5388,7 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA
# Apply observe attribution after caption is set; sticker is handled above
# because _handle_sticker overwrites event.text with its vision description.
event = self._apply_telegram_group_observe_attribution(event)
self._apply_topic_profile_routing(event, msg)

# Download photo to local image cache so the vision tool can access it
# even after Telegram's ephemeral file URLs expire (~1 hour).
Expand Down
24 changes: 24 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1534,6 +1534,25 @@ def _load_gateway_config() -> dict:
return {}


def _load_profile_config(profile_name: str) -> dict:
"""Load a specific Hermes profile config.yaml.

Returns the parsed config dict or {} on error. Used by
topic-to-profile routing to load the target profile model,
tools, and skills during agent creation.
"""
profile_home = get_hermes_home().parent / profile_name
config_path = profile_home / "config.yaml"
try:
if config_path.exists():
import yaml
with open(config_path, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
except Exception:
logger.debug("Could not load profile config from %s", config_path)
return {}


def _load_gateway_runtime_config() -> dict:
"""Load gateway config for runtime reads, expanding supported ``${VAR}`` refs.

Expand Down Expand Up @@ -16654,6 +16673,11 @@ def _run_still_current() -> bool:
return self._is_session_run_current(session_key, run_generation)

user_config = _load_gateway_config()
# Topic-to-profile routing: override config with target profile
if getattr(source, "routing_profile", None):
profile_cfg = _load_profile_config(source.routing_profile)
if profile_cfg:
user_config = profile_cfg
platform_key = _platform_config_key(source.platform)

from hermes_cli.tools_config import _get_platform_tools
Expand Down
1 change: 1 addition & 0 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ class SessionSource:
user_id: Optional[str] = None
user_name: Optional[str] = None
thread_id: Optional[str] = None # For forum topics, Discord threads, etc.
routing_profile: Optional[str] = None # Target Hermes profile for topic-to-profile routing
chat_topic: Optional[str] = None # Channel topic/description (Discord, Slack)
user_id_alt: Optional[str] = None # Platform-specific stable alt ID (Signal UUID, Feishu union_id)
chat_id_alt: Optional[str] = None # Signal group internal ID
Expand Down