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
22 changes: 16 additions & 6 deletions docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,12 +262,22 @@ connection to revoke it; rollback must preserve its private delivery journals.

Use **Continue original conversation** or the explicitly selected App steward
entry above to reach this path. Old ordinary history is retained separately.
This checkpoint covers text projection, not reply/thread relation mirroring,
attachment transfer, retroactive insertion into Lark chronology or all-channel
broadcast. An App attachment produces an explicit unsynchronized notice and a
partial outbox, never a full-delivery claim. Synthetic real-HTTP/Core journeys
cover repeated inputs, long text, restart, missing/unknown receipts, revocation
and provider replacement. Installed provider readback remains a separate gate.
This checkpoint covers text and bounded App image projection, not reply/thread
relation mirroring, retroactive insertion into Lark chronology or all-channel
broadcast. Canonical App images reuse the existing resource uploader and post
sender, retaining caption and ordered inline images in the original private
chat. Long captions keep images on the final concrete post only. Every upload
and send rechecks the original source and current Core message/grant. Sends and
edits check again after provider preview, before recording the write intent;
each continued or resumed rich part uses that same boundary. Completion
also requires independent image-byte readback. Failed verification recovers the
same message receipt without another upload or send. An upload whose provider
acknowledgement is unknown, or a started send without a message locator, stays
unconfirmed after restart. Earlier delivered attachment placeholders retain
their frozen receipts and partial status; upgrades do not silently replay them.
Synthetic real-HTTP/Core journeys cover repeated inputs, complete images and
long captions, restart, missing/unknown receipts, byte mismatch, revocation and
provider replacement. Installed provider readback remains a separate gate.

The implementation decision follows primary-source comparisons, not a claim
that shared context already guarantees two-way delivery:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,17 @@ Core 重查 Session、当前绑定与受众,provider 核验原来源。消息
关闭既有连接即可撤销授权;回滚须保留私有投递 journal。

通过上述 **继续原会话** 或明确选定的 App 管家入口使用该路径;旧普通历史继续
独立保留。本检查点覆盖文本投影,不覆盖回复/话题关系镜像、附件传输、向 Lark
历史回插旧时间或全渠道广播。App 附件会产生明确的未同步提示,并保留 partial
outbox,不声明完整送达。真实 HTTP/Core 配合合成 provider 的旅程覆盖重复输入、
长正文、重启、回执缺失/未知、撤权及 App 身份替换;安装态 provider 读回另行验收。
独立保留。本检查点覆盖文本及有界 App 图片投影,不覆盖回复/话题关系镜像、向
Lark 历史回插旧时间或全渠道广播。Core 中的 App 图片复用既有资源上传与 post
发送器,正文及有序行内图片留在原私聊;长正文只在最后一个实际分段携带图片。
每次上传和发送均重新核验原来源、当前 Core 消息与授权。发送和编辑在 provider
预检返回后、记录写入意图前再次检查;每个继续或恢复的富文本分段复用该边界。
完成还须独立读回图片
字节。验证失败时沿同一消息回执恢复,不再次上传或发送。上传确认未知,或发送
已开始但缺少消息定位符时,重启后仍保留未确认。以前已发送的附件占位说明保留
冻结回执及 partial 状态,升级不自动重放。真实 HTTP/Core 配合合成 provider 的
旅程覆盖重复输入、完整图片和长正文、重启、回执缺失/未知、字节不符、撤权及
App 身份替换;安装态 provider 读回另行验收。

实现判断来自一手源码比较,不把共享上下文视为双向送达已经成立:

Expand Down
25 changes: 17 additions & 8 deletions loopx/extensions/lark/inbox_reply.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ def _deliver_lark_inbox_outbound(
finalize_reactions: bool = True,
attachment_keys: tuple[str, ...] = (),
attachment_names: tuple[str, ...] = (),
image_keys: tuple[str, ...] = (),
) -> dict[str, Any]:
"""Deliver through one inbox-configured bot with exact provider readback.

Expand Down Expand Up @@ -377,6 +378,9 @@ def _deliver_lark_inbox_outbound(
if attachment_keys and (not markdown or len(attachment_keys) > 4
or any(not re.fullmatch(r"file_[A-Za-z0-9_-]{1,240}", key) for key in attachment_keys)):
raise ValueError("result files require bounded post attachment keys")
if image_keys and (not markdown or len(image_keys) > 4
or any(not re.fullmatch(r"img_[A-Za-z0-9_-]{1,240}", key) for key in image_keys)):
raise ValueError("App images require bounded post image keys")
reply_text = normalize_lark_outbound_text(
text,
limit=None if source_event is not None else short_message_limit,
Expand Down Expand Up @@ -425,6 +429,7 @@ def _deliver_lark_inbox_outbound(
**({"delivery_id": delivery_id} if delivery_id is not None else {}),
**({"content_format": "markdown"} if markdown else {}),
**({"attachment_keys": attachment_keys} if attachment_keys else {}),
**({"image_keys": image_keys} if image_keys else {}),
},
ensure_ascii=False,
sort_keys=True,
Expand All @@ -444,7 +449,7 @@ def _deliver_lark_inbox_outbound(

base = ["lark-cli", "--profile", profile]
content_args = (
["--msg-type", "post", "--content", lark_markdown_post_content(reply_text)]
["--msg-type", "post", "--content", lark_markdown_post_content(reply_text, image_keys=image_keys)]
if markdown else ["--text", reply_text]
)
if attachment_keys:
Expand Down Expand Up @@ -542,7 +547,7 @@ def verify_source() -> tuple[bool, bool]:
provider_preview_verified = bool(
preview.get("returncode") == 0
and (
lark_markdown_preview_matches(text=reply_text, payload=_json_object(preview.get("stdout")), attachment_keys=attachment_keys)
lark_markdown_preview_matches(text=reply_text, payload=_json_object(preview.get("stdout")), attachment_keys=attachment_keys, image_keys=image_keys)
if markdown else lark_provider_preview_matches_outbound(
outbound_text=reply_text, payload=_json_object(preview.get("stdout")),
)
Expand Down Expand Up @@ -594,7 +599,7 @@ def verify_source() -> tuple[bool, bool]:
**({"reply_placement": reply_placement} if reply_placement is not None else {}),
**({"delivery_id": delivery_id} if delivery_id is not None else {}),
"finalize_reactions": finalize_reactions,
"attachment_keys": attachment_keys, "attachment_names": attachment_names})
"attachment_keys": attachment_keys, "attachment_names": attachment_names, "image_keys": image_keys})
guidance = None
if before_send is not None:
# Bind review to destination/profile as well as content and placement.
Expand Down Expand Up @@ -747,7 +752,7 @@ def verify_source() -> tuple[bool, bool]:
and readback_message is not None
and (reply_placement is None or _private_root_readback_matches(readback_message, chat_id))
and (lark_markdown_readback_matches(text=reply_text, message=readback_message, attachment_keys=attachment_keys,
attachment_names=attachment_names)
attachment_names=attachment_names, image_keys=image_keys)
if markdown else lark_readback_matches_outbound(
outbound_text=reply_text,
message=readback_message,
Expand Down Expand Up @@ -829,6 +834,7 @@ def reply_lark_event_inbox(
finalize_reactions: bool = True,
attachment_keys: tuple[str, ...] = (),
attachment_names: tuple[str, ...] = (),
image_keys: tuple[str, ...] = (),
) -> dict[str, Any]:
"""Reply with the explicit inbox-configured bot and placement policy.

Expand Down Expand Up @@ -873,6 +879,7 @@ def reply_lark_event_inbox(
finalize_reactions=finalize_reactions,
attachment_keys=attachment_keys,
attachment_names=attachment_names,
image_keys=image_keys,
)

result.setdefault("content_format", "markdown" if content_format == "markdown"
Expand All @@ -895,6 +902,7 @@ def verify_lark_inbox_reply(
finalize_reactions: bool = True,
attachment_keys: tuple[str, ...] = (),
attachment_names: tuple[str, ...] = (),
image_keys: tuple[str, ...] = (),
resume_parts: bool = False,
before_send: Callable[[str], Mapping[str, Any]] | None = None,
) -> dict[str, Any]:
Expand Down Expand Up @@ -930,7 +938,7 @@ def verify_lark_inbox_reply(
chat_id = str(reply_config.get("chat_id") or "")
if batch:
original = reply_lark_event_inbox(project=project, config_path=config_path,
message_id=message_id, text=text, content_format="markdown", attachment_keys=attachment_keys,
message_id=message_id, text=text, content_format="markdown", attachment_keys=attachment_keys, image_keys=image_keys,
reply_placement=reply_placement, delivery_id=delivery_id)
receipt = original["idempotency_key"]
return verify_post_parts(receipt=receipt, intent_digest=_intent_digest(profile, chat_id, receipt),
Expand All @@ -942,10 +950,10 @@ def verify_lark_inbox_reply(
**({"delivery_id": delivery_id} if delivery_id is not None else {}),
"before_send": before_send, "finalize_reactions": finalize_reactions,
"text": normalize_lark_outbound_text(text, limit=None, preserve_format=True),
"attachment_keys": attachment_keys, "attachment_names": attachment_names})
"attachment_keys": attachment_keys, "attachment_names": attachment_names, "image_keys": image_keys})
preview = None
candidates = [(text, "markdown")]
if not attachment_keys:
if not attachment_keys and not image_keys:
# Historical text receipts are read-only compatibility, never a new
# downgrade from an oversized Markdown reply.
candidates.append((text, "text"))
Expand All @@ -961,6 +969,7 @@ def verify_lark_inbox_reply(
execute=False,
runner=runner,
attachment_keys=attachment_keys,
image_keys=image_keys,
)
receipt = str(candidate.get("idempotency_key") or "")
if (
Expand Down Expand Up @@ -1037,7 +1046,7 @@ def verify_lark_inbox_reply(
}
verified = (
lark_markdown_readback_matches(text=reply_text, message=message, attachment_keys=attachment_keys,
attachment_names=attachment_names)
attachment_names=attachment_names, image_keys=image_keys)
if markdown
else lark_readback_matches_outbound(
outbound_text=reply_text,
Expand Down
6 changes: 4 additions & 2 deletions loopx/extensions/lark/post_delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ def options(index):
"attachment_names": arguments.get("attachment_names", ())
if final
else (),
"image_keys": arguments.get("image_keys", ()) if final else (),
}

result = deliver_manager_reply_parts(
Expand All @@ -161,7 +162,7 @@ def options(index):
int(state.get("delivery_part_stall_count") or 0) + 1
)
notice_options = options(0)
notice_options.update(attachment_keys=(), attachment_names=())
notice_options.update(attachment_keys=(), attachment_names=(), image_keys=())
notice = deliver_stall_notice(
delivery_state=state,
delivery_path=path,
Expand Down Expand Up @@ -261,6 +262,7 @@ def verify_post_parts(*, receipt, intent_digest, attempt, arguments, resume=Fals
finalize_reactions=False,
attachment_keys=arguments.get("attachment_keys", ()) if final else (),
attachment_names=arguments.get("attachment_names", ()) if final else (),
image_keys=arguments.get("image_keys", ()) if final else (),
)
if not result.get("reply_verified"):
return result
Expand All @@ -286,7 +288,7 @@ def verify_post_parts(*, receipt, intent_digest, attempt, arguments, resume=Fals


def attachment_reply(*, project, config_path, attempt, text):
"""Files belong to the last concrete post, never to the composite locator."""
"""Resources belong to the last concrete post, never the composite locator."""
match = POST_PARTS_LOCATOR.fullmatch(str(attempt.get("message_ref") or ""))
if not match:
return text, attempt
Expand Down
3 changes: 3 additions & 0 deletions loopx/extensions/lark/presentation/markdown_parts.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ def post_request_bytes(text: str, template: Mapping[str, Any]) -> int:
content.update(
{key: value for key, value in authored.items() if key != "zh_cn"}
)
locale = authored.get("zh_cn")
if isinstance(locale, Mapping) and isinstance(locale.get("content"), list):
content["zh_cn"] = {**locale, "content": [content["zh_cn"]["content"][0], *locale["content"][1:]]}
body = {
**template,
"content": json.dumps(content, ensure_ascii=False, separators=(",", ":")),
Expand Down
42 changes: 30 additions & 12 deletions loopx/extensions/lark/presentation/markdown_post.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,17 +129,19 @@ def _normalize_strong_line(line: str) -> str:
return "".join(chunks)


def lark_markdown_post_content(text: str) -> str:
def lark_markdown_post_content(text: str, *, image_keys: tuple[str, ...] = ()) -> str:
"""Render authored Markdown without CLI image fetching or link rewriting."""
text = normalize_lark_markdown_emphasis(text)
return json.dumps(
{"zh_cn": {"content": [[{"tag": "md", "text": text}]]}},
{"zh_cn": {"content": [[{"tag": "md", "text": text}],
*[[{"tag": "img", "image_key": key}] for key in image_keys]]}},
ensure_ascii=False,
separators=(",", ":"),
)


def _single_markdown_post(value: Any, attachment_keys: tuple[str, ...] = ()) -> str | None:
def _single_markdown_post(value: Any, attachment_keys: tuple[str, ...] = (),
image_keys: tuple[str, ...] = ()) -> str | None:
if isinstance(value, str):
try:
value = json.loads(value)
Expand All @@ -157,7 +159,9 @@ def _single_markdown_post(value: Any, attachment_keys: tuple[str, ...] = ()) ->
if locale.get("title", "") != "":
return None
rows = locale.get("content")
if not isinstance(rows, list) or len(rows) != 1:
if not isinstance(rows, list) or len(rows) != 1 + len(image_keys):
return None
if rows[1:] != [[{"tag": "img", "image_key": key}] for key in image_keys]:
return None
row = rows[0]
if not isinstance(row, list) or len(row) != 1:
Expand All @@ -170,7 +174,8 @@ def _single_markdown_post(value: Any, attachment_keys: tuple[str, ...] = ()) ->
)


def lark_markdown_preview_matches(*, text: str, payload: Mapping[str, Any], attachment_keys: tuple[str, ...] = ()) -> bool:
def lark_markdown_preview_matches(*, text: str, payload: Mapping[str, Any],
attachment_keys: tuple[str, ...] = (), image_keys: tuple[str, ...] = ()) -> bool:
data = payload.get("data")
calls = payload.get("api")
if calls is None and isinstance(data, Mapping):
Expand All @@ -182,13 +187,13 @@ def lark_markdown_preview_matches(*, text: str, payload: Mapping[str, Any], atta
return (
isinstance(body, Mapping)
and body.get("msg_type") == "post"
and _single_markdown_post(body.get("content"), attachment_keys)
and _single_markdown_post(body.get("content"), attachment_keys, image_keys)
== normalize_lark_markdown_emphasis(text)
)


def _readback_post(message: Mapping[str, Any], attachment_keys: tuple[str, ...],
attachment_names: tuple[str, ...]) -> tuple[str, tuple[str, ...]] | None:
attachment_names: tuple[str, ...], image_keys: tuple[str, ...]) -> tuple[str, tuple[str, ...]] | None:
if message.get("msg_type", message.get("message_type")) != "post":
return None
if message.get("mentions") not in (None, []):
Expand All @@ -207,7 +212,7 @@ def _readback_post(message: Mapping[str, Any], attachment_keys: tuple[str, ...],
if isinstance(actual, Mapping) and attachment_keys:
files = actual.get("files")
keys = tuple(file.get("key") for file in files if isinstance(file, Mapping)) if isinstance(files, list) else ()
actual = _single_markdown_post(actual, keys)
actual = _single_markdown_post(actual, keys, image_keys)
else:
if attachment_keys:
# The CLI renders the post's attachment zone as trailing file tags.
Expand All @@ -220,6 +225,18 @@ def _readback_post(message: Mapping[str, Any], attachment_keys: tuple[str, ...],
return None
files = [tag.attrib for tag in tags]
actual = "\n".join(lines[:-len(attachment_keys)]).rstrip()
if image_keys:
# The CLI renders each inline image as its own Markdown line.
# Consume only the exact ordered resource keys, never a caption
# containing a similar image token or a missing/extra image.
lines = actual.rstrip().splitlines()
images = [re.fullmatch(r"!\[Image\]\((img_[A-Za-z0-9_-]{1,240})\)", line)
for line in lines[-len(image_keys):]]
if len(images) != len(image_keys) or any(match is None for match in images):
return None
if tuple(match[1] for match in images) != image_keys:
return None
actual = "\n".join(lines[:-len(image_keys)]).rstrip()
if not isinstance(actual, str) or not isinstance(files, list) or len(files) != len(attachment_keys):
return None
keys = []
Expand All @@ -242,9 +259,10 @@ def _readback_post(message: Mapping[str, Any], attachment_keys: tuple[str, ...],

def lark_markdown_readback_attachment_keys(*, text: str, message: Mapping[str, Any],
attachment_keys: tuple[str, ...] = (),
attachment_names: tuple[str, ...] = ()) -> tuple[str, ...] | None:
attachment_names: tuple[str, ...] = (),
image_keys: tuple[str, ...] = ()) -> tuple[str, ...] | None:
"""Resolve keys only from the exact post; this alone never verifies files."""
parsed = _readback_post(message, attachment_keys, attachment_names)
parsed = _readback_post(message, attachment_keys, attachment_names, image_keys)
if parsed is None:
return None
actual, keys = parsed
Expand All @@ -255,7 +273,7 @@ def lark_markdown_readback_attachment_keys(*, text: str, message: Mapping[str, A

def lark_markdown_readback_matches(*, text: str, message: Mapping[str, Any],
attachment_keys: tuple[str, ...] = (),
attachment_names: tuple[str, ...] = ()) -> bool:
attachment_names: tuple[str, ...] = (), image_keys: tuple[str, ...] = ()) -> bool:
"""Accept the raw post or CLI's md text, never a plain-text lookalike."""
return lark_markdown_readback_attachment_keys(text=text, message=message,
attachment_keys=attachment_keys, attachment_names=attachment_names) is not None
attachment_keys=attachment_keys, attachment_names=attachment_names, image_keys=image_keys) is not None
Loading
Loading