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
10 changes: 10 additions & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,16 @@ platform_toolsets:
# # allowed_chats: ["-1001234567890"]
# extra:
# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages
#
# Discord-specific settings (config.yaml top-level, not under platforms:):
#
# discord:
# require_mention: true # Require @mention in server channels (default: true)
# auto_thread: true # Auto-create thread on @mention (default: true)
# free_response_channels: "" # Channel IDs where no mention is needed
# reactions: true # Show processing reactions (default: true)
# history_backfill: false # Recover missed channel messages on mention (default: false)
# history_backfill_limit: 50 # Max messages to scan backwards (default: 50)

# ─────────────────────────────────────────────────────────────────────────────
# Available toolsets (use these names in platform_toolsets or the toolsets list)
Expand Down
8 changes: 8 additions & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,14 @@ def load_gateway_config() -> GatewayConfig:
if isinstance(ntc, list):
ntc = ",".join(str(v) for v in ntc)
os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc)
# history_backfill: recover missed channel messages for shared sessions
# when require_mention is active. Fetches messages between bot turns
# and prepends them to the user message for context.
if "history_backfill" in discord_cfg and not os.getenv("DISCORD_HISTORY_BACKFILL"):
os.environ["DISCORD_HISTORY_BACKFILL"] = str(discord_cfg["history_backfill"]).lower()
hbl = discord_cfg.get("history_backfill_limit")
if hbl is not None and not os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT"):
os.environ["DISCORD_HISTORY_BACKFILL_LIMIT"] = str(hbl)
# allow_mentions: granular control over what the bot can ping.
# Safe defaults (no @everyone/roles) are applied in the adapter;
# these YAML keys only override when set and let users opt back
Expand Down
6 changes: 6 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,12 @@ class MessageEvent:
# Per-channel ephemeral system prompt (e.g. Discord channel_prompts).
# Applied at API call time and never persisted to transcript history.
channel_prompt: Optional[str] = None

# Channel context recovered by history backfill (e.g. messages between
# bot turns that were missed due to require_mention). Kept separate
# from ``text`` so the sender-prefix logic in run.py can operate on the
# trigger message alone, then prepend this context afterward.
channel_context: Optional[str] = None

# Internal flag — set for synthetic events (e.g. background process
# completion notifications) that must bypass user authorization checks.
Expand Down
183 changes: 181 additions & 2 deletions gateway/platforms/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,10 @@ def __init__(self, config: PlatformConfig):
# chunk only, default), "all" (reply-reference on every chunk).
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
self._slash_commands: bool = self.config.extra.get("slash_commands", True)
# In-memory cache of the bot's last message ID per channel, used by
# history backfill to skip the full scan on hot paths. Falls back to
# scanning channel.history() on cache miss (cold start / restart).
self._last_self_message_id: Dict[str, str] = {}

async def connect(self) -> bool:
"""Connect to Discord and start receiving events."""
Expand Down Expand Up @@ -1459,6 +1463,12 @@ async def send(
raise
message_ids.append(str(msg.id))

# Track the last message we sent in this channel for history
# backfill — avoids a full channel.history() scan on hot paths.
if message_ids:
_target_id = thread_id or chat_id
self._last_self_message_id[_target_id] = message_ids[-1]

return SendResult(
success=True,
message_id=message_ids[0] if message_ids else None,
Expand Down Expand Up @@ -3596,6 +3606,134 @@ def _discord_thread_require_mention(self) -> bool:
return bool(configured)
return os.getenv("DISCORD_THREAD_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on")

def _discord_history_backfill(self) -> bool:
"""Return whether history backfill is enabled for shared sessions."""
configured = self.config.extra.get("history_backfill")
if configured is not None:
if isinstance(configured, str):
return configured.lower() not in ("false", "0", "no", "off")
return bool(configured)
return os.getenv("DISCORD_HISTORY_BACKFILL", "false").lower() in ("true", "1", "yes")

def _discord_history_backfill_limit(self) -> int:
"""Return the max number of messages to scan backwards for context.

In practice the scan usually stops much earlier — at the bot's own
last message in the channel (the natural partition point). This
limit is a safety cap for cold starts and long gaps where no prior
bot message exists in recent history.
"""
configured = self.config.extra.get("history_backfill_limit")
if configured is not None:
try:
return int(configured)
except (ValueError, TypeError):
pass
raw = os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT", "50")
try:
return int(raw)
except (ValueError, TypeError):
return 50

async def _fetch_channel_context(
self,
channel: Any,
before: "DiscordMessage",
) -> str:
"""Fetch recent channel messages for conversational context.

Scans backwards from *before* and collects messages until it hits
a message sent by this bot (the natural partition point between
bot turns) or reaches ``history_backfill_limit``.

Returns a formatted block like::

[Recent channel messages]
[Alice] some message
[Bob [bot]] another message

Returns an empty string if no context is available.
"""
limit = self._discord_history_backfill_limit()
if limit <= 0:
return ""

# Determine which bot messages to include in context
allow_bots_raw = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip()
include_other_bots = allow_bots_raw != "none"

# Use the in-memory cache to narrow the fetch window on hot paths.
# If we know our last message ID in this channel, pass it as `after`
# to avoid scanning the full limit. Falls back to scanning on cache
# miss (cold start / restart).
# Guard: only use the cache when it's chronologically before the
# trigger — Discord snowflake IDs are monotonically increasing, so
# a simple int comparison suffices.
channel_id = str(getattr(channel, "id", ""))
_cached_id = self._last_self_message_id.get(channel_id)
_after_obj = None
try:
if _cached_id and int(_cached_id) < int(before.id):
_after_obj = discord.Object(id=int(_cached_id))
except (ValueError, TypeError):
pass # Malformed cache entry — fall back to cold-start scan

try:
collected = []
# IMPORTANT: pass oldest_first=False explicitly. discord.py 2.x
# silently flips the default to True when `after=` is supplied,
# which would select the *earliest* N messages after our last
# response instead of the *latest* N before the trigger. In
# high-traffic windows that returns stale tool traces and drops
# the actual final answer. See the regression test
# `test_fetch_channel_context_cache_uses_latest_window_when_after_set`.
async for msg in channel.history(
limit=limit,
before=before,
after=_after_obj,
oldest_first=False,
):
# Stop at our own message — this is the partition point.
# Everything before this is already in the session transcript.
# (Redundant when _after_obj is set, but needed for cold start.)
if msg.author == self._client.user:
break

# Skip system messages (pins, joins, thread renames, etc.)
if msg.type not in (discord.MessageType.default, discord.MessageType.reply):
continue

# Respect DISCORD_ALLOW_BOTS for other bots.
# For history context, "mentions" is treated as "all" — we are
# deciding what context to show, not whether to respond.
if getattr(msg.author, "bot", False) and not include_other_bots:
continue

content = getattr(msg, "clean_content", msg.content) or ""
if not content and msg.attachments:
content = "(attachment)"
if not content:
continue

name = msg.author.display_name
if getattr(msg.author, "bot", False):
name = f"{name} [bot]"
collected.append(f"[{name}] {content}")

if not collected:
return ""

# channel.history returns newest-first (oldest_first=False); reverse for chronological order
collected.reverse()
return "[Recent channel messages]\n" + "\n".join(collected)

except discord.Forbidden:
logger.debug("[%s] Missing permissions to fetch channel history", self.name)
return ""
except Exception as e:
logger.warning("[%s] Failed to fetch channel history: %s", self.name, e)
return ""

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
Expand Down Expand Up @@ -4413,9 +4551,49 @@ async def _handle_message(self, message: DiscordMessage) -> None:
if pending_text_injection:
event_text = f"{pending_text_injection}\n\n{event_text}" if event_text else pending_text_injection

# ── History backfill ─────────────────────────────────────────
# When require_mention is active, the bot only processes messages
# that @mention it. This means channel messages between bot turns
# are invisible to the session transcript. To recover that context,
# fetch recent channel history and prepend it to the user message.
#
# The fetch window is: everything after the bot's last message in
# the channel up to (but not including) the current trigger. On
# cold start (no prior bot message found), fetch the last N messages
# and stop at the first self-message encountered.
#
# This only runs for shared sessions (group_sessions_per_user=False
# or shared threads) where multiple users contribute context the bot
# would otherwise miss.
#
# Messages that arrive while the bot is processing (between trigger
# and response) are not captured — this is an accepted simplification
# to keep the partition rule clean.
_channel_context = None
_is_dm = isinstance(message.channel, discord.DMChannel)
if not _is_dm:
_is_shared = (
(is_thread and not self.config.extra.get("thread_sessions_per_user", False))
or (not is_thread and not self.config.extra.get("group_sessions_per_user", True))
)
_needed_mention = (
require_mention
and not is_free_channel
and not in_bot_thread
)
_backfill_enabled = self._discord_history_backfill()
if _is_shared and _needed_mention and _backfill_enabled:
_backfill_text = await self._fetch_channel_context(
message.channel, before=message,
)
if _backfill_text:
_channel_context = _backfill_text

# Defense-in-depth: prevent empty user messages from entering session
# (can happen when user sends @mention-only with no other text)
if not event_text or not event_text.strip():
# (can happen when user sends @mention-only with no other text).
# When channel_context is present, a bare mention means "catch me up"
# — the context IS the message, so skip the placeholder.
if (not event_text or not event_text.strip()) and not _channel_context:
event_text = "(The user sent a message with no text content)"

_chan = message.channel
Expand Down Expand Up @@ -4444,6 +4622,7 @@ async def _handle_message(self, message: DiscordMessage) -> None:
timestamp=message.created_at,
auto_skill=_skills,
channel_prompt=_channel_prompt,
channel_context=_channel_context,
)

# Track thread participation so the bot won't require @mention for
Expand Down
6 changes: 6 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -6800,6 +6800,12 @@ async def _prepare_inbound_message_text(
if _is_shared_multi_user and source.user_name:
message_text = f"[{source.user_name}] {message_text}"

# Prepend channel context from history backfill (if any). This
# happens after sender-prefix so the prefix only applies to the
# trigger message, not the backfill block.
if getattr(event, "channel_context", None):
message_text = f"{event.channel_context}\n\n[New message]\n{message_text}"

if event.media_urls:
image_paths = []
audio_paths = []
Expand Down
2 changes: 2 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1251,6 +1251,8 @@ def _ensure_hermes_home_managed(home: Path):
"allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist)
"auto_thread": True, # Auto-create threads on @mention in channels (like Slack)
"thread_require_mention": False, # If True, require @mention in threads too (multi-bot threads)
"history_backfill": False, # If True, prepend recent channel scrollback when bot is triggered in a shared channel
"history_backfill_limit": 50, # Max number of recent messages to scan when assembling the backfill block
"reactions": True, # Add 👀/✅/❌ reactions to messages during processing
"channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads)
# Opt-in DM role-based auth (#12136). By default, DISCORD_ALLOWED_ROLES
Expand Down
20 changes: 20 additions & 0 deletions tests/gateway/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,26 @@ def test_bridges_discord_channel_prompts_from_config_yaml(self, tmp_path, monkey
"456": "Therapist mode",
}

def test_bridges_discord_history_backfill_settings_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(
"discord:\n"
" history_backfill: true\n"
" history_backfill_limit: 17\n",
encoding="utf-8",
)

monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("DISCORD_HISTORY_BACKFILL", raising=False)
monkeypatch.delenv("DISCORD_HISTORY_BACKFILL_LIMIT", raising=False)

load_gateway_config()

assert os.getenv("DISCORD_HISTORY_BACKFILL") == "true"
assert os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT") == "17"

def test_bridges_telegram_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
Expand Down
Loading
Loading