diff --git a/gateway/config.py b/gateway/config.py index 98b191805..fe827a4e7 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -536,6 +536,8 @@ def load_gateway_config() -> GatewayConfig: bridged["free_response_channels"] = platform_cfg["free_response_channels"] if "mention_patterns" in platform_cfg: 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 not bridged: continue plat_data = platforms_data.setdefault(plat.value, {}) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index ebe15b880..28615a006 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -589,8 +589,9 @@ class MessageEvent: reply_to_message_id: Optional[str] = None reply_to_text: Optional[str] = None # Text of the replied-to message (for context injection) - # Auto-loaded skill for topic/channel bindings (e.g., Telegram DM Topics) - auto_skill: Optional[str] = None + # Auto-loaded skill(s) for topic/channel bindings (e.g., Telegram DM Topics, + # Discord channel_skill_bindings). A single name or ordered list. + auto_skill: Optional[str | list[str]] = None # Internal flag — set for synthetic events (e.g. background process # completion notifications) that must bypass user authorization checks. diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index e503f0edd..1de446428 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -1892,14 +1892,42 @@ class DiscordAdapter(BasePlatformAdapter): chat_topic=chat_topic, ) + _parent_id = str(getattr(getattr(interaction, "channel", None), "parent_id", "") or "") + _skills = self._resolve_channel_skills(thread_id, _parent_id or None) event = MessageEvent( text=text, message_type=MessageType.TEXT, source=source, raw_message=interaction, + auto_skill=_skills, ) await self.handle_message(event) + def _resolve_channel_skills(self, channel_id: str, parent_id: str | None = None) -> list[str] | None: + """Look up auto-skill bindings for a Discord channel/forum thread. + + Config format (in platform extra): + channel_skill_bindings: + - id: "123456" + skills: ["skill-a", "skill-b"] + Also checks parent_id so forum threads inherit the forum's bindings. + """ + bindings = self.config.extra.get("channel_skill_bindings", []) + if not bindings: + return None + ids_to_check = {channel_id} + if parent_id: + ids_to_check.add(parent_id) + for entry in bindings: + entry_id = str(entry.get("id", "")) + if entry_id in ids_to_check: + skills = entry.get("skills") or entry.get("skill") + if isinstance(skills, str): + return [skills] + if isinstance(skills, list) and skills: + return list(dict.fromkeys(skills)) # dedup, preserve order + return None + def _thread_parent_channel(self, channel: Any) -> Any: """Return the parent text channel when invoked from a thread.""" return getattr(channel, "parent", None) or channel @@ -2484,6 +2512,10 @@ class DiscordAdapter(BasePlatformAdapter): if not event_text or not event_text.strip(): event_text = "(The user sent a message with no text content)" + _chan = message.channel + _parent_id = str(getattr(_chan, "parent_id", "") or "") + _chan_id = str(getattr(_chan, "id", "")) + _skills = self._resolve_channel_skills(_chan_id, _parent_id or None) event = MessageEvent( text=event_text, message_type=msg_type, @@ -2494,6 +2526,7 @@ class DiscordAdapter(BasePlatformAdapter): media_types=media_types, reply_to_message_id=str(message.reference.message_id) if message.reference else None, timestamp=message.created_at, + auto_skill=_skills, ) # Track thread participation so the bot won't require @mention for diff --git a/gateway/run.py b/gateway/run.py index 07acc30c6..8536aa870 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2419,37 +2419,41 @@ class GatewayRunner: session_entry.was_auto_reset = False session_entry.auto_reset_reason = None - # Auto-load skill for DM topic bindings (e.g., Telegram Private Chat Topics) - # Only inject on NEW sessions — for ongoing conversations the skill content - # is already in the conversation history from the first message. - if _is_new_session and getattr(event, "auto_skill", None): + # Auto-load skill(s) for topic/channel bindings (Telegram DM Topics, + # Discord channel_skill_bindings). Supports a single name or ordered list. + # Only inject on NEW sessions — ongoing conversations already have the + # skill content in their conversation history from the first message. + _auto = getattr(event, "auto_skill", None) + if _is_new_session and _auto: + _skill_names = [_auto] if isinstance(_auto, str) else list(_auto) try: from agent.skill_commands import _load_skill_payload, _build_skill_message - _skill_name = event.auto_skill - _loaded = _load_skill_payload(_skill_name, task_id=_quick_key) - if _loaded: - _loaded_skill, _skill_dir, _display_name = _loaded - _activation_note = ( - f'[SYSTEM: This conversation is in a topic with the "{_display_name}" skill ' - f"auto-loaded. Follow its instructions for the duration of this session.]" - ) - _skill_msg = _build_skill_message( - _loaded_skill, _skill_dir, _activation_note, - user_instruction=event.text, - ) - if _skill_msg: - event.text = _skill_msg - logger.info( - "[Gateway] Auto-loaded skill '%s' for DM topic session %s", - _skill_name, session_key, + _combined_parts: list[str] = [] + _loaded_names: list[str] = [] + for _sname in _skill_names: + _loaded = _load_skill_payload(_sname, task_id=_quick_key) + if _loaded: + _loaded_skill, _skill_dir, _display_name = _loaded + _note = ( + f'[SYSTEM: The "{_display_name}" skill is auto-loaded. ' + f"Follow its instructions for this session.]" ) - else: - logger.warning( - "[Gateway] DM topic skill '%s' not found in available skills", - _skill_name, + _part = _build_skill_message(_loaded_skill, _skill_dir, _note) + if _part: + _combined_parts.append(_part) + _loaded_names.append(_sname) + else: + logger.warning("[Gateway] Auto-skill '%s' not found", _sname) + if _combined_parts: + # Append the user's original text after all skill payloads + _combined_parts.append(event.text) + event.text = "\n\n".join(_combined_parts) + logger.info( + "[Gateway] Auto-loaded skill(s) %s for session %s", + _loaded_names, session_key, ) except Exception as e: - logger.warning("[Gateway] Failed to auto-load topic skill '%s': %s", event.auto_skill, e) + logger.warning("[Gateway] Failed to auto-load skill(s) %s: %s", _skill_names, e) # Load conversation history from transcript history = self.session_store.load_transcript(session_entry.session_id)