Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 81 additions & 3 deletions lark_channel/channel/normalize/converters/post.py
Original file line number Diff line number Diff line change
@@ -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'<at(\s+)user_id(\s*)=(\s*)"(.*?)">(.*?)</at>')
_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 ``<folder/>`` 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 ``<file/>`` / ``<folder/>`` 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 []
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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


Expand Down
14 changes: 9 additions & 5 deletions lark_channel/channel/normalize/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<at>``-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] = []
Expand Down
173 changes: 173 additions & 0 deletions lark_channel/channel/tests/test_flatten.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 '<file key="file_a" name="report.pdf"/>' in t
assert '<file key="file_b"/>' in t
assert '<folder key="dir_1" name="assets"/>' 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 "<file" not in t
assert r == []


def test_post_attachment_zone_escapes_key_and_handles_non_string_name():
# key with a quote must be escaped so it cannot forge tag attributes;
# non-string file_name degrades to no name attribute without throwing.
post = {
"zh_cn": {"content": [[{"tag": "text", "text": "hi"}]]},
"files": [
{"file_key": 'file_a" onmouseover="x', "file_name": "r.pdf"},
{"file_key": "file_b", "file_name": 123},
],
}
t, r = flatten(PostContent(post=post))
# python attr() maps " -> ' (仓库惯例), so the key quote becomes a single
# quote inside the attribute value.
assert '<file key="file_a\' onmouseover=\'x" name="r.pdf"/>' in t
assert '<file key="file_b"/>' 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 '<file key="file_a" name="a.pdf"/>' 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 == '<file key="f1" name="solo.pdf"/>'
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 <at>-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 '<file key="ok"/>' 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 <folder/>."""
post = {"files": [{"file_key": "f1", "is_folder": "false"}]}

t, r = flatten(PostContent(post=post))

assert t == '<file key="f1"/>'
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 == "<file key=\"k'1\" name=\"a'b c\"/>"


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"
Expand Down
2 changes: 1 addition & 1 deletion lark_channel/core/const.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Info
PROJECT = "channel-sdk-python"
VERSION = "1.3.0"
VERSION = "1.4.0"

# Domain
FEISHU_DOMAIN = "https://open.feishu.cn"
Expand Down
4 changes: 2 additions & 2 deletions tests/package_identity/test_import_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading