feat(platforms): add require_mention + allowed_users gating to DingTalk

DingTalk was the only messaging platform without group-mention gating or a
per-user allowlist. Slack, Telegram, Discord, WhatsApp, Matrix, and Mattermost
all support these via config.yaml + matching env vars; this change closes the
gap for DingTalk using the same surface:

Config:
  platforms.dingtalk.require_mention: bool   (env: DINGTALK_REQUIRE_MENTION)
  platforms.dingtalk.mention_patterns: list  (env: DINGTALK_MENTION_PATTERNS)
  platforms.dingtalk.free_response_chats: list  (env: DINGTALK_FREE_RESPONSE_CHATS)
  platforms.dingtalk.allowed_users: list     (env: DINGTALK_ALLOWED_USERS)

Semantics mirror Telegram's implementation:
- DMs are always accepted (subject to allowed_users).
- Group messages are accepted only when the chat is allowlisted, mention is
  not required, the bot was @mentioned (dingtalk_stream sets is_in_at_list),
  or the text matches a configured regex wake-word.
- allowed_users matches sender_id / sender_staff_id case-insensitively;
  a single "*" disables the check.

Rationale: without this, any DingTalk user in a group chat can trigger the
bot, which makes DingTalk less safe to deploy than the other platforms. A
user's config.yaml already accepts require_mention for dingtalk but the value
was silently ignored.
This commit is contained in:
yule975
2026-04-14 11:53:25 +08:00
committed by Teknium
parent 29d5d36b14
commit 9039273ff0
2 changed files with 160 additions and 1 deletions

View File

@@ -677,6 +677,24 @@ def load_gateway_config() -> GatewayConfig:
frc = ",".join(str(v) for v in frc)
os.environ["WHATSAPP_FREE_RESPONSE_CHATS"] = str(frc)
# DingTalk settings → env vars (env vars take precedence)
dingtalk_cfg = yaml_cfg.get("dingtalk", {})
if isinstance(dingtalk_cfg, dict):
if "require_mention" in dingtalk_cfg and not os.getenv("DINGTALK_REQUIRE_MENTION"):
os.environ["DINGTALK_REQUIRE_MENTION"] = str(dingtalk_cfg["require_mention"]).lower()
if "mention_patterns" in dingtalk_cfg and not os.getenv("DINGTALK_MENTION_PATTERNS"):
os.environ["DINGTALK_MENTION_PATTERNS"] = json.dumps(dingtalk_cfg["mention_patterns"])
frc = dingtalk_cfg.get("free_response_chats")
if frc is not None and not os.getenv("DINGTALK_FREE_RESPONSE_CHATS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["DINGTALK_FREE_RESPONSE_CHATS"] = str(frc)
allowed = dingtalk_cfg.get("allowed_users")
if allowed is not None and not os.getenv("DINGTALK_ALLOWED_USERS"):
if isinstance(allowed, list):
allowed = ",".join(str(v) for v in allowed)
os.environ["DINGTALK_ALLOWED_USERS"] = str(allowed)
# Matrix settings → env vars (env vars take precedence)
matrix_cfg = yaml_cfg.get("matrix", {})
if isinstance(matrix_cfg, dict):

View File

@@ -12,18 +12,27 @@ Configuration in config.yaml:
platforms:
dingtalk:
enabled: true
# Optional group-chat gating (mirrors Slack/Telegram/Discord):
require_mention: true # or DINGTALK_REQUIRE_MENTION env var
# free_response_chats: # conversations that skip require_mention
# - cidABC==
# mention_patterns: # regex wake-words (e.g. Chinese bot names)
# - "^小马"
# allowed_users: # staff_id or sender_id list; "*" = any
# - "manager1234"
extra:
client_id: "your-app-key" # or DINGTALK_CLIENT_ID env var
client_secret: "your-secret" # or DINGTALK_CLIENT_SECRET env var
"""
import asyncio
import json
import logging
import os
import re
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional, Set
try:
import dingtalk_stream
@@ -92,6 +101,10 @@ class DingTalkAdapter(BasePlatformAdapter):
# Map chat_id -> session_webhook for reply routing
self._session_webhooks: Dict[str, str] = {}
# Group-chat gating (mirrors Slack/Telegram/Discord/WhatsApp conventions)
self._mention_patterns: List[re.Pattern] = self._compile_mention_patterns()
self._allowed_users: Set[str] = self._load_allowed_users()
# -- Connection lifecycle -----------------------------------------------
async def connect(self) -> bool:
@@ -178,6 +191,118 @@ class DingTalkAdapter(BasePlatformAdapter):
self._dedup.clear()
logger.info("[%s] Disconnected", self.name)
# -- Group gating --------------------------------------------------------
def _dingtalk_require_mention(self) -> bool:
"""Return whether group chats should require an explicit bot trigger."""
configured = self.config.extra.get("require_mention")
if configured is not None:
if isinstance(configured, str):
return configured.lower() in ("true", "1", "yes", "on")
return bool(configured)
return os.getenv("DINGTALK_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on")
def _dingtalk_free_response_chats(self) -> Set[str]:
raw = self.config.extra.get("free_response_chats")
if raw is None:
raw = os.getenv("DINGTALK_FREE_RESPONSE_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
if patterns is None:
raw = os.getenv("DINGTALK_MENTION_PATTERNS", "").strip()
if raw:
try:
loaded = json.loads(raw)
except Exception:
loaded = [part.strip() for part in raw.splitlines() if part.strip()]
if not loaded:
loaded = [part.strip() for part in raw.split(",") if part.strip()]
patterns = loaded
if patterns is None:
return []
if isinstance(patterns, str):
patterns = [patterns]
if not isinstance(patterns, list):
logger.warning(
"[%s] dingtalk mention_patterns must be a list or string; got %s",
self.name,
type(patterns).__name__,
)
return []
compiled: List[re.Pattern] = []
for pattern in patterns:
if not isinstance(pattern, str) or not pattern.strip():
continue
try:
compiled.append(re.compile(pattern, re.IGNORECASE))
except re.error as exc:
logger.warning("[%s] Invalid DingTalk mention pattern %r: %s", self.name, pattern, exc)
if compiled:
logger.info("[%s] Loaded %d DingTalk mention pattern(s)", self.name, len(compiled))
return compiled
def _load_allowed_users(self) -> Set[str]:
"""Load allowed-users list from config.extra or env var.
IDs are matched case-insensitively against the sender's ``staff_id`` and
``sender_id``. A wildcard ``*`` disables the check.
"""
raw = self.config.extra.get("allowed_users") if self.config.extra else None
if raw is None:
raw = os.getenv("DINGTALK_ALLOWED_USERS", "")
if isinstance(raw, list):
items = [str(part).strip() for part in raw if str(part).strip()]
else:
items = [part.strip() for part in str(raw).split(",") if part.strip()]
return {item.lower() for item in items}
def _is_user_allowed(self, sender_id: str, sender_staff_id: str) -> bool:
if not self._allowed_users or "*" in self._allowed_users:
return True
candidates = {(sender_id or "").lower(), (sender_staff_id or "").lower()}
candidates.discard("")
return bool(candidates & self._allowed_users)
def _message_mentions_bot(self, message: "ChatbotMessage") -> bool:
"""True if the bot was @-mentioned in a group message.
dingtalk-stream sets ``is_in_at_list`` on the incoming ChatbotMessage
when the bot is addressed via @-mention.
"""
return bool(getattr(message, "is_in_at_list", False))
def _message_matches_mention_patterns(self, text: str) -> bool:
if not text or not self._mention_patterns:
return False
return any(pattern.search(text) for pattern in self._mention_patterns)
def _should_process_message(self, message: "ChatbotMessage", text: str, is_group: bool, chat_id: str) -> bool:
"""Apply DingTalk group trigger rules.
DMs remain unrestricted (subject to ``allowed_users`` which is enforced
earlier). Group messages are accepted when:
- 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
"""
if not is_group:
return True
if chat_id and chat_id in self._dingtalk_free_response_chats():
return True
if not self._dingtalk_require_mention():
return True
if self._message_mentions_bot(message):
return True
return self._message_matches_mention_patterns(text)
# -- Inbound message processing -----------------------------------------
async def _on_message(self, message: "ChatbotMessage") -> None:
@@ -203,6 +328,22 @@ class DingTalkAdapter(BasePlatformAdapter):
chat_id = conversation_id or sender_id
chat_type = "group" if is_group else "dm"
# Allowed-users gate (applies to both DM and group)
if not self._is_user_allowed(sender_id, sender_staff_id):
logger.debug(
"[%s] Dropping message from non-allowlisted user staff_id=%s sender_id=%s",
self.name, sender_staff_id, sender_id,
)
return
# Group mention/pattern gate
if not self._should_process_message(message, text, is_group, chat_id):
logger.debug(
"[%s] Dropping group message that failed mention gate message_id=%s chat_id=%s",
self.name, msg_id, chat_id,
)
return
# Store session webhook for reply routing (validate origin to prevent SSRF)
session_webhook = getattr(message, "session_webhook", None) or ""
if session_webhook and chat_id and _DINGTALK_WEBHOOK_RE.match(session_webhook):