mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 00:11:39 +08:00
Compare commits
5 Commits
skill/gith
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a48bedf1d | ||
|
|
7d9f1bd484 | ||
|
|
83864111ca | ||
|
|
e64c2044ab | ||
|
|
77910266e4 |
@@ -556,6 +556,18 @@ def load_gateway_config() -> GatewayConfig:
|
|||||||
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
|
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
|
||||||
if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"):
|
if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"):
|
||||||
os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower()
|
os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower()
|
||||||
|
# ignored_channels: channels where bot never responds (even when mentioned)
|
||||||
|
ic = discord_cfg.get("ignored_channels")
|
||||||
|
if ic is not None and not os.getenv("DISCORD_IGNORED_CHANNELS"):
|
||||||
|
if isinstance(ic, list):
|
||||||
|
ic = ",".join(str(v) for v in ic)
|
||||||
|
os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic)
|
||||||
|
# no_thread_channels: channels where bot responds directly without creating thread
|
||||||
|
ntc = discord_cfg.get("no_thread_channels")
|
||||||
|
if ntc is not None and not os.getenv("DISCORD_NO_THREAD_CHANNELS"):
|
||||||
|
if isinstance(ntc, list):
|
||||||
|
ntc = ",".join(str(v) for v in ntc)
|
||||||
|
os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc)
|
||||||
|
|
||||||
# Telegram settings → env vars (env vars take precedence)
|
# Telegram settings → env vars (env vars take precedence)
|
||||||
telegram_cfg = yaml_cfg.get("telegram", {})
|
telegram_cfg = yaml_cfg.get("telegram", {})
|
||||||
@@ -570,6 +582,8 @@ def load_gateway_config() -> GatewayConfig:
|
|||||||
if isinstance(frc, list):
|
if isinstance(frc, list):
|
||||||
frc = ",".join(str(v) for v in frc)
|
frc = ",".join(str(v) for v in frc)
|
||||||
os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
|
os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
|
||||||
|
if "reactions" in telegram_cfg and not os.getenv("TELEGRAM_REACTIONS"):
|
||||||
|
os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower()
|
||||||
|
|
||||||
whatsapp_cfg = yaml_cfg.get("whatsapp", {})
|
whatsapp_cfg = yaml_cfg.get("whatsapp", {})
|
||||||
if isinstance(whatsapp_cfg, dict):
|
if isinstance(whatsapp_cfg, dict):
|
||||||
|
|||||||
@@ -2193,9 +2193,11 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
# UNLESS the channel is in the free-response list or the message is
|
# UNLESS the channel is in the free-response list or the message is
|
||||||
# in a thread where the bot has already participated.
|
# in a thread where the bot has already participated.
|
||||||
#
|
#
|
||||||
# Config (all settable via discord.* in config.yaml):
|
# Config (all settable via discord.* in config.yaml or DISCORD_* env vars):
|
||||||
# discord.require_mention: Require @mention in server channels (default: true)
|
# discord.require_mention: Require @mention in server channels (default: true)
|
||||||
# discord.free_response_channels: Channel IDs where bot responds without mention
|
# discord.free_response_channels: Channel IDs where bot responds without mention
|
||||||
|
# discord.ignored_channels: Channel IDs where bot NEVER responds (even when mentioned)
|
||||||
|
# discord.no_thread_channels: Channel IDs where bot responds directly without creating thread
|
||||||
# discord.auto_thread: Auto-create thread on @mention in channels (default: true)
|
# discord.auto_thread: Auto-create thread on @mention in channels (default: true)
|
||||||
|
|
||||||
thread_id = None
|
thread_id = None
|
||||||
@@ -2206,9 +2208,18 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
parent_channel_id = self._get_parent_channel_id(message.channel)
|
parent_channel_id = self._get_parent_channel_id(message.channel)
|
||||||
|
|
||||||
if not isinstance(message.channel, discord.DMChannel):
|
if not isinstance(message.channel, discord.DMChannel):
|
||||||
|
# Check ignored channels first - never respond even when mentioned
|
||||||
|
ignored_channels_raw = os.getenv("DISCORD_IGNORED_CHANNELS", "")
|
||||||
|
ignored_channels = {ch.strip() for ch in ignored_channels_raw.split(",") if ch.strip()}
|
||||||
|
channel_ids = {str(message.channel.id)}
|
||||||
|
if parent_channel_id:
|
||||||
|
channel_ids.add(parent_channel_id)
|
||||||
|
if channel_ids & ignored_channels:
|
||||||
|
logger.debug("[%s] Ignoring message in ignored channel: %s", self.name, channel_ids)
|
||||||
|
return
|
||||||
|
|
||||||
free_channels_raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "")
|
free_channels_raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "")
|
||||||
free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()}
|
free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()}
|
||||||
channel_ids = {str(message.channel.id)}
|
|
||||||
if parent_channel_id:
|
if parent_channel_id:
|
||||||
channel_ids.add(parent_channel_id)
|
channel_ids.add(parent_channel_id)
|
||||||
|
|
||||||
@@ -2230,10 +2241,14 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
# Auto-thread: when enabled, automatically create a thread for every
|
# Auto-thread: when enabled, automatically create a thread for every
|
||||||
# @mention in a text channel so each conversation is isolated (like Slack).
|
# @mention in a text channel so each conversation is isolated (like Slack).
|
||||||
# Messages already inside threads or DMs are unaffected.
|
# Messages already inside threads or DMs are unaffected.
|
||||||
|
# no_thread_channels: channels where bot responds directly without thread.
|
||||||
auto_threaded_channel = None
|
auto_threaded_channel = None
|
||||||
if not is_thread and not isinstance(message.channel, discord.DMChannel):
|
if not is_thread and not isinstance(message.channel, discord.DMChannel):
|
||||||
|
no_thread_channels_raw = os.getenv("DISCORD_NO_THREAD_CHANNELS", "")
|
||||||
|
no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(",") if ch.strip()}
|
||||||
|
skip_thread = bool(channel_ids & no_thread_channels)
|
||||||
auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in ("true", "1", "yes")
|
auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in ("true", "1", "yes")
|
||||||
if auto_thread:
|
if auto_thread and not skip_thread:
|
||||||
thread = await self._auto_create_thread(message)
|
thread = await self._auto_create_thread(message)
|
||||||
if thread:
|
if thread:
|
||||||
is_thread = True
|
is_thread = True
|
||||||
|
|||||||
@@ -2673,3 +2673,46 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||||||
auto_skill=topic_skill,
|
auto_skill=topic_skill,
|
||||||
timestamp=message.date,
|
timestamp=message.date,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Message reactions (processing lifecycle) ──────────────────────────
|
||||||
|
|
||||||
|
def _reactions_enabled(self) -> bool:
|
||||||
|
"""Check if message reactions are enabled via config/env."""
|
||||||
|
return os.getenv("TELEGRAM_REACTIONS", "false").lower() not in ("false", "0", "no")
|
||||||
|
|
||||||
|
async def _set_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
|
||||||
|
"""Set a single emoji reaction on a Telegram message."""
|
||||||
|
if not self._bot:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
await self._bot.set_message_reaction(
|
||||||
|
chat_id=int(chat_id),
|
||||||
|
message_id=int(message_id),
|
||||||
|
reaction=emoji,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("[%s] set_message_reaction failed (%s): %s", self.name, emoji, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def on_processing_start(self, event: MessageEvent) -> None:
|
||||||
|
"""Add an in-progress reaction when message processing begins."""
|
||||||
|
if not self._reactions_enabled():
|
||||||
|
return
|
||||||
|
chat_id = getattr(event.source, "chat_id", None)
|
||||||
|
message_id = getattr(event, "message_id", None)
|
||||||
|
if chat_id and message_id:
|
||||||
|
await self._set_reaction(chat_id, message_id, "\U0001f440")
|
||||||
|
|
||||||
|
async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
|
||||||
|
"""Swap the in-progress reaction for a final success/failure reaction.
|
||||||
|
|
||||||
|
Unlike Discord (additive reactions), Telegram's set_message_reaction
|
||||||
|
replaces all existing reactions in one call — no remove step needed.
|
||||||
|
"""
|
||||||
|
if not self._reactions_enabled():
|
||||||
|
return
|
||||||
|
chat_id = getattr(event.source, "chat_id", None)
|
||||||
|
message_id = getattr(event, "message_id", None)
|
||||||
|
if chat_id and message_id:
|
||||||
|
await self._set_reaction(chat_id, message_id, "\u2705" if success else "\u274c")
|
||||||
|
|||||||
343
tests/gateway/test_discord_channel_controls.py
Normal file
343
tests/gateway/test_discord_channel_controls.py
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
"""Tests for Discord ignored_channels and no_thread_channels config."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gateway.config import PlatformConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_discord_mock():
|
||||||
|
"""Install a mock discord module when discord.py isn't available."""
|
||||||
|
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
|
||||||
|
return
|
||||||
|
|
||||||
|
discord_mod = MagicMock()
|
||||||
|
discord_mod.Intents.default.return_value = MagicMock()
|
||||||
|
discord_mod.Client = MagicMock
|
||||||
|
discord_mod.File = MagicMock
|
||||||
|
discord_mod.DMChannel = type("DMChannel", (), {})
|
||||||
|
discord_mod.Thread = type("Thread", (), {})
|
||||||
|
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
||||||
|
discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
|
||||||
|
discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)
|
||||||
|
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)
|
||||||
|
discord_mod.Interaction = object
|
||||||
|
discord_mod.Embed = MagicMock
|
||||||
|
discord_mod.app_commands = SimpleNamespace(
|
||||||
|
describe=lambda **kwargs: (lambda fn: fn),
|
||||||
|
choices=lambda **kwargs: (lambda fn: fn),
|
||||||
|
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
|
ext_mod = MagicMock()
|
||||||
|
commands_mod = MagicMock()
|
||||||
|
commands_mod.Bot = MagicMock
|
||||||
|
ext_mod.commands = commands_mod
|
||||||
|
|
||||||
|
sys.modules.setdefault("discord", discord_mod)
|
||||||
|
sys.modules.setdefault("discord.ext", ext_mod)
|
||||||
|
sys.modules.setdefault("discord.ext.commands", commands_mod)
|
||||||
|
|
||||||
|
|
||||||
|
_ensure_discord_mock()
|
||||||
|
|
||||||
|
import gateway.platforms.discord as discord_platform # noqa: E402
|
||||||
|
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDMChannel:
|
||||||
|
def __init__(self, channel_id: int = 1, name: str = "dm"):
|
||||||
|
self.id = channel_id
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTextChannel:
|
||||||
|
def __init__(self, channel_id: int = 1, name: str = "general", guild_name: str = "Hermes Server"):
|
||||||
|
self.id = channel_id
|
||||||
|
self.name = name
|
||||||
|
self.guild = SimpleNamespace(name=guild_name)
|
||||||
|
self.topic = None
|
||||||
|
|
||||||
|
|
||||||
|
class FakeThread:
|
||||||
|
def __init__(self, channel_id: int = 1, name: str = "thread", parent=None, guild_name: str = "Hermes Server"):
|
||||||
|
self.id = channel_id
|
||||||
|
self.name = name
|
||||||
|
self.parent = parent
|
||||||
|
self.parent_id = getattr(parent, "id", None)
|
||||||
|
self.guild = getattr(parent, "guild", None) or SimpleNamespace(name=guild_name)
|
||||||
|
self.topic = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def adapter(monkeypatch):
|
||||||
|
monkeypatch.setattr(discord_platform.discord, "DMChannel", FakeDMChannel, raising=False)
|
||||||
|
monkeypatch.setattr(discord_platform.discord, "Thread", FakeThread, raising=False)
|
||||||
|
|
||||||
|
config = PlatformConfig(enabled=True, token="fake-token")
|
||||||
|
adapter = DiscordAdapter(config)
|
||||||
|
adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
|
||||||
|
adapter.handle_message = AsyncMock()
|
||||||
|
return adapter
|
||||||
|
|
||||||
|
|
||||||
|
def make_message(*, channel, content: str, mentions=None):
|
||||||
|
author = SimpleNamespace(id=42, display_name="TestUser", name="TestUser")
|
||||||
|
return SimpleNamespace(
|
||||||
|
id=123,
|
||||||
|
content=content,
|
||||||
|
mentions=list(mentions or []),
|
||||||
|
attachments=[],
|
||||||
|
reference=None,
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
channel=channel,
|
||||||
|
author=author,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── ignored_channels ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ignored_channel_blocks_message(adapter, monkeypatch):
|
||||||
|
"""Messages in ignored channels are silently dropped."""
|
||||||
|
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||||
|
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500")
|
||||||
|
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||||
|
|
||||||
|
message = make_message(channel=FakeTextChannel(channel_id=500), content="hello")
|
||||||
|
await adapter._handle_message(message)
|
||||||
|
|
||||||
|
adapter.handle_message.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ignored_channel_blocks_even_with_mention(adapter, monkeypatch):
|
||||||
|
"""Ignored channels take priority — even @mentions are dropped."""
|
||||||
|
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
|
||||||
|
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500")
|
||||||
|
|
||||||
|
bot_user = adapter._client.user
|
||||||
|
message = make_message(
|
||||||
|
channel=FakeTextChannel(channel_id=500),
|
||||||
|
content=f"<@{bot_user.id}> hello",
|
||||||
|
mentions=[bot_user],
|
||||||
|
)
|
||||||
|
await adapter._handle_message(message)
|
||||||
|
|
||||||
|
adapter.handle_message.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_non_ignored_channel_processes_normally(adapter, monkeypatch):
|
||||||
|
"""Channels not in the ignored list process normally."""
|
||||||
|
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||||
|
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500,600")
|
||||||
|
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||||
|
|
||||||
|
message = make_message(channel=FakeTextChannel(channel_id=700), content="hello")
|
||||||
|
await adapter._handle_message(message)
|
||||||
|
|
||||||
|
adapter.handle_message.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ignored_channels_csv_parsing(adapter, monkeypatch):
|
||||||
|
"""Multiple channel IDs are parsed correctly from CSV."""
|
||||||
|
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||||
|
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500, 600 , 700")
|
||||||
|
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||||
|
|
||||||
|
for ch_id in (500, 600, 700):
|
||||||
|
adapter.handle_message.reset_mock()
|
||||||
|
message = make_message(channel=FakeTextChannel(channel_id=ch_id), content="hello")
|
||||||
|
await adapter._handle_message(message)
|
||||||
|
adapter.handle_message.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ignored_channels_empty_string_ignores_nothing(adapter, monkeypatch):
|
||||||
|
"""Empty DISCORD_IGNORED_CHANNELS means nothing is ignored."""
|
||||||
|
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||||
|
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "")
|
||||||
|
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||||
|
|
||||||
|
message = make_message(channel=FakeTextChannel(channel_id=500), content="hello")
|
||||||
|
await adapter._handle_message(message)
|
||||||
|
|
||||||
|
adapter.handle_message.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ignored_channel_thread_parent_match(adapter, monkeypatch):
|
||||||
|
"""Thread whose parent channel is ignored should also be ignored."""
|
||||||
|
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||||
|
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500")
|
||||||
|
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||||
|
|
||||||
|
parent = FakeTextChannel(channel_id=500, name="ignored-channel")
|
||||||
|
thread = FakeThread(channel_id=501, name="thread-in-ignored", parent=parent)
|
||||||
|
message = make_message(channel=thread, content="hello from thread")
|
||||||
|
await adapter._handle_message(message)
|
||||||
|
|
||||||
|
adapter.handle_message.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dms_unaffected_by_ignored_channels(adapter, monkeypatch):
|
||||||
|
"""DMs should never be affected by ignored_channels."""
|
||||||
|
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500")
|
||||||
|
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||||
|
|
||||||
|
message = make_message(channel=FakeDMChannel(channel_id=500), content="dm hello")
|
||||||
|
await adapter._handle_message(message)
|
||||||
|
|
||||||
|
adapter.handle_message.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ── no_thread_channels ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_thread_channel_skips_auto_thread(adapter, monkeypatch):
|
||||||
|
"""Channels in no_thread_channels should not auto-create threads."""
|
||||||
|
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||||
|
monkeypatch.setenv("DISCORD_NO_THREAD_CHANNELS", "800")
|
||||||
|
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
|
||||||
|
monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False)
|
||||||
|
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||||
|
|
||||||
|
adapter._auto_create_thread = AsyncMock(return_value=FakeThread(channel_id=999))
|
||||||
|
|
||||||
|
message = make_message(channel=FakeTextChannel(channel_id=800), content="hello")
|
||||||
|
await adapter._handle_message(message)
|
||||||
|
|
||||||
|
adapter._auto_create_thread.assert_not_awaited()
|
||||||
|
adapter.handle_message.assert_awaited_once()
|
||||||
|
event = adapter.handle_message.await_args.args[0]
|
||||||
|
assert event.source.chat_type == "group"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normal_channel_still_auto_threads(adapter, monkeypatch):
|
||||||
|
"""Channels NOT in no_thread_channels still get auto-threading."""
|
||||||
|
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||||
|
monkeypatch.setenv("DISCORD_NO_THREAD_CHANNELS", "800")
|
||||||
|
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
|
||||||
|
monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False)
|
||||||
|
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||||
|
|
||||||
|
fake_thread = FakeThread(channel_id=999, name="auto-thread")
|
||||||
|
adapter._auto_create_thread = AsyncMock(return_value=fake_thread)
|
||||||
|
|
||||||
|
message = make_message(channel=FakeTextChannel(channel_id=900), content="hello")
|
||||||
|
await adapter._handle_message(message)
|
||||||
|
|
||||||
|
adapter._auto_create_thread.assert_awaited_once()
|
||||||
|
adapter.handle_message.assert_awaited_once()
|
||||||
|
event = adapter.handle_message.await_args.args[0]
|
||||||
|
assert event.source.chat_type == "thread"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_thread_channels_csv_parsing(adapter, monkeypatch):
|
||||||
|
"""Multiple no_thread channel IDs parsed from CSV."""
|
||||||
|
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||||
|
monkeypatch.setenv("DISCORD_NO_THREAD_CHANNELS", "800, 900")
|
||||||
|
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
|
||||||
|
monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False)
|
||||||
|
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||||
|
|
||||||
|
adapter._auto_create_thread = AsyncMock(return_value=FakeThread(channel_id=999))
|
||||||
|
|
||||||
|
for ch_id in (800, 900):
|
||||||
|
adapter._auto_create_thread.reset_mock()
|
||||||
|
adapter.handle_message.reset_mock()
|
||||||
|
message = make_message(channel=FakeTextChannel(channel_id=ch_id), content="hello")
|
||||||
|
await adapter._handle_message(message)
|
||||||
|
adapter._auto_create_thread.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_thread_with_auto_thread_disabled_is_noop(adapter, monkeypatch):
|
||||||
|
"""no_thread_channels is a no-op when auto_thread is globally disabled."""
|
||||||
|
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||||
|
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
|
||||||
|
monkeypatch.setenv("DISCORD_NO_THREAD_CHANNELS", "800")
|
||||||
|
monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False)
|
||||||
|
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||||
|
|
||||||
|
adapter._auto_create_thread = AsyncMock()
|
||||||
|
|
||||||
|
message = make_message(channel=FakeTextChannel(channel_id=800), content="hello")
|
||||||
|
await adapter._handle_message(message)
|
||||||
|
|
||||||
|
adapter._auto_create_thread.assert_not_awaited()
|
||||||
|
adapter.handle_message.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ── config.py bridging ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_bridges_ignored_channels(monkeypatch, tmp_path):
|
||||||
|
"""gateway/config.py bridges discord.ignored_channels to env var."""
|
||||||
|
import yaml
|
||||||
|
config_file = tmp_path / "config.yaml"
|
||||||
|
config_file.write_text(yaml.dump({
|
||||||
|
"discord": {
|
||||||
|
"ignored_channels": ["111", "222"],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
# Use setenv (not delenv) so monkeypatch registers cleanup even when
|
||||||
|
# the var doesn't exist yet — load_gateway_config will overwrite it.
|
||||||
|
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "")
|
||||||
|
|
||||||
|
from gateway.config import load_gateway_config
|
||||||
|
load_gateway_config()
|
||||||
|
|
||||||
|
import os
|
||||||
|
assert os.getenv("DISCORD_IGNORED_CHANNELS") == "111,222"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_bridges_no_thread_channels(monkeypatch, tmp_path):
|
||||||
|
"""gateway/config.py bridges discord.no_thread_channels to env var."""
|
||||||
|
import yaml
|
||||||
|
config_file = tmp_path / "config.yaml"
|
||||||
|
config_file.write_text(yaml.dump({
|
||||||
|
"discord": {
|
||||||
|
"no_thread_channels": ["333"],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
monkeypatch.setenv("DISCORD_NO_THREAD_CHANNELS", "")
|
||||||
|
|
||||||
|
from gateway.config import load_gateway_config
|
||||||
|
load_gateway_config()
|
||||||
|
|
||||||
|
import os
|
||||||
|
assert os.getenv("DISCORD_NO_THREAD_CHANNELS") == "333"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_env_var_takes_precedence(monkeypatch, tmp_path):
|
||||||
|
"""Env vars should take precedence over config.yaml values."""
|
||||||
|
import yaml
|
||||||
|
config_file = tmp_path / "config.yaml"
|
||||||
|
config_file.write_text(yaml.dump({
|
||||||
|
"discord": {
|
||||||
|
"ignored_channels": ["111"],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "999")
|
||||||
|
|
||||||
|
from gateway.config import load_gateway_config
|
||||||
|
load_gateway_config()
|
||||||
|
|
||||||
|
import os
|
||||||
|
# Env var should NOT be overwritten
|
||||||
|
assert os.getenv("DISCORD_IGNORED_CHANNELS") == "999"
|
||||||
260
tests/gateway/test_telegram_reactions.py
Normal file
260
tests/gateway/test_telegram_reactions.py
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"""Tests for Telegram message reactions tied to processing lifecycle hooks."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gateway.config import Platform, PlatformConfig
|
||||||
|
from gateway.platforms.base import MessageEvent, MessageType
|
||||||
|
from gateway.session import SessionSource
|
||||||
|
|
||||||
|
|
||||||
|
def _make_adapter(**extra_env):
|
||||||
|
from gateway.platforms.telegram import TelegramAdapter
|
||||||
|
|
||||||
|
adapter = object.__new__(TelegramAdapter)
|
||||||
|
adapter.platform = Platform.TELEGRAM
|
||||||
|
adapter.config = PlatformConfig(enabled=True, token="fake-token")
|
||||||
|
adapter._bot = AsyncMock()
|
||||||
|
adapter._bot.set_message_reaction = AsyncMock()
|
||||||
|
return adapter
|
||||||
|
|
||||||
|
|
||||||
|
def _make_event(chat_id: str = "123", message_id: str = "456") -> MessageEvent:
|
||||||
|
return MessageEvent(
|
||||||
|
text="hello",
|
||||||
|
message_type=MessageType.TEXT,
|
||||||
|
source=SessionSource(
|
||||||
|
platform=Platform.TELEGRAM,
|
||||||
|
chat_id=chat_id,
|
||||||
|
chat_type="private",
|
||||||
|
user_id="42",
|
||||||
|
user_name="TestUser",
|
||||||
|
),
|
||||||
|
message_id=message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _reactions_enabled ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_reactions_disabled_by_default(monkeypatch):
|
||||||
|
"""Telegram reactions should be disabled by default."""
|
||||||
|
monkeypatch.delenv("TELEGRAM_REACTIONS", raising=False)
|
||||||
|
adapter = _make_adapter()
|
||||||
|
assert adapter._reactions_enabled() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_reactions_enabled_when_set_true(monkeypatch):
|
||||||
|
"""Setting TELEGRAM_REACTIONS=true enables reactions."""
|
||||||
|
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
|
||||||
|
adapter = _make_adapter()
|
||||||
|
assert adapter._reactions_enabled() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_reactions_enabled_with_1(monkeypatch):
|
||||||
|
"""TELEGRAM_REACTIONS=1 enables reactions."""
|
||||||
|
monkeypatch.setenv("TELEGRAM_REACTIONS", "1")
|
||||||
|
adapter = _make_adapter()
|
||||||
|
assert adapter._reactions_enabled() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_reactions_disabled_with_false(monkeypatch):
|
||||||
|
"""TELEGRAM_REACTIONS=false disables reactions."""
|
||||||
|
monkeypatch.setenv("TELEGRAM_REACTIONS", "false")
|
||||||
|
adapter = _make_adapter()
|
||||||
|
assert adapter._reactions_enabled() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_reactions_disabled_with_0(monkeypatch):
|
||||||
|
"""TELEGRAM_REACTIONS=0 disables reactions."""
|
||||||
|
monkeypatch.setenv("TELEGRAM_REACTIONS", "0")
|
||||||
|
adapter = _make_adapter()
|
||||||
|
assert adapter._reactions_enabled() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_reactions_disabled_with_no(monkeypatch):
|
||||||
|
"""TELEGRAM_REACTIONS=no disables reactions."""
|
||||||
|
monkeypatch.setenv("TELEGRAM_REACTIONS", "no")
|
||||||
|
adapter = _make_adapter()
|
||||||
|
assert adapter._reactions_enabled() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── _set_reaction ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_reaction_calls_bot_api(monkeypatch):
|
||||||
|
"""_set_reaction should call bot.set_message_reaction with correct args."""
|
||||||
|
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
|
||||||
|
adapter = _make_adapter()
|
||||||
|
|
||||||
|
result = await adapter._set_reaction("123", "456", "\U0001f440")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
adapter._bot.set_message_reaction.assert_awaited_once_with(
|
||||||
|
chat_id=123,
|
||||||
|
message_id=456,
|
||||||
|
reaction="\U0001f440",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_reaction_returns_false_without_bot(monkeypatch):
|
||||||
|
"""_set_reaction should return False when bot is not available."""
|
||||||
|
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
|
||||||
|
adapter = _make_adapter()
|
||||||
|
adapter._bot = None
|
||||||
|
|
||||||
|
result = await adapter._set_reaction("123", "456", "\U0001f440")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_reaction_handles_api_error_gracefully(monkeypatch):
|
||||||
|
"""API errors during reaction should not propagate."""
|
||||||
|
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
|
||||||
|
adapter = _make_adapter()
|
||||||
|
adapter._bot.set_message_reaction = AsyncMock(side_effect=RuntimeError("no perms"))
|
||||||
|
|
||||||
|
result = await adapter._set_reaction("123", "456", "\U0001f440")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── on_processing_start ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_processing_start_adds_eyes_reaction(monkeypatch):
|
||||||
|
"""Processing start should add eyes reaction when enabled."""
|
||||||
|
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
|
||||||
|
adapter = _make_adapter()
|
||||||
|
event = _make_event()
|
||||||
|
|
||||||
|
await adapter.on_processing_start(event)
|
||||||
|
|
||||||
|
adapter._bot.set_message_reaction.assert_awaited_once_with(
|
||||||
|
chat_id=123,
|
||||||
|
message_id=456,
|
||||||
|
reaction="\U0001f440",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_processing_start_skipped_when_disabled(monkeypatch):
|
||||||
|
"""Processing start should not react when reactions are disabled."""
|
||||||
|
monkeypatch.delenv("TELEGRAM_REACTIONS", raising=False)
|
||||||
|
adapter = _make_adapter()
|
||||||
|
event = _make_event()
|
||||||
|
|
||||||
|
await adapter.on_processing_start(event)
|
||||||
|
|
||||||
|
adapter._bot.set_message_reaction.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_processing_start_handles_missing_ids(monkeypatch):
|
||||||
|
"""Should handle events without chat_id or message_id gracefully."""
|
||||||
|
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
|
||||||
|
adapter = _make_adapter()
|
||||||
|
event = MessageEvent(
|
||||||
|
text="hello",
|
||||||
|
message_type=MessageType.TEXT,
|
||||||
|
source=SimpleNamespace(chat_id=None),
|
||||||
|
message_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
await adapter.on_processing_start(event)
|
||||||
|
|
||||||
|
adapter._bot.set_message_reaction.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
# ── on_processing_complete ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_processing_complete_success(monkeypatch):
|
||||||
|
"""Successful processing should set check mark reaction."""
|
||||||
|
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
|
||||||
|
adapter = _make_adapter()
|
||||||
|
event = _make_event()
|
||||||
|
|
||||||
|
await adapter.on_processing_complete(event, success=True)
|
||||||
|
|
||||||
|
adapter._bot.set_message_reaction.assert_awaited_once_with(
|
||||||
|
chat_id=123,
|
||||||
|
message_id=456,
|
||||||
|
reaction="\u2705",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_processing_complete_failure(monkeypatch):
|
||||||
|
"""Failed processing should set cross mark reaction."""
|
||||||
|
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
|
||||||
|
adapter = _make_adapter()
|
||||||
|
event = _make_event()
|
||||||
|
|
||||||
|
await adapter.on_processing_complete(event, success=False)
|
||||||
|
|
||||||
|
adapter._bot.set_message_reaction.assert_awaited_once_with(
|
||||||
|
chat_id=123,
|
||||||
|
message_id=456,
|
||||||
|
reaction="\u274c",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_processing_complete_skipped_when_disabled(monkeypatch):
|
||||||
|
"""Processing complete should not react when reactions are disabled."""
|
||||||
|
monkeypatch.delenv("TELEGRAM_REACTIONS", raising=False)
|
||||||
|
adapter = _make_adapter()
|
||||||
|
event = _make_event()
|
||||||
|
|
||||||
|
await adapter.on_processing_complete(event, success=True)
|
||||||
|
|
||||||
|
adapter._bot.set_message_reaction.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
# ── config.py bridging ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_bridges_telegram_reactions(monkeypatch, tmp_path):
|
||||||
|
"""gateway/config.py bridges telegram.reactions to TELEGRAM_REACTIONS env var."""
|
||||||
|
import yaml
|
||||||
|
config_file = tmp_path / "config.yaml"
|
||||||
|
config_file.write_text(yaml.dump({
|
||||||
|
"telegram": {
|
||||||
|
"reactions": True,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
# Use setenv (not delenv) so monkeypatch registers cleanup even when
|
||||||
|
# the var doesn't exist yet — load_gateway_config will overwrite it.
|
||||||
|
monkeypatch.setenv("TELEGRAM_REACTIONS", "")
|
||||||
|
|
||||||
|
from gateway.config import load_gateway_config
|
||||||
|
load_gateway_config()
|
||||||
|
|
||||||
|
import os
|
||||||
|
assert os.getenv("TELEGRAM_REACTIONS") == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_reactions_env_takes_precedence(monkeypatch, tmp_path):
|
||||||
|
"""Env var should take precedence over config.yaml for reactions."""
|
||||||
|
import yaml
|
||||||
|
config_file = tmp_path / "config.yaml"
|
||||||
|
config_file.write_text(yaml.dump({
|
||||||
|
"telegram": {
|
||||||
|
"reactions": True,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
monkeypatch.setenv("TELEGRAM_REACTIONS", "false")
|
||||||
|
|
||||||
|
from gateway.config import load_gateway_config
|
||||||
|
load_gateway_config()
|
||||||
|
|
||||||
|
import os
|
||||||
|
assert os.getenv("TELEGRAM_REACTIONS") == "false"
|
||||||
@@ -164,6 +164,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
|
|||||||
| `TELEGRAM_WEBHOOK_URL` | Public HTTPS URL for webhook mode (enables webhook instead of polling) |
|
| `TELEGRAM_WEBHOOK_URL` | Public HTTPS URL for webhook mode (enables webhook instead of polling) |
|
||||||
| `TELEGRAM_WEBHOOK_PORT` | Local listen port for webhook server (default: `8443`) |
|
| `TELEGRAM_WEBHOOK_PORT` | Local listen port for webhook server (default: `8443`) |
|
||||||
| `TELEGRAM_WEBHOOK_SECRET` | Secret token for verifying updates come from Telegram |
|
| `TELEGRAM_WEBHOOK_SECRET` | Secret token for verifying updates come from Telegram |
|
||||||
|
| `TELEGRAM_REACTIONS` | Enable emoji reactions on messages during processing (default: `false`) |
|
||||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||||
| `DISCORD_ALLOWED_USERS` | Comma-separated Discord user IDs allowed to use the bot |
|
| `DISCORD_ALLOWED_USERS` | Comma-separated Discord user IDs allowed to use the bot |
|
||||||
| `DISCORD_HOME_CHANNEL` | Default Discord channel for cron delivery |
|
| `DISCORD_HOME_CHANNEL` | Default Discord channel for cron delivery |
|
||||||
@@ -171,6 +172,9 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
|
|||||||
| `DISCORD_REQUIRE_MENTION` | Require an @mention before responding in server channels |
|
| `DISCORD_REQUIRE_MENTION` | Require an @mention before responding in server channels |
|
||||||
| `DISCORD_FREE_RESPONSE_CHANNELS` | Comma-separated channel IDs where mention is not required |
|
| `DISCORD_FREE_RESPONSE_CHANNELS` | Comma-separated channel IDs where mention is not required |
|
||||||
| `DISCORD_AUTO_THREAD` | Auto-thread long replies when supported |
|
| `DISCORD_AUTO_THREAD` | Auto-thread long replies when supported |
|
||||||
|
| `DISCORD_REACTIONS` | Enable emoji reactions on messages during processing (default: `true`) |
|
||||||
|
| `DISCORD_IGNORED_CHANNELS` | Comma-separated channel IDs where the bot never responds |
|
||||||
|
| `DISCORD_NO_THREAD_CHANNELS` | Comma-separated channel IDs where bot responds without auto-threading |
|
||||||
| `SLACK_BOT_TOKEN` | Slack bot token (`xoxb-...`) |
|
| `SLACK_BOT_TOKEN` | Slack bot token (`xoxb-...`) |
|
||||||
| `SLACK_APP_TOKEN` | Slack app-level token (`xapp-...`, required for Socket Mode) |
|
| `SLACK_APP_TOKEN` | Slack app-level token (`xapp-...`, required for Socket Mode) |
|
||||||
| `SLACK_ALLOWED_USERS` | Comma-separated Slack user IDs |
|
| `SLACK_ALLOWED_USERS` | Comma-separated Slack user IDs |
|
||||||
|
|||||||
@@ -280,6 +280,8 @@ Discord behavior is controlled through two files: **`~/.hermes/.env`** for crede
|
|||||||
| `DISCORD_AUTO_THREAD` | No | `true` | When `true`, automatically creates a new thread for every `@mention` in a text channel, so each conversation is isolated (similar to Slack behavior). Messages already inside threads or DMs are unaffected. |
|
| `DISCORD_AUTO_THREAD` | No | `true` | When `true`, automatically creates a new thread for every `@mention` in a text channel, so each conversation is isolated (similar to Slack behavior). Messages already inside threads or DMs are unaffected. |
|
||||||
| `DISCORD_ALLOW_BOTS` | No | `"none"` | Controls how the bot handles messages from other Discord bots. `"none"` — ignore all other bots. `"mentions"` — only accept bot messages that `@mention` Hermes. `"all"` — accept all bot messages. |
|
| `DISCORD_ALLOW_BOTS` | No | `"none"` | Controls how the bot handles messages from other Discord bots. `"none"` — ignore all other bots. `"mentions"` — only accept bot messages that `@mention` Hermes. `"all"` — accept all bot messages. |
|
||||||
| `DISCORD_REACTIONS` | No | `true` | When `true`, the bot adds emoji reactions to messages during processing (👀 when starting, ✅ on success, ❌ on error). Set to `false` to disable reactions entirely. |
|
| `DISCORD_REACTIONS` | No | `true` | When `true`, the bot adds emoji reactions to messages during processing (👀 when starting, ✅ on success, ❌ on error). Set to `false` to disable reactions entirely. |
|
||||||
|
| `DISCORD_IGNORED_CHANNELS` | No | — | Comma-separated channel IDs where the bot **never** responds, even when `@mentioned`. Takes priority over all other channel settings. |
|
||||||
|
| `DISCORD_NO_THREAD_CHANNELS` | No | — | Comma-separated channel IDs where the bot responds directly in the channel instead of creating a thread. Only relevant when `DISCORD_AUTO_THREAD` is `true`. |
|
||||||
|
|
||||||
### Config File (`config.yaml`)
|
### Config File (`config.yaml`)
|
||||||
|
|
||||||
@@ -292,6 +294,8 @@ discord:
|
|||||||
free_response_channels: "" # Comma-separated channel IDs (or YAML list)
|
free_response_channels: "" # Comma-separated channel IDs (or YAML list)
|
||||||
auto_thread: true # Auto-create threads on @mention
|
auto_thread: true # Auto-create threads on @mention
|
||||||
reactions: true # Add emoji reactions during processing
|
reactions: true # Add emoji reactions during processing
|
||||||
|
ignored_channels: [] # Channel IDs where bot never responds
|
||||||
|
no_thread_channels: [] # Channel IDs where bot responds without threading
|
||||||
|
|
||||||
# Session isolation (applies to all gateway platforms, not just Discord)
|
# Session isolation (applies to all gateway platforms, not just Discord)
|
||||||
group_sessions_per_user: true # Isolate sessions per user in shared channels
|
group_sessions_per_user: true # Isolate sessions per user in shared channels
|
||||||
@@ -342,6 +346,40 @@ Controls whether the bot adds emoji reactions to messages as visual feedback:
|
|||||||
|
|
||||||
Disable this if you find the reactions distracting or if the bot's role doesn't have the **Add Reactions** permission.
|
Disable this if you find the reactions distracting or if the bot's role doesn't have the **Add Reactions** permission.
|
||||||
|
|
||||||
|
#### `discord.ignored_channels`
|
||||||
|
|
||||||
|
**Type:** string or list — **Default:** `[]`
|
||||||
|
|
||||||
|
Channel IDs where the bot **never** responds, even when directly `@mentioned`. This takes the highest priority — if a channel is in this list, the bot silently ignores all messages there, regardless of `require_mention`, `free_response_channels`, or any other setting.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# String format
|
||||||
|
discord:
|
||||||
|
ignored_channels: "1234567890,9876543210"
|
||||||
|
|
||||||
|
# List format
|
||||||
|
discord:
|
||||||
|
ignored_channels:
|
||||||
|
- 1234567890
|
||||||
|
- 9876543210
|
||||||
|
```
|
||||||
|
|
||||||
|
If a thread's parent channel is in this list, messages in that thread are also ignored.
|
||||||
|
|
||||||
|
#### `discord.no_thread_channels`
|
||||||
|
|
||||||
|
**Type:** string or list — **Default:** `[]`
|
||||||
|
|
||||||
|
Channel IDs where the bot responds directly in the channel instead of auto-creating a thread. This only has an effect when `auto_thread` is `true` (the default). In these channels, the bot responds inline like a normal message rather than spawning a new thread.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
discord:
|
||||||
|
no_thread_channels:
|
||||||
|
- 1234567890 # Bot responds inline here
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful for channels dedicated to bot interaction where threads would add unnecessary noise.
|
||||||
|
|
||||||
#### `group_sessions_per_user`
|
#### `group_sessions_per_user`
|
||||||
|
|
||||||
**Type:** boolean — **Default:** `true`
|
**Type:** boolean — **Default:** `true`
|
||||||
|
|||||||
@@ -463,6 +463,35 @@ platforms:
|
|||||||
You usually don't need to configure this manually. The auto-discovery via DoH handles most restricted-network scenarios. The `TELEGRAM_FALLBACK_IPS` env var is only needed if DoH is also blocked on your network.
|
You usually don't need to configure this manually. The auto-discovery via DoH handles most restricted-network scenarios. The `TELEGRAM_FALLBACK_IPS` env var is only needed if DoH is also blocked on your network.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
## Message Reactions
|
||||||
|
|
||||||
|
The bot can add emoji reactions to messages as visual processing feedback:
|
||||||
|
|
||||||
|
- 👀 when the bot starts processing your message
|
||||||
|
- ✅ when the response is delivered successfully
|
||||||
|
- ❌ if an error occurs during processing
|
||||||
|
|
||||||
|
Reactions are **disabled by default**. Enable them in `config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
telegram:
|
||||||
|
reactions: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TELEGRAM_REACTIONS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
:::note
|
||||||
|
Unlike Discord (where reactions are additive), Telegram's Bot API replaces all bot reactions in a single call. The transition from 👀 to ✅/❌ happens atomically — you won't see both at once.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
If the bot doesn't have permission to add reactions in a group, the reaction calls fail silently and message processing continues normally.
|
||||||
|
:::
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
| Problem | Solution |
|
| Problem | Solution |
|
||||||
|
|||||||
Reference in New Issue
Block a user