Skip to content

Commit bca2fd6

Browse files
authored
fix(lark): verify group topic ancestry from raw provider messages (#6012)
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
1 parent cfb6877 commit bca2fd6

2 files changed

Lines changed: 131 additions & 5 deletions

File tree

‎loopx/extensions/lark/private_conversations.py‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,16 @@ def health(self) -> dict[str, dict[str, int]]:
8787
def _binding(self, profile: str) -> dict[str, Any]:
8888
return next(row for row in self.bindings.read()["bindings"] if row["transport_ref"] == profile)
8989

90+
def _group_source_message(self, profile: str, message_id: str) -> Mapping[str, Any] | None:
91+
# The display-oriented mget projection omits reply ancestry. Read the
92+
# original provider envelope for group provenance, including root @.
93+
result = call(self.runner, lark_args(cli_bin=self.cli_bin, profile=profile,
94+
tail=["api", "GET", f"/open-apis/im/v1/messages/{message_id}", "--as", "bot"]))
95+
payload = json_payload(result)
96+
if result.get("returncode") != 0 or payload.get("code", 0) != 0 or payload.get("ok") is False:
97+
return None
98+
return _message(payload, message_id)
99+
90100
def _source_message(self, record: dict[str, Any], *, selected: dict[str, Any] | None = None) -> Mapping[str, Any] | None:
91101
"""Read the exact source under this App, then recheck the Core audience.
92102
@@ -110,10 +120,22 @@ def _source_message(self, record: dict[str, Any], *, selected: dict[str, Any] |
110120
and message.get("chat_id") == event["chat_id"] and isinstance(sender, Mapping)
111121
and sender.get("id") == event["sender_id"] and sender.get("sender_type") == "user"):
112122
if selected["binding"].get("audience") == "group":
123+
canonical = self._group_source_message(record["profile"], event["message_id"])
124+
canonical_sender = canonical.get("sender") if isinstance(canonical, Mapping) else None
125+
if (canonical is None or canonical.get("chat_id") != event["chat_id"]
126+
or not isinstance(canonical_sender, Mapping)
127+
or canonical_sender.get("id") != sender.get("id")
128+
or canonical_sender.get("sender_type") != "user"
129+
or canonical.get("msg_type", canonical.get("message_type"))
130+
!= message.get("msg_type", message.get("message_type"))):
131+
return None
132+
# Keep the existing text/media rendering, but only the
133+
# lossless envelope supplies identity, ancestry and @.
134+
message = {**canonical, "content": message.get("content")}
113135
# Provider readback, never event text, fixes the topic root.
114136
root = str(message.get("root_id") or message["message_id"])
115137
# The compact event projection emits null for absent
116-
# ancestry. mget can enrich the thread id later; that is
138+
# ancestry. Readback can enrich the thread id later; that is
117139
# not a conflict unless the event supplied another id.
118140
# Parent/root ancestry must still agree exactly.
119141
parent = str(message.get("parent_id") or "")
@@ -127,10 +149,7 @@ def _source_message(self, record: dict[str, Any], *, selected: dict[str, Any] |
127149
if root == event["message_id"]:
128150
original: Mapping[str, Any] | None = message
129151
else:
130-
response = call(self.runner, lark_args(cli_bin=self.cli_bin, profile=record["profile"],
131-
tail=["im", "+messages-mget", "--message-ids", root,
132-
"--as", "bot", "--no-reactions", "--format", "json"]))
133-
original = _message(json_payload(response), root) if response.get("returncode") == 0 else None
152+
original = self._group_source_message(record["profile"], root)
134153
from .event_inbox import lark_event_mentions_bot
135154
observation = self.bindings.observe(record["profile"])
136155
if (not original or original.get("chat_id") != event["chat_id"]

‎tests/test_lark_private_conversations.py‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ def __call__(self, args, cwd=None, timeout=None):
6767
elif "+chat-list" in args:
6868
data = {"ok": True, "data": {"chats": [{"chat_id": chat, "name": title}
6969
for chat, title in [("oc_community", "Community trial"), ("oc_second", "Second trial")]]}}
70+
elif "api" in args and "GET" in args:
71+
path = args[args.index("GET") + 1]
72+
assert path.startswith("/open-apis/im/v1/messages/")
73+
message = dict(self.messages[path.rsplit("/", 1)[-1]])
74+
if message.get("msg_type") == "text" and "content" in message:
75+
message["body"] = {"content": json.dumps({"text": message.pop("content")})}
76+
data = {"code": 0, "data": {"items": [message]}}
7077
elif "+messages-mget" in args:
7178
ref = args[args.index("--message-ids") + 1]
7279
message = self.messages[ref]
@@ -364,6 +371,106 @@ def test_group_readback_rejects_supplied_conflicting_ancestry(ordinary, field, v
364371
runtime.close()
365372

366373

374+
def test_group_reply_uses_raw_ancestry_when_display_readback_omits_it(ordinary): # noqa: F811
375+
_, runtime, provider, transport = connect_group(ordinary)
376+
root = provider.topic("display-root", "Explain this project")
377+
reply = provider.topic("display-reply", "Compare its configuration", root=root["message_id"])
378+
379+
def rendered_provider(args, cwd=None, timeout=None):
380+
result = provider(args, cwd, timeout)
381+
if "+messages-mget" in args:
382+
payload = json.loads(result["stdout"])
383+
for message in payload["data"]["items"]:
384+
message = dict(message)
385+
message.pop("root_id", None)
386+
message.pop("parent_id", None)
387+
payload["data"]["items"] = [message]
388+
return {**result, "stdout": json.dumps(payload)}
389+
return result
390+
391+
transport.runner = rendered_provider
392+
try:
393+
assert transport.admit("notes-app", root)["status"] == "durably_accepted"
394+
first = finish_group_turn(runtime, transport, message=root["content"])
395+
assert transport.admit("notes-app", reply)["status"] == "durably_accepted"
396+
second = finish_group_turn(runtime, transport, message=reply["content"])
397+
assert second["session_id"] == first["session_id"]
398+
assert all(topic == root["message_id"] for _, topic, _ in provider.topic_writes)
399+
assert len([text for _, _, text in provider.topic_writes if text == "Runtime response."]) == 2
400+
finally:
401+
runtime.close()
402+
403+
404+
@pytest.mark.parametrize("failure", ["unavailable", "code_error", "wrong_sender", "wrong_chat", "wrong_type", "wrong_root"])
405+
def test_group_raw_readback_failure_cannot_fall_back_to_display_authority(ordinary, failure): # noqa: F811
406+
store, runtime, provider, transport = connect_group(ordinary)
407+
root = provider.topic("raw-root", "Explain this project")
408+
reply = provider.topic("raw-reply", "Compare its configuration", root=root["message_id"])
409+
410+
def conflicting_provider(args, cwd=None, timeout=None):
411+
result = provider(args, cwd, timeout)
412+
if "api" in args and "GET" in args:
413+
if failure == "unavailable":
414+
return {"returncode": 1, "stdout": '{"code":999}', "stderr": ""}
415+
if failure == "code_error":
416+
return {**result, "stdout": json.dumps({**json.loads(result["stdout"]), "code": 999})}
417+
payload = json.loads(result["stdout"])
418+
message = dict(payload["data"]["items"][0])
419+
message.update({"wrong_sender": {"sender": {"id": "ou_other", "sender_type": "user"}},
420+
"wrong_chat": {"chat_id": "oc_other"}, "wrong_type": {"msg_type": "image"},
421+
"wrong_root": {"root_id": "om_other_root"}}[failure])
422+
payload["data"]["items"] = [message]
423+
return {**result, "stdout": json.dumps(payload)}
424+
return result
425+
426+
transport.runner = conflicting_provider
427+
try:
428+
assert transport.admit("notes-app", reply)["status"] == "source_verification_failed"
429+
assert not store.list_sessions() and not list(transport.root.glob("*.json"))
430+
assert not provider.writes and not provider.reaction_creates
431+
finally:
432+
runtime.close()
433+
434+
435+
@pytest.mark.parametrize("kind", ["image", "post"])
436+
def test_group_raw_provenance_preserves_rendered_media_and_original_topic(ordinary, kind): # noqa: F811
437+
from test_lark_private_images import image_runner
438+
from test_chat_image_attachments import PNG_DATA_URL
439+
440+
_, runtime, provider, transport = connect_group(ordinary)
441+
root = provider.topic("media-root", "Explain this project")
442+
media = provider.topic("media-reply", "Inspect this diagram\n![Image](img_example)", root=root["message_id"])
443+
media["message_type"] = kind
444+
provider.messages[media["message_id"]]["msg_type"] = kind
445+
download = image_runner(provider)
446+
447+
def raw_media_provider(args, cwd=None, timeout=None):
448+
result = download(args, cwd, timeout)
449+
if "api" in args and "GET" in args:
450+
payload = json.loads(result["stdout"])
451+
message = payload["data"]["items"][0]
452+
if message.get("msg_type") == kind:
453+
message.pop("content")
454+
body = {"image_key": "img_example"} if kind == "image" else {
455+
"zh_cn": {"title": "", "content": [[{"tag": "text", "text": "Inspect this diagram"},
456+
{"tag": "img", "image_key": "img_example"}]]}}
457+
message["body"] = {"content": json.dumps(body)}
458+
return {**result, "stdout": json.dumps(payload)}
459+
return result
460+
461+
transport.runner = raw_media_provider
462+
try:
463+
assert transport.admit("notes-app", root)["status"] == "durably_accepted"
464+
first = finish_group_turn(runtime, transport, message=root["content"])
465+
assert transport.admit("notes-app", media)["status"] == "durably_accepted"
466+
second = finish_group_turn(runtime, transport, message="Inspect this diagram\n[图片 1]")
467+
assert second["session_id"] == first["session_id"]
468+
assert second["attachments"][0]["data_url"] == PNG_DATA_URL
469+
assert all(topic == root["message_id"] for _, topic, _ in provider.topic_writes)
470+
finally:
471+
runtime.close()
472+
473+
367474
def test_group_status_and_recipient_commands_cannot_expose_private_sessions(ordinary): # noqa: F811
368475
store, runtime, provider, transport = connect_group(ordinary)
369476
try:

0 commit comments

Comments
 (0)