diff --git a/lark_channel/channel/normalize/converters/post.py b/lark_channel/channel/normalize/converters/post.py index 863217e..735ba3d 100644 --- a/lark_channel/channel/normalize/converters/post.py +++ b/lark_channel/channel/normalize/converters/post.py @@ -1,14 +1,77 @@ """Converter: PostContent → Markdown (headings / bold / italic / code / links).""" import re -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, NamedTuple, Tuple from ...types import PostContent, ResourceDescriptor +from ._utils import attr _AT_MENTION_RE = re.compile(r'(.*?)') _IMAGE_KEY_RE = re.compile(r"!\[(.*?)\]\(([^)]+)\)") +class _Attachment(NamedTuple): + """A validated attachment-zone entry. + + Every field is guaranteed by :func:`_attachment_files`: ``key`` is a + non-empty string, ``name`` is a string (empty when absent or non-string), + and ``is_folder`` is a real bool — so callers can interpolate without + re-checking types. + """ + + key: str + name: str + is_folder: bool + + +def _attachment_files(post: Dict[str, Any]) -> List[_Attachment]: + """Return the usable entries of a post's top-level attachment zone. + + The attachment zone is a *sibling* of the locale documents rather than part + of one: ``files: [{file_key, file_name, is_folder}]``. + + Wire values are untrusted, so every field is narrowed here rather than at + the point of use: a non-string ``file_name`` reaching :func:`attr` would + raise ``AttributeError`` out of the whole normalize pipeline, and a + stringly ``is_folder`` (``"false"``) would hide a real, downloadable file + behind a ```` tag. Entries without a usable key are dropped — + unlike the standalone converters, there is no single attachment here for a + ``[file]`` / ``[folder]`` placeholder to stand in for. + + Callers render each entry via :func:`_render_attachment`. The ``name`` + attribute is omitted when empty, matching ``folder.convert`` and node's + ``post.ts`` (``file.convert`` differs: it always emits ``name=""``). + """ + if not isinstance(post, dict): + return [] + files = post.get("files") + if not isinstance(files, list): + return [] + attachments: List[_Attachment] = [] + for f in files: + if not isinstance(f, dict): + continue + key = f.get("file_key") + if not isinstance(key, str) or not key: + continue + name = f.get("file_name") + attachments.append( + _Attachment( + key=key, + name=name if isinstance(name, str) else "", + is_folder=f.get("is_folder") is True, + ) + ) + return attachments + + +def _render_attachment(att: _Attachment) -> str: + """Render one attachment-zone entry as a ```` / ```` tag.""" + tag = "folder" if att.is_folder else "file" + name_attr = f' name="{attr(att.name)}"' if att.name else "" + return f'<{tag} key="{attr(att.key)}"{name_attr}/>' + + def convert(content: PostContent) -> Tuple[str, List[ResourceDescriptor]]: md, md_resources = _post_to_markdown(content.post) if content.post else (content.text or "", []) resources = _post_resources(content.post) if content.post else [] @@ -38,9 +101,14 @@ def _post_to_markdown( post: Dict[str, Any], drop_open_id: str = "" ) -> Tuple[str, List[ResourceDescriptor]]: docs = _iter_documents(post) - if not docs: + # The attachment zone is a sibling of the locale documents, not part of + # one, so it must be read before the guard below — otherwise a post with + # attachments but no usable locale document would silently drop them from + # the text while still surfacing them as resources. + attachments = _attachment_files(post) + if not docs and not attachments: return "", [] - locale = docs[0] + locale = docs[0] if docs else {} # Choose source paragraphs: prefer content_v2, fallback to content. content_v2 = locale.get("content_v2") @@ -99,6 +167,10 @@ def _post_to_markdown( line = "".join(chunks) if line: lines.append(line) + # Attachment zone renders after the body; resource extraction for it lives + # in _post_resources (files only — folders are tag-only, mirroring + # folder.convert's resources=[]). + lines.extend(_render_attachment(att) for att in attachments) return "\n\n".join(lines).strip(), resources @@ -135,6 +207,12 @@ def add(kind: str, key: Any, *, file_name: Any = None) -> None: add("audio", el.get("file_key")) elif tag == "file": add("file", el.get("file_key"), file_name=el.get("file_name")) + # Attachment zone: files are downloadable resources; folders are rendered + # as tags only (mirrors the standalone folder converter, resources=[]). + for att in _attachment_files(post): + if att.is_folder: + continue + add("file", att.key, file_name=att.name) return resources diff --git a/lark_channel/channel/normalize/registry.py b/lark_channel/channel/normalize/registry.py index 938b31d..7f45d5f 100644 --- a/lark_channel/channel/normalize/registry.py +++ b/lark_channel/channel/normalize/registry.py @@ -60,14 +60,18 @@ 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. + + The top level can also carry non-document siblings alongside the locale + keys — notably the ``files`` attachment zone — so pick the first value that + actually is a document rather than the first key. Keying off position would + otherwise yield ``("", "")`` for ``{"files": [...], "zh_cn": {...}}``, + silently emptying ``PostContent.text`` and with it the pipeline's ``@all`` + probe and ````-tag parsing. """ 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 not isinstance(locale_doc, dict): + locale_doc = next((v for v in post.values() if isinstance(v, dict)), None) + if locale_doc is None: return "", "" title = locale_doc.get("title") or "" lines: List[str] = [] diff --git a/lark_channel/channel/tests/test_flatten.py b/lark_channel/channel/tests/test_flatten.py index 6da5e29..93bbee5 100644 --- a/lark_channel/channel/tests/test_flatten.py +++ b/lark_channel/channel/tests/test_flatten.py @@ -1,5 +1,6 @@ """Tests for flat-string content + resources[] derivation (Node-aligned).""" +from lark_channel.channel.normalize.converters.post import convert_body from lark_channel.channel.normalize.flatten import flatten from lark_channel.channel.types import ( AudioContent, @@ -371,6 +372,178 @@ def test_interactive_walk_handles_deep_card_without_recursion_error(): assert "deep leaf" in t +def test_post_attachment_zone_renders_files_and_folders(): + # Attachment zone lives at the top level of the post JSON, outside the + # locale document: files: [{file_key, file_name, is_folder}]. + post = { + "zh_cn": { + "title": "报告", + "content": [[{"tag": "text", "text": "正文"}]], + }, + "files": [ + {"file_key": "file_a", "file_name": "report.pdf"}, + {"file_key": "file_b"}, + {"file_key": "dir_1", "file_name": "assets", "is_folder": True}, + ], + } + + t, r = flatten(PostContent(post=post)) + + assert "# 报告" in t + assert "正文" in t + assert '' in t + assert '' in t + assert '' in t + # Files are downloadable resources; folders are tag-only. + assert [(x.type, x.file_key, x.file_name) for x in r] == [ + ("file", "file_a", "report.pdf"), + ("file", "file_b", None), + ] + + +def test_post_attachment_zone_ignores_empty_files(): + post = {"zh_cn": {"content": [[{"tag": "text", "text": "hi"}]]}, "files": []} + t, r = flatten(PostContent(post=post)) + assert "hi" in t + assert " ' (仓库惯例), so the key quote becomes a single + # quote inside the attribute value. + assert '' in t + assert '' in t + assert [(x.type, x.file_key, x.file_name) for x in r] == [ + ("file", 'file_a" onmouseover="x', "r.pdf"), + ("file", "file_b", None), + ] + + +def test_post_attachment_zone_in_body_text(): + # convert_body (used for InboundMessage.body_text) must also carry the + # attachment zone. + post = { + "zh_cn": {"content": [[{"tag": "text", "text": "hi"}]]}, + "files": [{"file_key": "file_a", "file_name": "a.pdf"}], + } + body = convert_body(PostContent(post=post), drop_open_id="ou_x") + assert '' in body + + +def test_post_attachment_zone_without_locale_document(): + """A post carrying only an attachment zone must still render it. + + The attachment zone is a sibling of the locale documents, so the + no-usable-document guard must not swallow it — otherwise the text says + nothing while resources[] still offers a downloadable file. + """ + post = {"files": [{"file_key": "f1", "file_name": "solo.pdf"}]} + + t, r = flatten(PostContent(post=post)) + + assert t == '' + assert [(x.type, x.file_key, x.file_name) for x in r] == [("file", "f1", "solo.pdf")] + + +def test_post_attachment_zone_before_locale_key_keeps_plain_text(): + """`files` may precede the locale key on the wire. + + `PostContent.text` feeds the pipeline's @all probe and -tag parsing, so + it must not go empty just because a non-document sibling sorts first. + """ + from lark_channel.channel.normalize.registry import parse_message_content + + content = parse_message_content( + "post", + { + "files": [{"file_key": "f1", "file_name": "a.pdf"}], + "zh_cn": {"title": "T", "content": [[{"tag": "text", "text": "hello"}]]}, + }, + ) + + assert content.title == "T" + assert "hello" in content.text + + +def test_post_attachment_zone_tolerates_malformed_entries(): + """Wire junk must be dropped entry-by-entry, never raise.""" + post = { + "zh_cn": {"content": [[{"tag": "text", "text": "hi"}]]}, + "files": [ + "not-a-dict", + None, + {}, # no file_key + {"file_key": ""}, # empty file_key + {"file_key": 123}, # non-str file_key + {"file_key": "ok", "file_name": 42}, # non-str file_name + ], + } + + t, r = flatten(PostContent(post=post)) + + assert "hi" in t + # Only the one usable entry survives, and the non-str name is dropped + # rather than reaching attr() and raising out of the pipeline. + assert '' in t + assert [(x.type, x.file_key, x.file_name) for x in r] == [("file", "ok", None)] + + +def test_post_attachment_zone_non_list_files_is_ignored(): + post = {"zh_cn": {"content": [[{"tag": "text", "text": "hi"}]]}, "files": "nope"} + t, r = flatten(PostContent(post=post)) + assert t == "hi" + assert r == [] + + +def test_post_attachment_zone_is_folder_requires_real_bool(): + """A stringly `is_folder` must not hide a downloadable file behind .""" + post = {"files": [{"file_key": "f1", "is_folder": "false"}]} + + t, r = flatten(PostContent(post=post)) + + assert t == '' + assert [(x.type, x.file_key) for x in r] == [("file", "f1")] + + +def test_post_attachment_zone_escapes_name_and_key(): + post = { + "files": [{"file_key": 'k"1', "file_name": 'a"b\nc'}], + } + + t, _ = flatten(PostContent(post=post)) + + # attr() maps `"` to `'` and newlines to spaces, so neither field can + # close the attribute and forge a sibling tag. + assert t == "" + + +def test_post_attachment_zone_dedups_against_inline_file_element(): + """Inline `tag:file` wins over an attachment-zone entry with the same key.""" + post = { + "zh_cn": { + "content": [[{"tag": "file", "file_key": "dup", "file_name": "inline.pdf"}]] + }, + "files": [{"file_key": "dup", "file_name": "zone.pdf"}], + } + + _, r = flatten(PostContent(post=post)) + + assert [(x.type, x.file_key, x.file_name) for x in r] == [ + ("file", "dup", "inline.pdf") + ] + + def test_unknown_fallback_uses_raw_text(): t, _ = flatten(UnknownContent(raw={"text": "raw text"})) assert t == "raw text" diff --git a/lark_channel/core/const.py b/lark_channel/core/const.py index b799593..ffeec11 100644 --- a/lark_channel/core/const.py +++ b/lark_channel/core/const.py @@ -1,6 +1,6 @@ # Info PROJECT = "channel-sdk-python" -VERSION = "1.3.0" +VERSION = "1.4.0" # Domain FEISHU_DOMAIN = "https://open.feishu.cn" diff --git a/tests/package_identity/test_import_identity.py b/tests/package_identity/test_import_identity.py index 69d9472..598d277 100644 --- a/tests/package_identity/test_import_identity.py +++ b/tests/package_identity/test_import_identity.py @@ -29,7 +29,7 @@ def test_transport_keepalive_config_imports_from_package_root(): assert KeepaliveConfig is ChannelKeepaliveConfig -def test_release_version_is_1_3_0(): +def test_release_version_is_1_4_0(): from lark_channel.core.const import VERSION - assert VERSION == "1.3.0" + assert VERSION == "1.4.0"