diff --git a/lark_oapi/channel/normalize/registry.py b/lark_oapi/channel/normalize/registry.py index cfdf6e144..a64b691cc 100644 --- a/lark_oapi/channel/normalize/registry.py +++ b/lark_oapi/channel/normalize/registry.py @@ -59,14 +59,19 @@ def _safe_json(raw: Any) -> Dict[str, Any]: def _flatten_post_text(post: Dict[str, Any]) -> Tuple[str, str]: """Return (title, plain_text) from a post AST. - Post content has locale keys (`zh_cn`, `en_us`). We pick the first locale. + Post content may be locale-keyed (``{"zh_cn": {...}, "en_us": {...}}``) + or carry ``content`` directly (the classic single-locale post shape). + For locale-keyed payloads the first locale is used. """ if not isinstance(post, dict): return "", "" - first_key = next(iter(post), None) - if first_key is None: - return "", "" - locale_doc = post.get(first_key) + if "content" in post: + locale_doc = post + else: + first_key = next(iter(post), None) + if first_key is None: + return "", "" + locale_doc = post.get(first_key) if not isinstance(locale_doc, dict): return "", "" title = locale_doc.get("title") or "" @@ -82,8 +87,19 @@ def _flatten_post_text(post: Dict[str, Any]) -> Tuple[str, str]: elif tag == "a": chunk.append(el.get("text") or el.get("href") or "") elif tag == "at": - nm = el.get("user_name") or el.get("user_id") or "" - chunk.append(f"@{nm}" if nm else "@") + el_id = el.get("id") + is_all = el.get("user_id") == "all" or ( + isinstance(el_id, dict) and el_id.get("user_id") == "all" + ) + if is_all: + # Mention-all node: render the ``@_all`` placeholder so the + # pipeline's mention-all probes (``text_has_mention_all`` / + # ``resolve_mentions``) fire for rich-text @all messages. + # See https://github.com/larksuite/oapi-sdk-python/issues/138 + chunk.append("@_all") + else: + nm = el.get("user_name") or el.get("user_id") or "" + chunk.append(f"@{nm}" if nm else "@") elif tag == "emotion": chunk.append(f":{el.get('emoji_type') or ''}:") elif tag == "img": diff --git a/lark_oapi/channel/tests/test_post_mention_all.py b/lark_oapi/channel/tests/test_post_mention_all.py new file mode 100644 index 000000000..d4e62f4bb --- /dev/null +++ b/lark_oapi/channel/tests/test_post_mention_all.py @@ -0,0 +1,83 @@ +"""mentioned_all for rich-text post @all messages (issue #138). + +A post that @-mentions everyone carries an ``at`` AST node with +``user_id == "all"`` and Feishu does NOT populate ``mentions[]`` for @all — +so the only signal is the rendered text. The flatten renderer must emit the +``@_all`` placeholder so the pipeline's probes (``text_has_mention_all`` / +``resolve_mentions``) fire. +""" + +import json + +import pytest + +from lark_oapi.channel.normalize.pipeline import InboundPipeline, PipelineConfig, PipelineDeps + + +def _msg(msg_type="post", content=None): + return { + "message_id": "om_1", + "create_time": 1000, + "chat_id": "oc_1", + "chat_type": "group", + "message_type": msg_type, + "content": json.dumps(content or {"text": "hi"}, ensure_ascii=False), + "mentions": [], + } + + +def _sender(open_id="ou_sender"): + return {"sender_id": {"open_id": open_id, "user_id": "u1"}, "sender_type": "user"} + + +@pytest.mark.asyncio +async def test_post_mention_all_sets_mentioned_all(): + # Covers both wire shapes: the classic single-locale ``{"content": ...}`` + # post and locale-keyed posts (``{"zh_cn": {...}}``). + for post_content in ( + { + "content": [ + [ + {"tag": "at", "user_id": "all", "user_name": "Everyone"}, + {"tag": "text", "text": " heads up everyone"}, + ] + ] + }, + { + "zh_cn": { + "content": [ + [ + {"tag": "at", "user_id": "all", "user_name": "所有人"}, + {"tag": "text", "text": " 全体注意"}, + ] + ] + } + }, + ): + p = InboundPipeline(PipelineConfig(), PipelineDeps()) + inbound = await p.process( + event_id="e", message_event=_msg(content=post_content), sender=_sender() + ) + assert inbound is not None + assert inbound.mentioned_all is True + # the @_all placeholder resolves to the human-visible form + assert "@all" in inbound.content.text + + +@pytest.mark.asyncio +async def test_post_regular_at_does_not_set_mentioned_all(): + post_content = { + "content": [ + [ + {"tag": "at", "user_id": "ou_user", "user_name": "Alice"}, + {"tag": "text", "text": " hi"}, + ] + ] + } + p = InboundPipeline(PipelineConfig(), PipelineDeps()) + inbound = await p.process( + event_id="e", message_event=_msg(content=post_content), sender=_sender() + ) + assert inbound is not None + assert inbound.mentioned_all is False + assert "@Alice" in inbound.content.text