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"