diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 85cebe538..930760aed 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -35,7 +35,7 @@ from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from types import SimpleNamespace -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Sequence from urllib.error import HTTPError, URLError from urllib.parse import urlencode from urllib.request import Request, urlopen @@ -73,7 +73,9 @@ try: UpdateMessageRequest, UpdateMessageRequestBody, ) + from lark_oapi.core import AccessTokenType, HttpMethod from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN + from lark_oapi.core.model import BaseRequest from lark_oapi.event.callback.model.p2_card_action_trigger import ( CallBackCard, P2CardActionTriggerResponse, @@ -234,6 +236,8 @@ FALLBACK_ATTACHMENT_TEXT = "[Attachment]" _PREFERRED_LOCALES = ("zh_cn", "en_us") _MARKDOWN_SPECIAL_CHARS_RE = re.compile(r"([\\`*_{}\[\]()#+\-!|>~])") _MENTION_PLACEHOLDER_RE = re.compile(r"@_user_\d+") +_MENTION_BOUNDARY_CHARS = frozenset(" \t\n\r.,;:!?、,。;:!?()[]{}<>\"'`") +_TRAILING_TERMINAL_PUNCT = frozenset(" \t\n\r.!?。!?") _WHITESPACE_RE = re.compile(r"\s+") _SUPPORTED_CARD_TEXT_KEYS = ( "title", @@ -277,12 +281,36 @@ class FeishuPostMediaRef: resource_type: str = "file" +@dataclass(frozen=True) +class FeishuMentionRef: + name: str = "" + open_id: str = "" + is_all: bool = False + is_self: bool = False + + +@dataclass(frozen=True) +class _FeishuBotIdentity: + open_id: str = "" + user_id: str = "" + name: str = "" + + def matches(self, *, open_id: str, user_id: str, name: str) -> bool: + # Precedence: open_id > user_id > name. IDs are authoritative when both + # sides have them; the next tier is only considered when either side + # lacks the current one. + if open_id and self.open_id: + return open_id == self.open_id + if user_id and self.user_id: + return user_id == self.user_id + return bool(self.name) and name == self.name + + @dataclass(frozen=True) class FeishuPostParseResult: text_content: str image_keys: List[str] = field(default_factory=list) media_refs: List[FeishuPostMediaRef] = field(default_factory=list) - mentioned_ids: List[str] = field(default_factory=list) @dataclass(frozen=True) @@ -292,7 +320,7 @@ class FeishuNormalizedMessage: preferred_message_type: str = "text" image_keys: List[str] = field(default_factory=list) media_refs: List[FeishuPostMediaRef] = field(default_factory=list) - mentioned_ids: List[str] = field(default_factory=list) + mentions: List[FeishuMentionRef] = field(default_factory=list) relation_kind: str = "plain" metadata: Dict[str, Any] = field(default_factory=dict) @@ -505,14 +533,17 @@ def _build_markdown_post_rows(content: str) -> List[List[Dict[str, str]]]: return rows or [[{"tag": "md", "text": content}]] -def parse_feishu_post_payload(payload: Any) -> FeishuPostParseResult: +def parse_feishu_post_payload( + payload: Any, + *, + mentions_map: Optional[Dict[str, FeishuMentionRef]] = None, +) -> FeishuPostParseResult: resolved = _resolve_post_payload(payload) if not resolved: return FeishuPostParseResult(text_content=FALLBACK_POST_TEXT) image_keys: List[str] = [] media_refs: List[FeishuPostMediaRef] = [] - mentioned_ids: List[str] = [] parts: List[str] = [] title = _normalize_feishu_text(str(resolved.get("title", "")).strip()) @@ -523,7 +554,10 @@ def parse_feishu_post_payload(payload: Any) -> FeishuPostParseResult: if not isinstance(row, list): continue row_text = _normalize_feishu_text( - "".join(_render_post_element(item, image_keys, media_refs, mentioned_ids) for item in row) + "".join( + _render_post_element(item, image_keys, media_refs, mentions_map) + for item in row + ) ) if row_text: parts.append(row_text) @@ -532,7 +566,6 @@ def parse_feishu_post_payload(payload: Any) -> FeishuPostParseResult: text_content="\n".join(parts).strip() or FALLBACK_POST_TEXT, image_keys=image_keys, media_refs=media_refs, - mentioned_ids=mentioned_ids, ) @@ -584,7 +617,7 @@ def _render_post_element( element: Any, image_keys: List[str], media_refs: List[FeishuPostMediaRef], - mentioned_ids: List[str], + mentions_map: Optional[Dict[str, FeishuMentionRef]] = None, ) -> str: if isinstance(element, str): return element @@ -602,19 +635,21 @@ def _render_post_element( escaped_label = _escape_markdown_text(label) return f"[{escaped_label}]({href})" if href else escaped_label if tag == "at": - mentioned_id = ( - str(element.get("open_id", "")).strip() - or str(element.get("user_id", "")).strip() - ) - if mentioned_id and mentioned_id not in mentioned_ids: - mentioned_ids.append(mentioned_id) - display_name = ( - str(element.get("user_name", "")).strip() - or str(element.get("name", "")).strip() - or str(element.get("text", "")).strip() - or mentioned_id - ) - return f"@{_escape_markdown_text(display_name)}" if display_name else "@" + # Post .user_id is a placeholder ("@_user_N" or "@_all"); look up + # the real ref in mentions_map for the display name. + placeholder = str(element.get("user_id", "")).strip() + if placeholder == "@_all": + # Feishu SDK sometimes omits @_all from the top-level mentions + # payload; record it here so the caller's mention list stays complete. + if mentions_map is not None and "@_all" not in mentions_map: + mentions_map["@_all"] = FeishuMentionRef(is_all=True) + return "@all" + ref = (mentions_map or {}).get(placeholder) + if ref is not None: + display_name = ref.name or ref.open_id or "user" + else: + display_name = str(element.get("user_name", "")).strip() or "user" + return f"@{_escape_markdown_text(display_name)}" if tag in {"img", "image"}: image_key = str(element.get("image_key", "")).strip() if image_key and image_key not in image_keys: @@ -652,8 +687,7 @@ def _render_post_element( nested_parts: List[str] = [] for key in ("text", "title", "content", "children", "elements"): - value = element.get(key) - extracted = _render_nested_post(value, image_keys, media_refs, mentioned_ids) + extracted = _render_nested_post(element.get(key), image_keys, media_refs, mentions_map) if extracted: nested_parts.append(extracted) return " ".join(part for part in nested_parts if part) @@ -663,7 +697,7 @@ def _render_nested_post( value: Any, image_keys: List[str], media_refs: List[FeishuPostMediaRef], - mentioned_ids: List[str], + mentions_map: Optional[Dict[str, FeishuMentionRef]] = None, ) -> str: if isinstance(value, str): return _escape_markdown_text(value) @@ -671,17 +705,17 @@ def _render_nested_post( return " ".join( part for item in value - for part in [_render_nested_post(item, image_keys, media_refs, mentioned_ids)] + for part in [_render_nested_post(item, image_keys, media_refs, mentions_map)] if part ) if isinstance(value, dict): - direct = _render_post_element(value, image_keys, media_refs, mentioned_ids) + direct = _render_post_element(value, image_keys, media_refs, mentions_map) if direct: return direct return " ".join( part for item in value.values() - for part in [_render_nested_post(item, image_keys, media_refs, mentioned_ids)] + for part in [_render_nested_post(item, image_keys, media_refs, mentions_map)] if part ) return "" @@ -692,31 +726,48 @@ def _render_nested_post( # --------------------------------------------------------------------------- -def normalize_feishu_message(*, message_type: str, raw_content: str) -> FeishuNormalizedMessage: +def normalize_feishu_message( + *, + message_type: str, + raw_content: str, + mentions: Optional[Sequence[Any]] = None, + bot: _FeishuBotIdentity = _FeishuBotIdentity(), +) -> FeishuNormalizedMessage: normalized_type = str(message_type or "").strip().lower() payload = _load_feishu_payload(raw_content) + mentions_map = _build_mentions_map(mentions, bot) if normalized_type == "text": + text = str(payload.get("text", "") or "") + # Feishu SDK sometimes omits @_all from the mentions payload even when + # the text literal contains it (confirmed via im.v1.message.get). + if "@_all" in text and "@_all" not in mentions_map: + mentions_map["@_all"] = FeishuMentionRef(is_all=True) return FeishuNormalizedMessage( raw_type=normalized_type, - text_content=_normalize_feishu_text(str(payload.get("text", "") or "")), + text_content=_normalize_feishu_text(text, mentions_map), + mentions=list(mentions_map.values()), ) if normalized_type == "post": - parsed_post = parse_feishu_post_payload(payload) + # The walker writes back to mentions_map if it encounters + # , so reading .values() after parsing is enough. + parsed_post = parse_feishu_post_payload(payload, mentions_map=mentions_map) return FeishuNormalizedMessage( raw_type=normalized_type, text_content=parsed_post.text_content, image_keys=list(parsed_post.image_keys), media_refs=list(parsed_post.media_refs), - mentioned_ids=list(parsed_post.mentioned_ids), + mentions=list(mentions_map.values()), relation_kind="post", ) + mention_refs = list(mentions_map.values()) if normalized_type == "image": image_key = str(payload.get("image_key", "") or "").strip() alt_text = _normalize_feishu_text( str(payload.get("text", "") or "") or str(payload.get("alt", "") or "") - or FALLBACK_IMAGE_TEXT + or FALLBACK_IMAGE_TEXT, + mentions_map, ) return FeishuNormalizedMessage( raw_type=normalized_type, @@ -724,6 +775,7 @@ def normalize_feishu_message(*, message_type: str, raw_content: str) -> FeishuNo preferred_message_type="photo", image_keys=[image_key] if image_key else [], relation_kind="image", + mentions=mention_refs, ) if normalized_type in {"file", "audio", "media"}: media_ref = _build_media_ref_from_payload(payload, resource_type=normalized_type) @@ -735,6 +787,7 @@ def normalize_feishu_message(*, message_type: str, raw_content: str) -> FeishuNo media_refs=[media_ref] if media_ref.file_key else [], relation_kind=normalized_type, metadata={"placeholder_text": placeholder}, + mentions=mention_refs, ) if normalized_type == "merge_forward": return _normalize_merge_forward_message(payload) @@ -1009,8 +1062,20 @@ def _first_non_empty_text(*values: Any) -> str: # --------------------------------------------------------------------------- -def _normalize_feishu_text(text: str) -> str: - cleaned = _MENTION_PLACEHOLDER_RE.sub(" ", text or "") +def _normalize_feishu_text( + text: str, + mentions_map: Optional[Dict[str, FeishuMentionRef]] = None, +) -> str: + def _sub(match: "re.Match[str]") -> str: + key = match.group(0) + ref = (mentions_map or {}).get(key) + if ref is None: + return " " + name = ref.name or ref.open_id or "user" + return f"@{name}" + + cleaned = _MENTION_PLACEHOLDER_RE.sub(_sub, text or "") + cleaned = cleaned.replace("@_all", "@all") cleaned = cleaned.replace("\r\n", "\n").replace("\r", "\n") cleaned = "\n".join(_WHITESPACE_RE.sub(" ", line).strip() for line in cleaned.split("\n")) cleaned = "\n".join(line for line in cleaned.split("\n") if line) @@ -1029,6 +1094,117 @@ def _unique_lines(lines: List[str]) -> List[str]: return unique +# --------------------------------------------------------------------------- +# Mention helpers +# --------------------------------------------------------------------------- + + +def _extract_mention_ids(mention: Any) -> tuple[str, str]: + # Returns (open_id, user_id). im.v1.message.get hands back id as a string + # plus id_type discriminator; event payloads hand back a nested UserId + # object carrying both fields. + mention_id = getattr(mention, "id", None) + if isinstance(mention_id, str): + id_type = str(getattr(mention, "id_type", "") or "").lower() + if id_type == "open_id": + return mention_id, "" + if id_type == "user_id": + return "", mention_id + return "", "" + if mention_id is None: + return "", "" + return ( + str(getattr(mention_id, "open_id", "") or ""), + str(getattr(mention_id, "user_id", "") or ""), + ) + + +def _build_mentions_map( + mentions: Optional[Sequence[Any]], + bot: _FeishuBotIdentity, +) -> Dict[str, FeishuMentionRef]: + result: Dict[str, FeishuMentionRef] = {} + for mention in mentions or []: + key = str(getattr(mention, "key", "") or "") + if not key: + continue + if key == "@_all": + result[key] = FeishuMentionRef(is_all=True) + continue + open_id, user_id = _extract_mention_ids(mention) + name = str(getattr(mention, "name", "") or "").strip() + result[key] = FeishuMentionRef( + name=name, + open_id=open_id, + is_self=bot.matches(open_id=open_id, user_id=user_id, name=name), + ) + return result + + +def _build_mention_hint(mentions: Sequence[FeishuMentionRef]) -> str: + parts: List[str] = [] + seen: set = set() + for ref in mentions: + if ref.is_self: + continue + signature = (ref.is_all, ref.open_id, ref.name) + if signature in seen: + continue + seen.add(signature) + if ref.is_all: + parts.append("@all") + elif ref.open_id: + parts.append(f"{ref.name or 'unknown'} (open_id={ref.open_id})") + else: + parts.append(ref.name or "unknown") + return f"[Mentioned: {', '.join(parts)}]" if parts else "" + + +def _strip_edge_self_mentions( + text: str, + mentions: Sequence[FeishuMentionRef], +) -> str: + # Leading: strip consecutive self-mentions unconditionally. + # Trailing: strip only when followed by whitespace/terminal punct, so + # mid-sentence references ("don't @Bot again") stay intact. + # Leading word-boundary prevents @Al from eating @Alice. + if not text: + return text + self_names = [ + f"@{ref.name or ref.open_id or 'user'}" + for ref in mentions + if ref.is_self + ] + if not self_names: + return text + + remaining = text.lstrip() + while True: + for nm in self_names: + if not remaining.startswith(nm): + continue + after = remaining[len(nm):] + if after and after[0] not in _MENTION_BOUNDARY_CHARS: + continue + remaining = after.lstrip() + break + else: + break + + while True: + i = len(remaining) + while i > 0 and remaining[i - 1] in _TRAILING_TERMINAL_PUNCT: + i -= 1 + body = remaining[:i] + tail = remaining[i:] + for nm in self_names: + if body.endswith(nm): + remaining = body[: -len(nm)].rstrip() + tail + break + else: + return remaining + + def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None: """Run the official Lark WS client in its own thread-local event loop.""" import lark_oapi.ws.client as ws_client_module @@ -2470,13 +2646,22 @@ class FeishuAdapter(BasePlatformAdapter): chat_type: str, message_id: str, ) -> None: - text, inbound_type, media_urls, media_types = await self._extract_message_content(message) + text, inbound_type, media_urls, media_types, mentions = await self._extract_message_content(message) + + if inbound_type == MessageType.TEXT: + text = _strip_edge_self_mentions(text, mentions) + if text.startswith("/"): + inbound_type = MessageType.COMMAND + + # Guard runs post-strip so a pure "@Bot" message (stripped to "") is dropped. if inbound_type == MessageType.TEXT and not text and not media_urls: - logger.debug("[Feishu] Ignoring unsupported or empty message type: %s", getattr(message, "message_type", "")) + logger.debug("[Feishu] Ignoring empty text message id=%s", message_id) return - if inbound_type == MessageType.TEXT and text.startswith("/"): - inbound_type = MessageType.COMMAND + if inbound_type != MessageType.COMMAND: + hint = _build_mention_hint(mentions) + if hint: + text = f"{hint}\n\n{text}" if text else hint reply_to_message_id = ( getattr(message, "parent_id", None) @@ -2935,14 +3120,20 @@ class FeishuAdapter(BasePlatformAdapter): # Message content extraction and resource download # ========================================================================= - async def _extract_message_content(self, message: Any) -> tuple[str, MessageType, List[str], List[str]]: - """Extract text and cached media from a normalized Feishu message.""" + async def _extract_message_content( + self, message: Any + ) -> tuple[str, MessageType, List[str], List[str], List[FeishuMentionRef]]: raw_content = getattr(message, "content", "") or "" raw_type = getattr(message, "message_type", "") or "" message_id = str(getattr(message, "message_id", "") or "") logger.info("[Feishu] Received raw message type=%s message_id=%s", raw_type, message_id) - normalized = normalize_feishu_message(message_type=raw_type, raw_content=raw_content) + normalized = normalize_feishu_message( + message_type=raw_type, + raw_content=raw_content, + mentions=getattr(message, "mentions", None), + bot=self._bot_identity(), + ) media_urls, media_types = await self._download_feishu_message_resources( message_id=message_id, normalized=normalized, @@ -2959,7 +3150,7 @@ class FeishuAdapter(BasePlatformAdapter): if injected: text = injected - return text, inbound_type, media_urls, media_types + return text, inbound_type, media_urls, media_types, list(normalized.mentions) async def _download_feishu_message_resources( self, @@ -3308,15 +3499,31 @@ class FeishuAdapter(BasePlatformAdapter): body = getattr(parent, "body", None) msg_type = getattr(parent, "msg_type", "") or "" raw_content = getattr(body, "content", "") or "" - text = self._extract_text_from_raw_content(msg_type=msg_type, raw_content=raw_content) + parent_mentions = getattr(parent, "mentions", None) if parent else None + text = self._extract_text_from_raw_content( + msg_type=msg_type, + raw_content=raw_content, + mentions=parent_mentions, + ) self._message_text_cache[message_id] = text return text except Exception: logger.warning("[Feishu] Failed to fetch parent message %s", message_id, exc_info=True) return None - def _extract_text_from_raw_content(self, *, msg_type: str, raw_content: str) -> Optional[str]: - normalized = normalize_feishu_message(message_type=msg_type, raw_content=raw_content) + def _extract_text_from_raw_content( + self, + *, + msg_type: str, + raw_content: str, + mentions: Optional[Sequence[Any]] = None, + ) -> Optional[str]: + normalized = normalize_feishu_message( + message_type=msg_type, + raw_content=raw_content, + mentions=mentions, + bot=self._bot_identity(), + ) if normalized.text_content: return normalized.text_content placeholder = normalized.metadata.get("placeholder_text") if isinstance(normalized.metadata, dict) else None @@ -3386,10 +3593,10 @@ class FeishuAdapter(BasePlatformAdapter): normalized = normalize_feishu_message( message_type=getattr(message, "message_type", "") or "", raw_content=raw_content, + mentions=getattr(message, "mentions", None), + bot=self._bot_identity(), ) - if normalized.mentioned_ids: - return self._post_mentions_bot(normalized.mentioned_ids) - return False + return self._post_mentions_bot(normalized.mentions) def _is_self_sent_bot_message(self, event: Any) -> bool: """Return True only for Feishu events emitted by this Hermes bot.""" @@ -3409,30 +3616,37 @@ class FeishuAdapter(BasePlatformAdapter): return False def _message_mentions_bot(self, mentions: List[Any]) -> bool: - """Check whether any mention targets the configured or inferred bot identity.""" + # IDs trump names: when both sides have open_id (or both user_id), + # match requires equal IDs. Name fallback only when either side + # lacks an ID. for mention in mentions: mention_id = getattr(mention, "id", None) - mention_open_id = getattr(mention_id, "open_id", None) - mention_user_id = getattr(mention_id, "user_id", None) + mention_open_id = (getattr(mention_id, "open_id", None) or "").strip() + mention_user_id = (getattr(mention_id, "user_id", None) or "").strip() mention_name = (getattr(mention, "name", None) or "").strip() - if self._bot_open_id and mention_open_id == self._bot_open_id: - return True - if self._bot_user_id and mention_user_id == self._bot_user_id: - return True + if mention_open_id and self._bot_open_id: + if mention_open_id == self._bot_open_id: + return True + continue # IDs differ — not the bot; skip name fallback. + if mention_user_id and self._bot_user_id: + if mention_user_id == self._bot_user_id: + return True + continue if self._bot_name and mention_name == self._bot_name: return True return False - def _post_mentions_bot(self, mentioned_ids: List[str]) -> bool: - if not mentioned_ids: - return False - if self._bot_open_id and self._bot_open_id in mentioned_ids: - return True - if self._bot_user_id and self._bot_user_id in mentioned_ids: - return True - return False + def _post_mentions_bot(self, mentions: List[FeishuMentionRef]) -> bool: + return any(m.is_self for m in mentions) + + def _bot_identity(self) -> _FeishuBotIdentity: + return _FeishuBotIdentity( + open_id=self._bot_open_id, + user_id=self._bot_user_id, + name=self._bot_name, + ) async def _hydrate_bot_identity(self) -> None: """Best-effort discovery of bot identity for precise group mention gating @@ -3457,14 +3671,15 @@ class FeishuAdapter(BasePlatformAdapter): # uses via probe_bot(). if not self._bot_open_id or not self._bot_name: try: - resp = await asyncio.to_thread( - self._client.request, - method="GET", - url="/open-apis/bot/v3/info", - body=None, - raw_response=True, + req = ( + BaseRequest.builder() + .http_method(HttpMethod.GET) + .uri("/open-apis/bot/v3/info") + .token_types({AccessTokenType.TENANT}) + .build() ) - content = getattr(resp, "content", None) + resp = await asyncio.to_thread(self._client.request, req) + content = getattr(getattr(resp, "raw", None), "content", None) if content: payload = json.loads(content) parsed = _parse_bot_response(payload) or {} @@ -4232,12 +4447,12 @@ def _build_onboard_client(app_id: str, app_secret: str, domain: str) -> Any: def _parse_bot_response(data: dict) -> Optional[dict]: - """Extract bot_name and bot_open_id from a /bot/v3/info response.""" + # /bot/v3/info returns bot.app_name; legacy paths used bot_name — accept both. if data.get("code") != 0: return None bot = data.get("bot") or data.get("data", {}).get("bot") or {} return { - "bot_name": bot.get("bot_name"), + "bot_name": bot.get("app_name") or bot.get("bot_name"), "bot_open_id": bot.get("open_id"), } @@ -4246,13 +4461,18 @@ def _probe_bot_sdk(app_id: str, app_secret: str, domain: str) -> Optional[dict]: """Probe bot info using lark_oapi SDK.""" try: client = _build_onboard_client(app_id, app_secret, domain) - resp = client.request( - method="GET", - url="/open-apis/bot/v3/info", - body=None, - raw_response=True, + req = ( + BaseRequest.builder() + .http_method(HttpMethod.GET) + .uri("/open-apis/bot/v3/info") + .token_types({AccessTokenType.TENANT}) + .build() ) - return _parse_bot_response(json.loads(resp.content)) + resp = client.request(req) + content = getattr(getattr(resp, "raw", None), "content", None) + if content is None: + return None + return _parse_bot_response(json.loads(content)) except Exception as exc: logger.debug("[Feishu onboard] SDK probe failed: %s", exc) return None diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 1813eb31f..75aecd586 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -781,11 +781,12 @@ class TestAdapterBehavior(unittest.TestCase): from gateway.platforms.feishu import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) + # Mention without IDs — name fallback legitimately engages. mentioned = SimpleNamespace( mentions=[ SimpleNamespace( name="Hermes Bot", - id=SimpleNamespace(open_id="ou_other", user_id="u_other"), + id=SimpleNamespace(open_id=None, user_id=None), ) ] ) @@ -1026,40 +1027,47 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) def test_group_message_matches_bot_name_when_only_name_available(self): + """Name fallback engages when either side lacks an open_id. When BOTH + the mention and the bot carry open_ids, IDs are authoritative — a + same-name human with a different open_id must NOT admit.""" from gateway.config import PlatformConfig from gateway.platforms.feishu import FeishuAdapter + # Case 1: bot has only a name (open_id not hydrated / not configured). + # Name fallback is the only available signal for any mention. adapter = FeishuAdapter(PlatformConfig()) adapter._bot_name = "Hermes Bot" sender_id = SimpleNamespace(open_id="ou_any", user_id=None) - named_mention = SimpleNamespace( + name_only_mention = SimpleNamespace( name="Hermes Bot", - id=SimpleNamespace(open_id="ou_other", user_id="u_other"), + id=SimpleNamespace(open_id=None, user_id=None), ) different_mention = SimpleNamespace( name="Another Bot", - id=SimpleNamespace(open_id="ou_other", user_id="u_other"), + id=SimpleNamespace(open_id=None, user_id=None), ) - self.assertTrue(adapter._should_accept_group_message(SimpleNamespace(mentions=[named_mention]), sender_id, "")) + self.assertTrue(adapter._should_accept_group_message(SimpleNamespace(mentions=[name_only_mention]), sender_id, "")) self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[different_mention]), sender_id, "")) - @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) - def test_group_post_message_uses_parsed_mentions_when_sdk_mentions_missing(self): - from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + # Case 2: bot's open_id IS known — a same-name human with different + # open_id must NOT admit (IDs override names). + adapter2 = FeishuAdapter(PlatformConfig()) + adapter2._bot_open_id = "ou_bot" + adapter2._bot_name = "Hermes Bot" - adapter = FeishuAdapter(PlatformConfig()) - adapter._bot_open_id = "ou_bot" - sender_id = SimpleNamespace(open_id="ou_any", user_id=None) - message = SimpleNamespace( - message_type="post", - mentions=[], - content='{"en_us":{"content":[[{"tag":"at","user_name":"Hermes","open_id":"ou_bot"}]]}}', + same_name_other_id_mention = SimpleNamespace( + name="Hermes Bot", + id=SimpleNamespace(open_id="ou_other", user_id="u_other"), + ) + bot_mention = SimpleNamespace( + name="Hermes Bot", + id=SimpleNamespace(open_id="ou_bot", user_id=None), ) - self.assertTrue(adapter._should_accept_group_message(message, sender_id, "")) + self.assertFalse(adapter2._should_accept_group_message(SimpleNamespace(mentions=[same_name_other_id_mention]), sender_id, "")) + self.assertTrue(adapter2._should_accept_group_message(SimpleNamespace(mentions=[bot_mention]), sender_id, "")) @patch.dict(os.environ, {}, clear=True) def test_extract_post_message_as_text(self): @@ -1073,7 +1081,7 @@ class TestAdapterBehavior(unittest.TestCase): message_id="om_post", ) - text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message)) + text, msg_type, media_urls, media_types, _mentions = asyncio.run(adapter._extract_message_content(message)) self.assertEqual(text, "Title\nhello\n[doc](https://example.com)") self.assertEqual(msg_type.value, "text") @@ -1092,7 +1100,7 @@ class TestAdapterBehavior(unittest.TestCase): message_id="om_post_fr", ) - text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message)) + text, msg_type, media_urls, media_types, _mentions = asyncio.run(adapter._extract_message_content(message)) self.assertEqual(text, "Subject\nbonjour") self.assertEqual(msg_type.value, "text") @@ -1118,7 +1126,7 @@ class TestAdapterBehavior(unittest.TestCase): message_id="om_post_rich", ) - text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message)) + text, msg_type, media_urls, media_types, _mentions = asyncio.run(adapter._extract_message_content(message)) self.assertEqual(text, "Rich message\n[Image: diagram]\n@Alice please check the attachment\n[Attachment: spec.pdf]\n:smile:") self.assertEqual(msg_type.value, "text") @@ -1144,7 +1152,7 @@ class TestAdapterBehavior(unittest.TestCase): message_id="om_post_media", ) - text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message)) + text, msg_type, media_urls, media_types, _mentions = asyncio.run(adapter._extract_message_content(message)) self.assertEqual(text, "Rich message\n[Image: diagram]\n[Attachment: spec.pdf]") self.assertEqual(msg_type.value, "text") @@ -1181,7 +1189,7 @@ class TestAdapterBehavior(unittest.TestCase): message_id="om_merge_forward", ) - text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message)) + text, msg_type, media_urls, media_types, _mentions = asyncio.run(adapter._extract_message_content(message)) self.assertEqual( text, @@ -1203,7 +1211,7 @@ class TestAdapterBehavior(unittest.TestCase): message_id="om_share_chat", ) - text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message)) + text, msg_type, media_urls, media_types, _mentions = asyncio.run(adapter._extract_message_content(message)) self.assertEqual(text, "Shared chat: Platform Ops\nChat ID: oc_shared") self.assertEqual(msg_type.value, "text") @@ -1237,7 +1245,7 @@ class TestAdapterBehavior(unittest.TestCase): message_id="om_interactive", ) - text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message)) + text, msg_type, media_urls, media_types, _mentions = asyncio.run(adapter._extract_message_content(message)) self.assertEqual(text, "Approval Request\nRequester: Alice\nApprove\nActions: Approve") self.assertEqual(msg_type.value, "text") @@ -1257,7 +1265,7 @@ class TestAdapterBehavior(unittest.TestCase): message_id="om_image", ) - text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message)) + text, msg_type, media_urls, media_types, _mentions = asyncio.run(adapter._extract_message_content(message)) self.assertEqual(text, "") self.assertEqual(msg_type.value, "photo") @@ -1283,7 +1291,7 @@ class TestAdapterBehavior(unittest.TestCase): message_id="om_audio", ) - text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message)) + text, msg_type, media_urls, media_types, _mentions = asyncio.run(adapter._extract_message_content(message)) self.assertEqual(text, "") self.assertEqual(msg_type.value, "audio") @@ -1305,7 +1313,7 @@ class TestAdapterBehavior(unittest.TestCase): message_id="om_file", ) - text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message)) + text, msg_type, media_urls, media_types, _mentions = asyncio.run(adapter._extract_message_content(message)) self.assertEqual(text, "") self.assertEqual(msg_type.value, "document") @@ -1327,7 +1335,7 @@ class TestAdapterBehavior(unittest.TestCase): message_id="om_media", ) - text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message)) + text, msg_type, media_urls, media_types, _mentions = asyncio.run(adapter._extract_message_content(message)) self.assertEqual(text, "") self.assertEqual(msg_type.value, "photo") @@ -1349,7 +1357,7 @@ class TestAdapterBehavior(unittest.TestCase): message_id="om_video", ) - text, msg_type, media_urls, media_types = asyncio.run(adapter._extract_message_content(message)) + text, msg_type, media_urls, media_types, _mentions = asyncio.run(adapter._extract_message_content(message)) self.assertEqual(text, "") self.assertEqual(msg_type.value, "video") @@ -2685,7 +2693,7 @@ class TestHydrateBotIdentity(unittest.TestCase): }, } ).encode("utf-8") - response = SimpleNamespace(content=payload) + response = SimpleNamespace(raw=SimpleNamespace(content=payload)) adapter._client.request = Mock(return_value=response) asyncio.run(adapter._hydrate_bot_identity()) @@ -2732,7 +2740,7 @@ class TestHydrateBotIdentity(unittest.TestCase): }, } ).encode("utf-8") - adapter._client.request = Mock(return_value=SimpleNamespace(content=payload)) + adapter._client.request = Mock(return_value=SimpleNamespace(raw=SimpleNamespace(content=payload))) asyncio.run(adapter._hydrate_bot_identity()) @@ -2766,7 +2774,7 @@ class TestHydrateBotIdentity(unittest.TestCase): payload = json.dumps( {"code": 0, "bot": {"bot_name": "Hermes", "open_id": "ou_hermes"}} ).encode("utf-8") - adapter._client.request = Mock(return_value=SimpleNamespace(content=payload)) + adapter._client.request = Mock(return_value=SimpleNamespace(raw=SimpleNamespace(content=payload))) asyncio.run(adapter._hydrate_bot_identity()) @@ -3479,3 +3487,1033 @@ class TestProcessingReactions(unittest.TestCase): len(adapter._pending_processing_reactions), _FEISHU_PROCESSING_REACTION_CACHE_SIZE, ) + + +class TestFeishuMentionMap(unittest.TestCase): + def test_build_mentions_map_handles_at_all(self): + from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity, FeishuMentionRef + + mention = SimpleNamespace(key="@_all", id=None, name="") + result = _build_mentions_map( + [mention], + _FeishuBotIdentity(open_id="ou_bot", name="Hermes"), + ) + self.assertEqual(result["@_all"], FeishuMentionRef(is_all=True)) + + def test_build_mentions_map_marks_self_by_open_id(self): + from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + + mention = SimpleNamespace( + key="@_user_1", + id=SimpleNamespace(open_id="ou_bot", user_id=""), + name="Hermes", + ) + ref = _build_mentions_map([mention], _FeishuBotIdentity(open_id="ou_bot"))["@_user_1"] + self.assertTrue(ref.is_self) + self.assertEqual(ref.open_id, "ou_bot") + self.assertEqual(ref.name, "Hermes") + + def test_build_mentions_map_marks_self_by_name_fallback(self): + from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + + mention = SimpleNamespace( + key="@_user_1", + id=SimpleNamespace(open_id="", user_id=""), + name="Hermes", + ) + result = _build_mentions_map([mention], _FeishuBotIdentity(name="Hermes")) + self.assertTrue(result["@_user_1"].is_self) + + def test_build_mentions_map_name_match_does_not_override_mismatching_open_id(self): + """Regression: a human user whose display name matches the bot must + NOT be flagged as self when their open_id differs. Before the fix, + name-match fired even when open_id was present and different, causing + their messages to be silently stripped/dropped.""" + from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + + human_with_same_name = SimpleNamespace( + key="@_user_1", + id=SimpleNamespace(open_id="ou_human", user_id=""), + name="Hermes Bot", + ) + result = _build_mentions_map( + [human_with_same_name], + _FeishuBotIdentity(open_id="ou_bot", name="Hermes Bot"), + ) + self.assertFalse(result["@_user_1"].is_self) + + def test_build_mentions_map_falls_back_to_name_when_bot_open_id_not_hydrated(self): + """Regression: right after gateway startup, _hydrate_bot_identity may + not have populated _bot_open_id yet. During that window, a mention + carrying a real open_id should still match via name — otherwise + @bot messages silently fail admission.""" + from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + + bot_mention = SimpleNamespace( + key="@_user_1", + id=SimpleNamespace(open_id="ou_bot_actual", user_id=""), + name="Hermes Bot", + ) + # Bot identity has name but no open_id yet (hydration pending). + result = _build_mentions_map( + [bot_mention], + _FeishuBotIdentity(open_id="", name="Hermes Bot"), + ) + self.assertTrue(result["@_user_1"].is_self) + + def test_build_mentions_map_non_self_user(self): + from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + + mention = SimpleNamespace( + key="@_user_1", + id=SimpleNamespace(open_id="ou_alice", user_id=""), + name="Alice", + ) + ref = _build_mentions_map([mention], _FeishuBotIdentity(open_id="ou_bot"))["@_user_1"] + self.assertFalse(ref.is_self) + self.assertEqual(ref.open_id, "ou_alice") + self.assertEqual(ref.name, "Alice") + + def test_build_mentions_map_returns_empty_for_none_input(self): + from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + + self.assertEqual(_build_mentions_map(None, _FeishuBotIdentity(open_id="ou_bot")), {}) + + def test_build_mentions_map_tolerates_missing_id_object(self): + from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + + mention = SimpleNamespace(key="@_user_9", id=None, name="") + ref = _build_mentions_map([mention], _FeishuBotIdentity(open_id="ou_bot"))["@_user_9"] + self.assertEqual(ref.open_id, "") + self.assertFalse(ref.is_self) + + +class TestFeishuMentionHint(unittest.TestCase): + def test_hint_single_user(self): + from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + + refs = [FeishuMentionRef(name="Alice", open_id="ou_alice")] + self.assertEqual( + _build_mention_hint(refs), + "[Mentioned: Alice (open_id=ou_alice)]", + ) + + def test_hint_multiple_users(self): + from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + + refs = [ + FeishuMentionRef(name="Alice", open_id="ou_alice"), + FeishuMentionRef(name="Bob", open_id="ou_bob"), + ] + self.assertEqual( + _build_mention_hint(refs), + "[Mentioned: Alice (open_id=ou_alice), Bob (open_id=ou_bob)]", + ) + + def test_hint_at_all(self): + from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + + refs = [FeishuMentionRef(is_all=True)] + self.assertEqual(_build_mention_hint(refs), "[Mentioned: @all]") + + def test_hint_filters_self_mentions(self): + from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + + refs = [ + FeishuMentionRef(name="Hermes", open_id="ou_bot", is_self=True), + FeishuMentionRef(name="Alice", open_id="ou_alice"), + ] + self.assertEqual( + _build_mention_hint(refs), + "[Mentioned: Alice (open_id=ou_alice)]", + ) + + def test_hint_returns_empty_when_only_self(self): + from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + + refs = [FeishuMentionRef(name="Hermes", open_id="ou_bot", is_self=True)] + self.assertEqual(_build_mention_hint(refs), "") + + def test_hint_returns_empty_for_no_refs(self): + from gateway.platforms.feishu import _build_mention_hint + + self.assertEqual(_build_mention_hint([]), "") + + def test_hint_falls_back_when_open_id_missing(self): + from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + + refs = [FeishuMentionRef(name="Alice", open_id="")] + self.assertEqual(_build_mention_hint(refs), "[Mentioned: Alice]") + + def test_hint_uses_unknown_placeholder_when_name_missing(self): + from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + + refs = [FeishuMentionRef(name="", open_id="ou_xxx")] + self.assertEqual(_build_mention_hint(refs), "[Mentioned: unknown (open_id=ou_xxx)]") + + def test_hint_dedupes_repeated_user(self): + from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + + refs = [ + FeishuMentionRef(name="Alice", open_id="ou_alice"), + FeishuMentionRef(name="Alice", open_id="ou_alice"), + FeishuMentionRef(name="Bob", open_id="ou_bob"), + ] + self.assertEqual( + _build_mention_hint(refs), + "[Mentioned: Alice (open_id=ou_alice), Bob (open_id=ou_bob)]", + ) + + def test_hint_dedupes_repeated_at_all(self): + from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + + refs = [FeishuMentionRef(is_all=True), FeishuMentionRef(is_all=True)] + self.assertEqual(_build_mention_hint(refs), "[Mentioned: @all]") + + +class TestFeishuStripLeadingSelf(unittest.TestCase): + def _make_refs(self, *, self_name="Hermes", other_name=None): + from gateway.platforms.feishu import FeishuMentionRef + + refs = [FeishuMentionRef(name=self_name, open_id="ou_bot", is_self=True)] + if other_name: + refs.append(FeishuMentionRef(name=other_name, open_id="ou_alice")) + return refs + + def test_strips_leading_self(self): + from gateway.platforms.feishu import _strip_edge_self_mentions + + result = _strip_edge_self_mentions("@Hermes /help", self._make_refs()) + self.assertEqual(result, "/help") + + def test_strips_consecutive_leading_self(self): + from gateway.platforms.feishu import _strip_edge_self_mentions + + result = _strip_edge_self_mentions("@Hermes @Hermes hi", self._make_refs()) + self.assertEqual(result, "hi") + + def test_stops_at_first_non_self_token(self): + from gateway.platforms.feishu import _strip_edge_self_mentions + + result = _strip_edge_self_mentions( + "@Hermes @Alice make a group", self._make_refs(other_name="Alice") + ) + self.assertEqual(result, "@Alice make a group") + + def test_preserves_mid_text_self(self): + from gateway.platforms.feishu import _strip_edge_self_mentions + + result = _strip_edge_self_mentions("check @Hermes said yesterday", self._make_refs()) + self.assertEqual(result, "check @Hermes said yesterday") + + def test_strips_trailing_self_at_end_of_text(self): + from gateway.platforms.feishu import _strip_edge_self_mentions + + result = _strip_edge_self_mentions("look up docs @Hermes", self._make_refs()) + self.assertEqual(result, "look up docs") + + def test_strips_trailing_self_with_terminal_punct(self): + from gateway.platforms.feishu import _strip_edge_self_mentions + + # Terminal punct after the mention — strip the mention, keep the punct. + result = _strip_edge_self_mentions("look up docs @Hermes.", self._make_refs()) + self.assertEqual(result, "look up docs.") + + def test_preserves_trailing_self_before_non_terminal_char(self): + from gateway.platforms.feishu import _strip_edge_self_mentions + + # Non-terminal char (here a Chinese particle) follows — preserve. + result = _strip_edge_self_mentions( + "please don't @Hermes anymore", self._make_refs() + ) + self.assertEqual(result, "please don't @Hermes anymore") + + def test_returns_input_when_refs_empty(self): + from gateway.platforms.feishu import _strip_edge_self_mentions + + self.assertEqual(_strip_edge_self_mentions("@Hermes /help", []), "@Hermes /help") + + def test_returns_input_when_no_self_refs(self): + from gateway.platforms.feishu import _strip_edge_self_mentions, FeishuMentionRef + + refs = [FeishuMentionRef(name="Alice", open_id="ou_alice")] + self.assertEqual(_strip_edge_self_mentions("@Alice hi", refs), "@Alice hi") + + def test_uses_open_id_fallback_when_name_missing(self): + from gateway.platforms.feishu import _strip_edge_self_mentions, FeishuMentionRef + + refs = [FeishuMentionRef(name="", open_id="ou_bot", is_self=True)] + self.assertEqual(_strip_edge_self_mentions("@ou_bot hi", refs), "hi") + + def test_word_boundary_prevents_prefix_collision(self): + """A bot named 'Al' must not eat the leading '@Alice' of a different user.""" + from gateway.platforms.feishu import _strip_edge_self_mentions, FeishuMentionRef + + refs = [FeishuMentionRef(name="Al", open_id="ou_bot", is_self=True)] + self.assertEqual(_strip_edge_self_mentions("@Alice hi", refs), "@Alice hi") + + +class TestFeishuNormalizeText(unittest.TestCase): + def test_renders_mention_with_display_name(self): + from gateway.platforms.feishu import _normalize_feishu_text, FeishuMentionRef + + refs = {"@_user_1": FeishuMentionRef(name="Alice", open_id="ou_alice")} + self.assertEqual(_normalize_feishu_text("@_user_1 hello", refs), "@Alice hello") + + def test_renders_self_mention_with_name(self): + from gateway.platforms.feishu import _normalize_feishu_text, FeishuMentionRef + + refs = {"@_user_1": FeishuMentionRef(name="Hermes", open_id="ou_bot", is_self=True)} + self.assertEqual( + _normalize_feishu_text("stop pinging @_user_1 please", refs), + "stop pinging @Hermes please", + ) + + def test_at_all_rendered_as_english_literal(self): + from gateway.platforms.feishu import _normalize_feishu_text + + self.assertEqual(_normalize_feishu_text("@_all notice", None), "@all notice") + + def test_unknown_placeholder_degrades_to_space(self): + from gateway.platforms.feishu import _normalize_feishu_text + + # No map: fall back to the old behavior (substitute with space, then collapse). + self.assertEqual(_normalize_feishu_text("@_user_9 hello", None), "hello") + + def test_backward_compatible_without_map(self): + from gateway.platforms.feishu import _normalize_feishu_text + + self.assertEqual(_normalize_feishu_text("hello world"), "hello world") + + def test_mention_for_missing_map_entry_degrades_to_space(self): + from gateway.platforms.feishu import _normalize_feishu_text, FeishuMentionRef + + refs = {"@_user_1": FeishuMentionRef(name="Alice")} + # @_user_2 has no entry — should degrade to a space (legacy behavior) + self.assertEqual( + _normalize_feishu_text("@_user_1 @_user_2 hi", refs), + "@Alice hi", + ) + + +class TestFeishuPostMentionParsing(unittest.TestCase): + def test_post_at_tag_renders_via_mentions_map(self): + """Post .user_id is a placeholder ('@_user_N'); the real display + name comes from the mentions_map lookup. Confirmed via live + im.v1.message.get payload.""" + from gateway.platforms.feishu import parse_feishu_post_payload, FeishuMentionRef + + payload = { + "en_us": { + "content": [[ + {"tag": "at", "user_id": "@_user_1", "user_name": "ignored"}, + {"tag": "text", "text": " hello"}, + ]] + } + } + mentions_map = { + "@_user_1": FeishuMentionRef(name="Alice", open_id="ou_alice"), + } + result = parse_feishu_post_payload(payload, mentions_map=mentions_map) + self.assertEqual(result.text_content, "@Alice hello") + + def test_post_at_tag_falls_back_to_inline_user_name_when_map_misses(self): + """When the mentions payload is missing a placeholder, fall back to the + inline user_name in the tag itself.""" + from gateway.platforms.feishu import parse_feishu_post_payload + + payload = { + "en_us": { + "content": [[ + {"tag": "at", "user_id": "@_user_7", "user_name": "Unknown"}, + {"tag": "text", "text": " hi"}, + ]] + } + } + result = parse_feishu_post_payload(payload, mentions_map={}) + self.assertEqual(result.text_content, "@Unknown hi") + + def test_post_at_all_tag_renders_as_at_all(self): + """Post-format @everyone has user_id == '@_all' (confirmed via live + im.v1.message.get). Rendered as literal '@all' regardless of map.""" + from gateway.platforms.feishu import parse_feishu_post_payload + + payload = { + "en_us": { + "content": [[ + {"tag": "at", "user_id": "@_all", "user_name": "everyone"}, + {"tag": "text", "text": " meeting"}, + ]] + } + } + result = parse_feishu_post_payload(payload) + self.assertIn("@all", result.text_content) + + +class TestFeishuNormalizeWithMentions(unittest.TestCase): + def test_text_message_renders_mention_by_name(self): + from gateway.platforms.feishu import normalize_feishu_message, _FeishuBotIdentity + + mention = SimpleNamespace( + key="@_user_1", + id=SimpleNamespace(open_id="ou_alice", user_id=""), + name="Alice", + ) + normalized = normalize_feishu_message( + message_type="text", + raw_content=json.dumps({"text": "@_user_1 hello"}), + mentions=[mention], + bot=_FeishuBotIdentity(open_id="ou_bot"), + ) + self.assertEqual(normalized.text_content, "@Alice hello") + self.assertEqual(len(normalized.mentions), 1) + self.assertEqual(normalized.mentions[0].open_id, "ou_alice") + self.assertFalse(normalized.mentions[0].is_self) + + def test_text_message_marks_bot_self_mention(self): + from gateway.platforms.feishu import normalize_feishu_message, _FeishuBotIdentity + + mention = SimpleNamespace( + key="@_user_1", + id=SimpleNamespace(open_id="ou_bot", user_id=""), + name="Hermes", + ) + normalized = normalize_feishu_message( + message_type="text", + raw_content=json.dumps({"text": "@_user_1 /help"}), + mentions=[mention], + bot=_FeishuBotIdentity(open_id="ou_bot"), + ) + self.assertTrue(normalized.mentions[0].is_self) + # self mention is still rendered — strip is a separate adapter-level pass + self.assertEqual(normalized.text_content, "@Hermes /help") + + def test_text_message_at_all_surfaces_ref(self): + from gateway.platforms.feishu import normalize_feishu_message + + mention = SimpleNamespace(key="@_all", id=None, name="") + normalized = normalize_feishu_message( + message_type="text", + raw_content=json.dumps({"text": "@_all meeting"}), + mentions=[mention], + ) + self.assertEqual(normalized.text_content, "@all meeting") + self.assertEqual(len(normalized.mentions), 1) + self.assertTrue(normalized.mentions[0].is_all) + + def test_text_message_at_all_in_text_without_mentions_payload(self): + """Feishu SDK sometimes omits @_all from the mentions payload (confirmed + via im.v1.message.get). The fallback scan on raw text must still yield + an is_all ref so [Mentioned: @all] gets injected.""" + from gateway.platforms.feishu import normalize_feishu_message + + normalized = normalize_feishu_message( + message_type="text", + raw_content=json.dumps({"text": "@_all hello"}), + mentions=None, + ) + self.assertEqual(normalized.text_content, "@all hello") + self.assertEqual(len(normalized.mentions), 1) + self.assertTrue(normalized.mentions[0].is_all) + + def test_text_message_at_all_not_synthesized_if_absent_from_text(self): + """No @_all in text → no synthetic ref even if mentions_map is empty.""" + from gateway.platforms.feishu import normalize_feishu_message + + normalized = normalize_feishu_message( + message_type="text", + raw_content=json.dumps({"text": "plain hello"}), + mentions=None, + ) + self.assertEqual(normalized.mentions, []) + + def test_text_message_without_mentions_param_is_backward_compatible(self): + from gateway.platforms.feishu import normalize_feishu_message + + normalized = normalize_feishu_message( + message_type="text", + raw_content=json.dumps({"text": "hello world"}), + ) + self.assertEqual(normalized.text_content, "hello world") + self.assertEqual(normalized.mentions, []) + + def test_post_message_marks_self_via_mentions_map_lookup(self): + """Real Feishu post: + top-level mentions array + resolves to open_id via placeholder lookup, not direct tag fields.""" + from gateway.platforms.feishu import normalize_feishu_message, _FeishuBotIdentity + + raw = json.dumps({ + "en_us": { + "content": [ + [ + {"tag": "at", "user_id": "@_user_1", "user_name": "Hermes"}, + {"tag": "text", "text": " check this"}, + ] + ] + } + }) + bot_mention = SimpleNamespace( + key="@_user_1", + id=SimpleNamespace(open_id="ou_bot", user_id=""), + name="Hermes", + ) + normalized = normalize_feishu_message( + message_type="post", + raw_content=raw, + mentions=[bot_mention], + bot=_FeishuBotIdentity(open_id="ou_bot"), + ) + self.assertEqual(len(normalized.mentions), 1) + self.assertTrue(normalized.mentions[0].is_self) + self.assertEqual(normalized.mentions[0].open_id, "ou_bot") + + +class TestFeishuPostMentionsBot(unittest.TestCase): + def _build_adapter(self, bot_open_id="ou_bot", bot_user_id="", bot_name=""): + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter.__new__(FeishuAdapter) + adapter._bot_open_id = bot_open_id + adapter._bot_user_id = bot_user_id + adapter._bot_name = bot_name + return adapter + + def test_post_mentions_bot_uses_is_self_flag(self): + from gateway.platforms.feishu import FeishuMentionRef + + adapter = self._build_adapter() + self.assertTrue( + adapter._post_mentions_bot( + [FeishuMentionRef(name="Hermes", open_id="ou_bot", is_self=True)] + ) + ) + self.assertFalse( + adapter._post_mentions_bot( + [FeishuMentionRef(name="Alice", open_id="ou_alice")] + ) + ) + + def test_post_mentions_bot_empty_returns_false(self): + adapter = self._build_adapter() + self.assertFalse(adapter._post_mentions_bot([])) + + +class TestFeishuExtractMessageContent(unittest.TestCase): + def _build_adapter(self): + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter.__new__(FeishuAdapter) + adapter._bot_open_id = "ou_bot" + adapter._bot_user_id = "" + adapter._bot_name = "Hermes" + adapter._download_feishu_message_resources = AsyncMock(return_value=([], [])) + return adapter + + def test_returns_five_tuple_with_mentions(self): + adapter = self._build_adapter() + message = SimpleNamespace( + content=json.dumps({"text": "@_user_1 hello"}), + message_type="text", + message_id="m1", + mentions=[ + SimpleNamespace( + key="@_user_1", + id=SimpleNamespace(open_id="ou_alice", user_id=""), + name="Alice", + ) + ], + ) + + text, inbound_type, media_urls, media_types, mentions = asyncio.run( + adapter._extract_message_content(message) + ) + self.assertEqual(text, "@Alice hello") + self.assertEqual(len(mentions), 1) + self.assertEqual(mentions[0].open_id, "ou_alice") + + def test_returns_empty_mentions_when_missing(self): + adapter = self._build_adapter() + message = SimpleNamespace( + content=json.dumps({"text": "plain hello"}), + message_type="text", + message_id="m2", + mentions=None, + ) + + text, _, _, _, mentions = asyncio.run(adapter._extract_message_content(message)) + self.assertEqual(text, "plain hello") + self.assertEqual(mentions, []) + + +class TestFeishuProcessInboundMessage(unittest.TestCase): + def _build_adapter(self): + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter.__new__(FeishuAdapter) + adapter._bot_open_id = "ou_bot" + adapter._bot_user_id = "" + adapter._bot_name = "Hermes" + adapter._download_feishu_message_resources = AsyncMock(return_value=([], [])) + adapter._fetch_message_text = AsyncMock(return_value=None) + adapter.get_chat_info = AsyncMock(return_value={"name": "Test Chat"}) + adapter._resolve_sender_profile = AsyncMock( + return_value={"user_id": "u1", "user_name": "Alice", "user_id_alt": None} + ) + adapter._resolve_source_chat_type = Mock(return_value="group") + adapter.build_source = Mock(return_value=SimpleNamespace(thread_id=None)) + adapter._dispatch_inbound_event = AsyncMock() + return adapter + + def test_leading_self_mention_stripped_for_command(self): + from gateway.platforms.base import MessageType + + adapter = self._build_adapter() + bot_mention = SimpleNamespace( + key="@_user_1", + id=SimpleNamespace(open_id="ou_bot", user_id=""), + name="Hermes", + ) + message = SimpleNamespace( + content=json.dumps({"text": "@_user_1 /help"}), + message_type="text", + message_id="m1", + mentions=[bot_mention], + chat_id="oc_chat", + parent_id=None, + upper_message_id=None, + thread_id=None, + ) + asyncio.run( + adapter._process_inbound_message( + data=message, + message=message, + sender_id=None, + chat_type="group", + message_id="m1", + ) + ) + event = adapter._dispatch_inbound_event.call_args.args[0] + self.assertEqual(event.text, "/help") + self.assertEqual(event.message_type, MessageType.COMMAND) + + def test_non_command_message_with_mentions_injects_hint(self): + from gateway.platforms.base import MessageType + + adapter = self._build_adapter() + alice = SimpleNamespace( + key="@_user_1", + id=SimpleNamespace(open_id="ou_alice", user_id=""), + name="Alice", + ) + bob = SimpleNamespace( + key="@_user_2", + id=SimpleNamespace(open_id="ou_bob", user_id=""), + name="Bob", + ) + message = SimpleNamespace( + content=json.dumps({"text": "@_user_1 @_user_2 make a group"}), + message_type="text", + message_id="m2", + mentions=[alice, bob], + chat_id="oc_chat", + parent_id=None, + upper_message_id=None, + thread_id=None, + ) + asyncio.run( + adapter._process_inbound_message( + data=message, + message=message, + sender_id=None, + chat_type="group", + message_id="m2", + ) + ) + event = adapter._dispatch_inbound_event.call_args.args[0] + self.assertEqual(event.message_type, MessageType.TEXT) + self.assertIn("[Mentioned: Alice (open_id=ou_alice), Bob (open_id=ou_bob)]", event.text) + self.assertIn("@Alice @Bob make a group", event.text) + + def test_command_message_never_injects_hint(self): + adapter = self._build_adapter() + bot_mention = SimpleNamespace( + key="@_user_1", + id=SimpleNamespace(open_id="ou_bot", user_id=""), + name="Hermes", + ) + alice = SimpleNamespace( + key="@_user_2", + id=SimpleNamespace(open_id="ou_alice", user_id=""), + name="Alice", + ) + message = SimpleNamespace( + content=json.dumps({"text": "@_user_1 /model @_user_2"}), + message_type="text", + message_id="m3", + mentions=[bot_mention, alice], + chat_id="oc_chat", + parent_id=None, + upper_message_id=None, + thread_id=None, + ) + asyncio.run( + adapter._process_inbound_message( + data=message, + message=message, + sender_id=None, + chat_type="group", + message_id="m3", + ) + ) + event = adapter._dispatch_inbound_event.call_args.args[0] + self.assertNotIn("[Mentioned:", event.text) + self.assertTrue(event.text.startswith("/model")) + + def test_mid_text_self_mention_preserved(self): + adapter = self._build_adapter() + bot_mention = SimpleNamespace( + key="@_user_1", + id=SimpleNamespace(open_id="ou_bot", user_id=""), + name="Hermes", + ) + message = SimpleNamespace( + content=json.dumps({"text": "stop pinging @_user_1 please"}), + message_type="text", + message_id="m4", + mentions=[bot_mention], + chat_id="oc_chat", + parent_id=None, + upper_message_id=None, + thread_id=None, + ) + asyncio.run( + adapter._process_inbound_message( + data=message, + message=message, + sender_id=None, + chat_type="group", + message_id="m4", + ) + ) + event = adapter._dispatch_inbound_event.call_args.args[0] + self.assertEqual(event.text, "stop pinging @Hermes please") + + def test_pure_self_mention_message_is_ignored(self): + """A message containing only '@Bot' (no body, no media) must not dispatch. + + Regression guard: the rendered '@Hermes' slips past the pre-strip empty + guard; the post-strip guard must catch it. + """ + adapter = self._build_adapter() + bot_mention = SimpleNamespace( + key="@_user_1", + id=SimpleNamespace(open_id="ou_bot", user_id=""), + name="Hermes", + ) + message = SimpleNamespace( + content=json.dumps({"text": "@_user_1"}), + message_type="text", + message_id="m5", + mentions=[bot_mention], + chat_id="oc_chat", + parent_id=None, + upper_message_id=None, + thread_id=None, + ) + asyncio.run( + adapter._process_inbound_message( + data=message, message=message, sender_id=None, + chat_type="group", message_id="m5", + ) + ) + adapter._dispatch_inbound_event.assert_not_called() + + +class TestFeishuFetchMessageText(unittest.TestCase): + def _build_adapter(self): + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter.__new__(FeishuAdapter) + adapter._bot_open_id = "ou_bot" + adapter._bot_user_id = "" + adapter._bot_name = "Hermes" + adapter._message_text_cache = {} + adapter._client = Mock() + adapter._build_get_message_request = Mock(return_value=object()) + return adapter + + def test_fetch_message_text_renders_mentions_without_hint_prefix(self): + adapter = self._build_adapter() + + alice_mention = SimpleNamespace( + key="@_user_1", + id="ou_alice", + id_type="open_id", + name="Alice", + ) + parent = SimpleNamespace( + body=SimpleNamespace(content=json.dumps({"text": "@_user_1 hi"})), + msg_type="text", + mentions=[alice_mention], + ) + response = Mock() + response.success = Mock(return_value=True) + response.data = SimpleNamespace(items=[parent]) + adapter._client.im.v1.message.get = Mock(return_value=response) + + result = asyncio.run(adapter._fetch_message_text("m_parent")) + self.assertEqual(result, "@Alice hi") + # No [Mentioned:] wrapper — reply-context path intentionally skips the hint. + self.assertNotIn("[Mentioned:", result) + + def test_extract_text_from_raw_content_accepts_mentions_kwarg(self): + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter.__new__(FeishuAdapter) + adapter._bot_open_id = "" + adapter._bot_user_id = "" + adapter._bot_name = "" + + alice_mention = SimpleNamespace( + key="@_user_1", + id=SimpleNamespace(open_id="ou_alice", user_id=""), + name="Alice", + ) + self.assertEqual( + adapter._extract_text_from_raw_content( + msg_type="text", + raw_content=json.dumps({"text": "@_user_1 hello"}), + mentions=[alice_mention], + ), + "@Alice hello", + ) + + def test_fetch_message_text_marks_is_self_via_string_id_shape(self): + """History-path Mention objects carry id as str + id_type; is_self must still work.""" + adapter = self._build_adapter() + # bot_name is empty — is_self must be detected via open_id alone + adapter._bot_name = "" + + bot_mention = SimpleNamespace( + key="@_user_1", + id="ou_bot", + id_type="open_id", + name="Hermes", + ) + parent = SimpleNamespace( + body=SimpleNamespace(content=json.dumps({"text": "@_user_1 hi"})), + msg_type="text", + mentions=[bot_mention], + ) + response = Mock() + response.success = Mock(return_value=True) + response.data = SimpleNamespace(items=[parent]) + adapter._client.im.v1.message.get = Mock(return_value=response) + + # The rendered text should still have the bot name substituted. + result = asyncio.run(adapter._fetch_message_text("m_parent")) + self.assertEqual(result, "@Hermes hi") + + def test_build_mentions_map_string_id_shape(self): + """_build_mentions_map accepts the reply-history shape (id as str + + id_type='open_id'). user_id id_type is not load-bearing for self + detection — inbound mention payloads always include an open_id.""" + from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + + # open_id discriminator, non-self + alice = SimpleNamespace(key="@_user_1", id="ou_alice", id_type="open_id", name="Alice") + ref = _build_mentions_map([alice], _FeishuBotIdentity(open_id="ou_bot"))["@_user_1"] + self.assertEqual(ref.open_id, "ou_alice") + self.assertFalse(ref.is_self) + + # open_id discriminator, is_self matches via open_id + bot_oid = SimpleNamespace(key="@_user_3", id="ou_bot", id_type="open_id", name="Hermes") + self.assertTrue( + _build_mentions_map([bot_oid], _FeishuBotIdentity(open_id="ou_bot"))["@_user_3"].is_self + ) + + +class TestFeishuMentionEndToEnd(unittest.TestCase): + """High-level scenarios from the design spec — verify the full pipeline.""" + + def _build_adapter(self): + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter.__new__(FeishuAdapter) + adapter._bot_open_id = "ou_bot" + adapter._bot_user_id = "" + adapter._bot_name = "Hermes" + adapter._download_feishu_message_resources = AsyncMock(return_value=([], [])) + adapter._fetch_message_text = AsyncMock(return_value=None) + adapter.get_chat_info = AsyncMock(return_value={"name": "Test Chat"}) + adapter._resolve_sender_profile = AsyncMock( + return_value={"user_id": "u1", "user_name": "Alice", "user_id_alt": None} + ) + adapter._resolve_source_chat_type = Mock(return_value="group") + adapter.build_source = Mock(return_value=SimpleNamespace(thread_id=None)) + adapter._dispatch_inbound_event = AsyncMock() + return adapter + + def _run(self, adapter, text, mentions): + raw_mentions = [ + SimpleNamespace( + key=m["key"], + id=SimpleNamespace(open_id=m.get("open_id", ""), user_id=m.get("user_id", "")), + name=m.get("name", ""), + ) + for m in mentions + ] + message = SimpleNamespace( + content=json.dumps({"text": text}), + message_type="text", + message_id="m", + mentions=raw_mentions, + chat_id="oc_chat", + parent_id=None, + upper_message_id=None, + thread_id=None, + ) + asyncio.run( + adapter._process_inbound_message( + data=message, message=message, sender_id=None, chat_type="group", message_id="m", + ) + ) + return adapter._dispatch_inbound_event.call_args.args[0] + + def test_scenario_bot_plus_alice_plus_bob_build_group(self): + adapter = self._build_adapter() + event = self._run( + adapter, + "@_user_1 @_user_2 @_user_3 build me a group", + [ + {"key": "@_user_1", "open_id": "ou_bot", "name": "Hermes"}, + {"key": "@_user_2", "open_id": "ou_alice", "name": "Alice"}, + {"key": "@_user_3", "open_id": "ou_bob", "name": "Bob"}, + ], + ) + self.assertIn("[Mentioned: Alice (open_id=ou_alice), Bob (open_id=ou_bob)]", event.text) + self.assertIn("@Alice @Bob build me a group", event.text) + self.assertNotIn("@Hermes", event.text) + + def test_scenario_at_all_announcement(self): + adapter = self._build_adapter() + event = self._run( + adapter, + "@_all meeting at 3pm", + [{"key": "@_all"}], + ) + self.assertTrue(event.text.startswith("[Mentioned: @all]")) + self.assertIn("@all meeting at 3pm", event.text) + + def test_scenario_trailing_self_mention_stripped(self): + """Trailing @bot at the end of a message is routing noise, not content — + strip it so the agent sees a clean instruction body.""" + adapter = self._build_adapter() + event = self._run( + adapter, + "who are you @_user_1", + [{"key": "@_user_1", "open_id": "ou_bot", "name": "Hermes"}], + ) + self.assertEqual(event.text, "who are you") + + def test_scenario_mid_text_self_mention_preserved(self): + """Self mention in the middle of a sentence (followed by a non-terminal + character) is meaningful content — preserve it.""" + adapter = self._build_adapter() + event = self._run( + adapter, + "please don't @_user_1 anymore", + [{"key": "@_user_1", "open_id": "ou_bot", "name": "Hermes"}], + ) + self.assertEqual(event.text, "please don't @Hermes anymore") + + def test_scenario_no_mentions_zero_regression(self): + adapter = self._build_adapter() + event = self._run(adapter, "plain message", []) + self.assertEqual(event.text, "plain message") + self.assertNotIn("[Mentioned:", event.text) + + def test_scenario_post_at_alice_exposes_open_id(self): + """Post-type @mention: placeholder resolves via top-level mentions, + agent gets real open_id in the hint (mirrors text-type behavior).""" + adapter = self._build_adapter() + alice_mention = SimpleNamespace( + key="@_user_1", + id=SimpleNamespace(open_id="ou_alice", user_id=""), + name="Alice", + ) + post_content = json.dumps({ + "zh_cn": { + "content": [[ + {"tag": "at", "user_id": "@_user_1", "user_name": "Alice"}, + {"tag": "text", "text": " lookup this doc"}, + ]] + } + }) + message = SimpleNamespace( + content=post_content, + message_type="post", + message_id="m_post", + mentions=[alice_mention], + chat_id="oc_chat", + parent_id=None, + upper_message_id=None, + thread_id=None, + ) + asyncio.run( + adapter._process_inbound_message( + data=message, message=message, sender_id=None, + chat_type="group", message_id="m_post", + ) + ) + event = adapter._dispatch_inbound_event.call_args.args[0] + self.assertIn("[Mentioned: Alice (open_id=ou_alice)]", event.text) + self.assertIn("@Alice lookup this doc", event.text) + + def test_scenario_post_bot_plus_alice_filters_self_from_hint(self): + """Post-type message @-ing both the bot and Alice: leading bot is + stripped from the body, self is filtered from the [Mentioned: ...] + hint, and Alice's real open_id is surfaced for the agent.""" + adapter = self._build_adapter() + bot_mention = SimpleNamespace( + key="@_user_1", + id=SimpleNamespace(open_id="ou_bot", user_id=""), + name="Hermes", + ) + alice_mention = SimpleNamespace( + key="@_user_2", + id=SimpleNamespace(open_id="ou_alice", user_id=""), + name="Alice", + ) + post_content = json.dumps({ + "zh_cn": { + "content": [[ + {"tag": "at", "user_id": "@_user_1", "user_name": "Hermes"}, + {"tag": "at", "user_id": "@_user_2", "user_name": "Alice"}, + {"tag": "text", "text": " review the spec with Alice"}, + ]] + } + }) + message = SimpleNamespace( + content=post_content, + message_type="post", + message_id="m_post_both", + mentions=[bot_mention, alice_mention], + chat_id="oc_chat", + parent_id=None, + upper_message_id=None, + thread_id=None, + ) + asyncio.run( + adapter._process_inbound_message( + data=message, message=message, sender_id=None, + chat_type="group", message_id="m_post_both", + ) + ) + event = adapter._dispatch_inbound_event.call_args.args[0] + # Hint surfaces Alice; bot excluded because is_self=True. + self.assertIn("[Mentioned: Alice (open_id=ou_alice)]", event.text) + self.assertNotIn("Hermes (open_id=", event.text) + # Body: leading @Hermes stripped, Alice preserved, trailing text intact. + self.assertIn("@Alice review the spec with Alice", event.text) + self.assertNotIn("@Hermes @Alice", event.text)