feat(gateway): add allowed_{chats,channels,rooms} whitelist to Telegram, Mattermost, Matrix, DingTalk

Mirrors the Slack `allowed_channels` feature (PR #7401) and Discord's
`allowed_channels` (PR #7044) across the remaining group-capable platforms.
All five platforms (Slack + Discord + the four added here) now follow the
same pattern: primary config via config.yaml, env-var fallback as an escape
hatch — matching the project policy that .env is for secrets only and
behavioral settings belong in config.yaml.

Also fixes a duplicate `slack` key in DEFAULT_CONFIG introduced by PR
#7401 (the later entry silently overwrote `allowed_channels`, `require_mention`,
and `free_response_channels` at dict-literal evaluation time).

Platforms added:
- Telegram: `telegram.allowed_chats` (env alias: `TELEGRAM_ALLOWED_CHATS`)
- Mattermost: `mattermost.allowed_channels` (env alias: `MATTERMOST_ALLOWED_CHANNELS`)
- Matrix: `matrix.allowed_rooms` (env alias: `MATRIX_ALLOWED_ROOMS`)
- DingTalk: `dingtalk.allowed_chats` (env alias: `DINGTALK_ALLOWED_CHATS`)

Mattermost and Matrix previously had NO config.yaml bridging for any of
their gating settings; this PR adds `load_gateway_config` bridges for them
(Mattermost gets require_mention + free_response_channels + allowed_channels;
Matrix gets allowed_rooms on top of its existing bridges for require_mention
and free_response_rooms).

Semantics identical everywhere:
- Empty = no restriction (fully backward compatible).
- Non-empty = hard whitelist: non-listed chats are silently ignored,
  even when the bot is @mentioned.
- DMs bypass the check entirely.

DEFAULT_CONFIG merges the duplicate `slack` block and adds new `mattermost`
and `matrix` blocks so all gating settings surface in defaults.

Not included: Feishu (has its own per-chat `chat_rules` system that covers
this use case differently), WhatsApp (already has `group_allow_from` via
`group_policy: allowlist`), pure-DM platforms (Signal, SMS, BlueBubbles,
Yuanbao — no group concept).
This commit is contained in:
Teknium 2026-05-07 05:58:56 -07:00
parent f5c9bb582c
commit 69d025e4a7
7 changed files with 518 additions and 9 deletions

View file

@ -365,6 +365,20 @@ class DingTalkAdapter(BasePlatformAdapter):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
def _dingtalk_allowed_chats(self) -> Set[str]:
"""Return the whitelist of group chat IDs the bot will respond in.
When non-empty, group messages from chats NOT in this set are silently
ignored even if the bot is @mentioned. DMs are never filtered.
Empty set means no restriction (fully backward compatible).
"""
raw = self.config.extra.get("allowed_chats") if self.config.extra else None
if raw is None:
raw = os.getenv("DINGTALK_ALLOWED_CHATS", "")
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
def _compile_mention_patterns(self) -> List[re.Pattern]:
"""Compile optional regex wake-word patterns for group triggers."""
patterns = self.config.extra.get("mention_patterns") if self.config.extra else None
@ -443,13 +457,21 @@ class DingTalkAdapter(BasePlatformAdapter):
DMs remain unrestricted (subject to ``allowed_users`` which is enforced
earlier). Group messages are accepted when:
- the chat passes the ``allowed_chats`` whitelist (when set)
- the chat is explicitly allowlisted in ``free_response_chats``
- ``require_mention`` is disabled
- the bot is @mentioned (``is_in_at_list``)
- the text matches a configured regex wake-word pattern
When ``allowed_chats`` is non-empty, it acts as a hard gate messages
from any group chat not in the list are ignored regardless of the
other rules.
"""
if not is_group:
return True
allowed = self._dingtalk_allowed_chats()
if allowed and chat_id and chat_id not in allowed:
return False
if chat_id and chat_id in self._dingtalk_free_response_chats():
return True
if not self._dingtalk_require_mention():

View file

@ -17,7 +17,8 @@ Environment variables:
MATRIX_REACTIONS Set "false" to disable processing lifecycle reactions
(eyes/checkmark/cross). Default: true
MATRIX_REQUIRE_MENTION Require @mention in rooms (default: true)
MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement
MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement (alias of matrix.free_response_rooms)
MATRIX_ALLOWED_ROOMS Comma-separated room IDs; if set, bot ONLY responds in these rooms (whitelist, DMs exempt; alias of matrix.allowed_rooms)
MATRIX_AUTO_THREAD Auto-create threads for room messages (default: true)
MATRIX_DM_AUTO_THREAD Auto-create threads for DM messages (default: false)
MATRIX_RECOVERY_KEY Recovery key for cross-signing verification after device key rotation
@ -343,10 +344,29 @@ class MatrixAdapter(BasePlatformAdapter):
self._require_mention: bool = os.getenv(
"MATRIX_REQUIRE_MENTION", "true"
).lower() not in ("false", "0", "no")
free_rooms_raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "")
self._free_rooms: Set[str] = {
r.strip() for r in free_rooms_raw.split(",") if r.strip()
}
free_rooms_raw = config.extra.get("free_response_rooms")
if free_rooms_raw is None:
free_rooms_raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "")
if isinstance(free_rooms_raw, list):
self._free_rooms: Set[str] = {
str(r).strip() for r in free_rooms_raw if str(r).strip()
}
else:
self._free_rooms: Set[str] = {
r.strip() for r in str(free_rooms_raw).split(",") if r.strip()
}
# If non-empty, bot ONLY responds in these rooms (whitelist); DMs exempt.
allowed_rooms_raw = config.extra.get("allowed_rooms")
if allowed_rooms_raw is None:
allowed_rooms_raw = os.getenv("MATRIX_ALLOWED_ROOMS", "")
if isinstance(allowed_rooms_raw, list):
self._allowed_rooms: Set[str] = {
str(r).strip() for r in allowed_rooms_raw if str(r).strip()
}
else:
self._allowed_rooms: Set[str] = {
r.strip() for r in str(allowed_rooms_raw).split(",") if r.strip()
}
self._auto_thread: bool = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in (
"true",
"1",
@ -1573,6 +1593,18 @@ class MatrixAdapter(BasePlatformAdapter):
# Require-mention gating.
if not is_dm:
# allowed_rooms check (whitelist — must pass before other gating).
# When set, messages from rooms NOT in this whitelist are silently
# ignored, even if @mentioned. DMs are already excluded above.
if self._allowed_rooms and room_id not in self._allowed_rooms:
logger.debug(
"Matrix: ignoring message %s in %s — room not in "
"MATRIX_ALLOWED_ROOMS whitelist",
event_id,
room_id,
)
return None
is_free_room = room_id in self._free_rooms
in_bot_thread = bool(thread_id and thread_id in self._threads)
if self._require_mention and not is_free_room and not in_bot_thread:

View file

@ -706,10 +706,30 @@ class MattermostAdapter(BasePlatformAdapter):
message_text = post.get("message", "")
# Mention-gating for non-DM channels.
# Config (env vars):
# MATTERMOST_REQUIRE_MENTION: Require @mention in channels (default: true)
# MATTERMOST_FREE_RESPONSE_CHANNELS: Channel IDs where bot responds without mention
# Config (config.yaml `mattermost.*` with env-var fallback):
# require_mention / MATTERMOST_REQUIRE_MENTION: Require @mention in channels (default: true)
# free_response_channels / MATTERMOST_FREE_RESPONSE_CHANNELS: Channel IDs where bot responds without mention
# allowed_channels / MATTERMOST_ALLOWED_CHANNELS: If set, bot ONLY responds in these channels (whitelist)
if channel_type_raw != "D":
# allowed_channels check (whitelist — must pass before other gating).
# When set, messages from channels NOT in this list are silently
# ignored, even if @mentioned. DMs are already excluded above.
allowed_raw = self.config.extra.get("allowed_channels") if self.config.extra else None
if allowed_raw is None:
allowed_raw = os.getenv("MATTERMOST_ALLOWED_CHANNELS", "")
if isinstance(allowed_raw, list):
allowed_channels = {str(c).strip() for c in allowed_raw if str(c).strip()}
else:
allowed_channels = {
c.strip() for c in str(allowed_raw).split(",") if c.strip()
}
if allowed_channels and channel_id not in allowed_channels:
logger.debug(
"Mattermost: ignoring message in non-allowed channel: %s",
channel_id,
)
return
require_mention = os.getenv(
"MATTERMOST_REQUIRE_MENTION", "true"
).lower() not in ("false", "0", "no")

View file

@ -2771,6 +2771,20 @@ class TelegramAdapter(BasePlatformAdapter):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
def _telegram_allowed_chats(self) -> set[str]:
"""Return the whitelist of group/supergroup chat IDs the bot will respond in.
When non-empty, group messages from chats NOT in this set are silently
ignored even if the bot is @mentioned. DMs are never filtered.
Empty set means no restriction (fully backward compatible).
"""
raw = self.config.extra.get("allowed_chats")
if raw is None:
raw = os.getenv("TELEGRAM_ALLOWED_CHATS", "")
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
def _telegram_ignored_threads(self) -> set[int]:
raw = self.config.extra.get("ignored_threads")
if raw is None:
@ -2919,13 +2933,16 @@ class TelegramAdapter(BasePlatformAdapter):
"""Apply Telegram group trigger rules.
DMs remain unrestricted. Group/supergroup messages are accepted when:
- the chat passes the ``allowed_chats`` whitelist (when set)
- the chat is explicitly allowlisted in ``free_response_chats``
- ``require_mention`` is disabled
- the message replies to the bot
- the bot is @mentioned
- the text/caption matches a configured regex wake-word pattern
When ``require_mention`` is enabled, slash commands are not given
When ``allowed_chats`` is non-empty, it acts as a hard gate messages
from any chat not in the list are ignored regardless of the other
rules. When ``require_mention`` is enabled, slash commands are not given
special treatment they must pass the same mention/reply checks
as any other group message. Users can still trigger commands via
the Telegram bot menu (``/command@botname``) or by explicitly
@ -2934,6 +2951,14 @@ class TelegramAdapter(BasePlatformAdapter):
"""
if not self._is_group_chat(message):
return True
# allowed_chats check (whitelist — must pass before other gating).
# When set, group messages from chats NOT in this whitelist are
# silently ignored, even if @mentioned. DMs are already excluded above.
allowed = self._telegram_allowed_chats()
if allowed:
chat_id_str = str(getattr(getattr(message, "chat", None), "id", ""))
if chat_id_str not in allowed:
return False
thread_id = getattr(message, "message_thread_id", None)
if thread_id is not None:
try: