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
40 changes: 40 additions & 0 deletions docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,46 @@ Packaged browser evidence qualifies the local UI path separately. Live Lark
return delivery, installed service adoption, split-root composition and
Goal-less project peer handoff remain open; this checkpoint does not close them.

The existing Lark reply workers now also project committed App-origin user and
answer messages from that exact owner-private Session. Each provider outbox
retains the canonical message identity, frozen text and delivery attempt. The
original Lark input supplies address provenance only: projection never readmits
it or starts model work. Before every write and receipt recovery, Core rechecks
the Session, current binding and audience, and the provider verifies the original
source. Posts identify the App speaker, stay in canonical append order within
that outbox and retain complete long text through the existing rich-post parts.
Distinct canonical messages and parts get distinct provider idempotency keys,
including when their text is identical; callers without that identity retain
their existing receipt digest. Known attempts are read back before continuation;
a started write without a provider locator stays unconfirmed after restart.
Revocation or changed identity prevents further delivery. Disable the existing
connection to revoke it; rollback must preserve its private delivery journals.

Use **Continue original conversation** above to reach this path. Ordinary App
steward history is still a separate entry; it is not silently merged or retargeted.
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.

The implementation decision follows primary-source comparisons, not a claim
that shared context already guarantees two-way delivery:

- [OpenClaw history delta](https://github.com/openclaw/openclaw/blob/b3ba4d622638ac23f64c0cec415bf5bfe81f5cf5/src/gateway/server-methods/chat-history-delta.ts)
resets incomplete/scoped projections; its
[send retry owner](https://github.com/openclaw/openclaw/blob/b3ba4d622638ac23f64c0cec415bf5bfe81f5cf5/src/gateway/server-methods/chat-send-retry.ts)
reconciles already-started execution. These inform recovery, not a Lark bridge.
- [nanobot unified session keys](https://github.com/HKUDS/nanobot/blob/9e9e22d3319678178dba05a76f0ea61064b78ee8/nanobot/session/keys.py)
and [last-destination routing](https://github.com/HKUDS/nanobot/blob/9e9e22d3319678178dba05a76f0ea61064b78ee8/nanobot/agent/loop.py)
share context; a last destination does not replicate a transcript to every surface.
- [mautrix message mappings](https://github.com/mautrix/go/blob/a4237e5b9f8f0e9b35c81d5310615eb70c202cea/bridgev2/database/message.go)
and [portal echo handling](https://github.com/mautrix/go/blob/a4237e5b9f8f0e9b35c81d5310615eb70c202cea/bridgev2/portal.go)
motivate stable transaction/part identities and echo suppression. A Matrix
homeserver/bridge would add separate infrastructure and identity ownership.
LoopX borrows the design, not MPL-2.0 code; the other two snapshots are MIT.

Synthetic product previews: [desktop](../../assets/personal-workspace/private-project-conversations.png),
[narrow](../../assets/personal-workspace/private-project-conversations-narrow.png),
[revoked workspace](../../assets/personal-workspace/private-project-workspace-revoked.png).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,36 @@ Web 来源,通过已有 Core 队列及新鲜来源准入。连接列表独立
真实 Lark 结果回传、安装态服务采用、分离运行根组合及无 Goal 项目的 peer 交办
仍未关闭,不由此检查点代替。

现有 Lark 回复 worker 现在也投递该本人私聊确切 Session 中已提交的 App 用户消息和
回答。每份 provider outbox 保留 canonical 消息身份、冻结正文和发送尝试。原 Lark
输入只提供地址来源证据;投影不会重新受理它或启动模型工作。每次发送和恢复回执前,
Core 重查 Session、当前绑定与受众,provider 核验原来源。消息标明 App 发言者,在
该 outbox 内按 canonical 追加顺序发送,长正文沿用既有富文本分段,保留完整内容。
即使正文相同,不同 canonical 消息及分段也使用不同 provider 幂等键;未提供该身份
的既有调用者保持原回执 digest。恢复先读回已知发送,再继续未发送部分;已经开始但
没有 provider locator 的写入,重启后仍保留未确认。撤权或身份变化阻止后续投递。
关闭既有连接即可撤销授权;回滚须保留私有投递 journal。

通过上述 **继续原会话** 使用该路径。普通 App 管家历史仍是独立入口,不会被静默
合并或重定向。本检查点覆盖文本投影,不覆盖回复/话题关系镜像、附件传输、向 Lark
历史回插旧时间或全渠道广播。App 附件会产生明确的未同步提示,并保留 partial
outbox,不声明完整送达。真实 HTTP/Core 配合合成 provider 的旅程覆盖重复输入、
长正文、重启、回执缺失/未知、撤权及 App 身份替换;安装态 provider 读回另行验收。

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

- [OpenClaw 历史增量](https://github.com/openclaw/openclaw/blob/b3ba4d622638ac23f64c0cec415bf5bfe81f5cf5/src/gateway/server-methods/chat-history-delta.ts)
对不完整/受限投影要求重置;其
[发送重试 owner](https://github.com/openclaw/openclaw/blob/b3ba4d622638ac23f64c0cec415bf5bfe81f5cf5/src/gateway/server-methods/chat-send-retry.ts)
对已经开始的执行先对账。这些机制用于恢复设计,不代表已有 Lark 桥。
- [nanobot 统一会话键](https://github.com/HKUDS/nanobot/blob/9e9e22d3319678178dba05a76f0ea61064b78ee8/nanobot/session/keys.py)
和[最后目的地路由](https://github.com/HKUDS/nanobot/blob/9e9e22d3319678178dba05a76f0ea61064b78ee8/nanobot/agent/loop.py)
可以共享上下文,但最后目的地不等于向每个界面复制完整记录。
- [mautrix 消息映射](https://github.com/mautrix/go/blob/a4237e5b9f8f0e9b35c81d5310615eb70c202cea/bridgev2/database/message.go)
及 [portal 回声处理](https://github.com/mautrix/go/blob/a4237e5b9f8f0e9b35c81d5310615eb70c202cea/bridgev2/portal.go)
支持稳定事务/分段身份和回声抑制的设计判断。采用 Matrix homeserver/桥还会增加
独立基础设施与身份 owner;LoopX 借鉴设计,不复制 MPL-2.0 代码,另两个快照为 MIT。

![合成私聊工作区设置](../../assets/personal-workspace/private-project-conversations.png)
![窄屏私聊设置](../../assets/personal-workspace/private-project-conversations-narrow.png)
![工作区撤权读回](../../assets/personal-workspace/private-project-workspace-revoked.png)
Expand Down
13 changes: 12 additions & 1 deletion loopx/extensions/lark/inbox_reply.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ def _deliver_lark_inbox_outbound(
text: str,
content_format: str = "text",
reply_placement: str | None = None,
delivery_id: str | None = None,
execute: bool = False,
provider_preflight: bool = False,
runner: CommandRunner = _default_runner,
Expand Down Expand Up @@ -412,12 +413,16 @@ def _deliver_lark_inbox_outbound(
)
else "source_thread"
)
if delivery_id is not None and (not isinstance(delivery_id, str)
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}", delivery_id)):
raise ValueError("invalid native delivery identity")
digest = hashlib.sha256(
json.dumps(
{
"message_id": source_message_id,
"placement": placement,
"text": reply_text,
**({"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 {}),
},
Expand Down Expand Up @@ -587,6 +592,7 @@ def verify_source() -> tuple[bool, bool]:
"source_membership_verifier": source_membership_verifier,
"source_authority_verifier": source_authority_verifier,
**({"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})
guidance = None
Expand Down Expand Up @@ -810,6 +816,7 @@ def reply_lark_event_inbox(
text: str,
content_format: str = "text",
reply_placement: str | None = None,
delivery_id: str | None = None,
execute: bool = False,
provider_preflight: bool = False,
runner: CommandRunner = _default_runner,
Expand Down Expand Up @@ -853,6 +860,7 @@ def reply_lark_event_inbox(
text=text,
content_format=content_format,
reply_placement=reply_placement,
delivery_id=delivery_id,
execute=execute,
provider_preflight=provider_preflight,
runner=runner,
Expand All @@ -878,6 +886,7 @@ def verify_lark_inbox_reply(
config_path: str | Path,
message_id: str,
reply_placement: str | None = None,
delivery_id: str | None = None,
text: str,
attempt: Mapping[str, Any],
runner: CommandRunner = _default_runner,
Expand Down Expand Up @@ -922,14 +931,15 @@ def verify_lark_inbox_reply(
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,
reply_placement=reply_placement)
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),
attempt=attempt, resume=resume_parts, arguments={"project": project,
"config_path": config_path, "message_id": message_id, "runner": runner,
"source_membership_verifier": source_membership_verifier,
"source_authority_verifier": source_authority_verifier,
**({"reply_placement": reply_placement} if reply_placement is not None else {}),
**({"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})
Expand All @@ -947,6 +957,7 @@ def verify_lark_inbox_reply(
text=candidate_text,
content_format=content_format,
reply_placement=reply_placement,
delivery_id=delivery_id,
execute=False,
runner=runner,
attachment_keys=attachment_keys,
Expand Down
9 changes: 9 additions & 0 deletions loopx/extensions/lark/post_delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import json
import hashlib
import re
from pathlib import Path
from typing import Any
Expand All @@ -19,6 +20,12 @@
POST_PARTS_LOCATOR = re.compile(r"^lark-parts:([a-f0-9]{64})$")


def _part_identity(arguments, index):
identity = arguments.get("delivery_id")
return ({"delivery_id": hashlib.sha256(f"{identity}:part:{index}".encode()).hexdigest()}
if identity is not None else {})


def _path(project, config_path, intent_digest):
config = load_lark_event_inbox_config(project=project, config_path=config_path)
return (
Expand Down Expand Up @@ -124,6 +131,7 @@ def options(index):
"source_authority_verifier": arguments.get("source_authority_verifier"),
**({"reply_placement": arguments["reply_placement"]}
if arguments.get("reply_placement") is not None else {}),
**_part_identity(arguments, index),
"before_send": (lambda _part_intent: outer_hook(intent_digest))
if outer_hook
else None,
Expand Down Expand Up @@ -249,6 +257,7 @@ def verify_post_parts(*, receipt, intent_digest, attempt, arguments, resume=Fals
source_authority_verifier=arguments.get("source_authority_verifier"),
**({"reply_placement": arguments["reply_placement"]}
if arguments.get("reply_placement") is not None else {}),
**_part_identity(arguments, index),
finalize_reactions=False,
attachment_keys=arguments.get("attachment_keys", ()) if final else (),
attachment_names=arguments.get("attachment_names", ()) if final else (),
Expand Down
Loading
Loading