Skip to content

Commit 41a0586

Browse files
authored
Fix Lark chat-wide routing and contextual inbox replies (#3555)
* fix(lark): preserve contextual inbox replies Signed-off-by: huangruiteng <huangrt01@163.com> * docs(lark): describe chat-wide inbox behavior Signed-off-by: huangruiteng <huangrt01@163.com> --------- Signed-off-by: huangruiteng <huangrt01@163.com>
1 parent a47dbee commit 41a0586

11 files changed

Lines changed: 438 additions & 94 deletions

File tree

‎apps/presentation/dashboard/src/data/chat.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,6 +1000,7 @@ const larkTopicEventRejectionReasons = [
10001000
"binding_unavailable",
10011001
"chat_mismatch",
10021002
"topic_mismatch",
1003+
"route_ambiguous",
10031004
"self_message",
10041005
"invalid_routing_state",
10051006
"not_addressed",

‎apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,15 @@ function larkConnectionHealth(connection: LarkGoalConnection): { label: string;
8787
}
8888
if (
8989
connection.health_error_code === "lark_event_route_mismatch"
90-
|| ["chat_mismatch", "topic_mismatch"].includes(connection.last_event_reason ?? "")
90+
|| ["chat_mismatch", "topic_mismatch", "route_ambiguous"].includes(connection.last_event_reason ?? "")
9191
) {
9292
return {
93-
label: "消息未匹配当前 Goal Topic",
94-
detail: "事件来自其他群聊或 Topic。请重新选择群聊并连接该 Goal,然后发送一条新的 @ 消息。",
93+
label: connection.last_event_reason === "route_ambiguous"
94+
? "消息匹配多个 Goal"
95+
: "消息未匹配当前 Goal Topic",
96+
detail: connection.last_event_reason === "route_ambiguous"
97+
? "同一 Bot 和群聊存在多个完整群捕获连接。请让每个 Agent 使用独立 Bot,或只保留一个完整群连接。"
98+
: "事件来自其他群聊或 Topic。请重新选择群聊并连接该 Goal,然后发送一条新的 @ 消息。",
9599
ready: false,
96100
};
97101
}

‎loopx/extensions/lark/docs/lark-event-inbox.md‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,12 @@ configuration or host state and must not enter public LoopX packets.
8686
`capture_scope` defaults to `addressed_only` for compatibility. Drain output
8787
reports `thread_complete=false` and a coverage warning for that mode. For
8888
`configured_chat_all`, the collector's jq filter should select the configured
89-
chat only; do not add a content-level `@bot` predicate.
89+
chat only; do not add a content-level `@bot` predicate. A Goal Topic root is
90+
presentation and reply context, not an additional ingress filter for
91+
`configured_chat_all`: new topics and replies in the same configured chat must
92+
remain visible to the bound Agent. When more than one chat-wide Goal route is
93+
eligible for the same Bot target, routing fails closed instead of choosing one
94+
by iteration order.
9095

9196
Optional source-thread replies are a separate, default-off boundary. Bind an
9297
explicit non-default bot profile to the same local-private chat. An
@@ -297,8 +302,13 @@ and interprets the source event before deciding the durable effect or reply; the
297302
summary is a scheduling signal, not semantic authority.
298303

299304
For a direct question, explicit bot mention, or verified reply to the configured
300-
bot, write the requested durable effect first, preview one concise source-thread
301-
reply, execute it, require readback, and only then ACK:
305+
bot, write the requested durable effect first, preview one concise reply,
306+
execute it, require readback, and only then ACK. New Goal Topic inbox configs use
307+
`reply.placement_policy=source_context`: a top-level chat request receives a new
308+
top-level chat response, while an event already inside a topic receives a reply
309+
inside that source topic. Existing configs without the field retain the legacy
310+
`source_thread` policy. `reply.editorial_style=bullet_points_preferred` projects
311+
an operator hint for structured replies; the command preserves line breaks.
302312

303313
```bash
304314
loopx lark-inbox reply \
@@ -315,10 +325,10 @@ loopx lark-inbox reply \
315325
--execute
316326
```
317327

318-
The command uses an idempotency key derived from the source message and reply
319-
text, sends with `--reply-in-thread`, and reads the created message back through
320-
the same configured profile. Lifecycle reactions are removed only after that
321-
readback succeeds. A sent reply whose reaction cleanup fails returns
328+
The command uses an idempotency key derived from the source message, resolved
329+
placement, and reply text, then reads the created message back through the same
330+
configured profile. Lifecycle reactions are removed only after that readback
331+
succeeds. A sent reply whose reaction cleanup fails returns
322332
`sent_verified_cleanup_pending`; retry `lark-inbox reaction-complete` before
323333
acknowledging the source:
324334

‎loopx/extensions/lark/event_inbox.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
SAFE_PROFILE_PATTERN = re.compile(r"[A-Za-z0-9._-]{1,100}")
2222
CHAT_ID_PATTERN = re.compile(r"oc_[A-Za-z0-9_-]+")
2323
REACTION_EMOJI_PATTERN = re.compile(r"[A-Za-z0-9_]{1,64}")
24+
REPLY_PLACEMENT_POLICIES = {"source_thread", "source_context"}
25+
REPLY_EDITORIAL_STYLES = {"concise", "bullet_points_preferred"}
2426
LARK_OPERATOR_INBOX_SOURCE_CONTRACT = OperatorInboxSourceContract(
2527
config_schema_version=CONFIG_SCHEMA_VERSION,
2628
event_schema_version=EVENT_SCHEMA_VERSION,
@@ -91,6 +93,18 @@ def load_lark_event_inbox_config(
9193
str(reply_payload.get("bot_display_name") or "").split()
9294
)[:100]
9395
chat_id = str(reply_payload.get("chat_id") or "").strip()
96+
placement_policy = str(
97+
reply_payload.get("placement_policy") or "source_thread"
98+
).strip()
99+
editorial_style = str(reply_payload.get("editorial_style") or "concise").strip()
100+
if placement_policy not in REPLY_PLACEMENT_POLICIES:
101+
raise ValueError(
102+
"lark inbox placement_policy must be source_thread or source_context"
103+
)
104+
if editorial_style not in REPLY_EDITORIAL_STYLES:
105+
raise ValueError(
106+
"lark inbox editorial_style must be concise or bullet_points_preferred"
107+
)
94108
received_reaction_emoji = str(
95109
reply_payload.get("received_reaction_emoji") or ""
96110
).strip()
@@ -141,6 +155,8 @@ def load_lark_event_inbox_config(
141155
"sender_identity": sender_identity,
142156
"bot_display_name": bot_display_name,
143157
"chat_id": chat_id,
158+
"placement_policy": placement_policy,
159+
"editorial_style": editorial_style,
144160
"received_reaction_emoji": received_reaction_emoji,
145161
"processing_reaction_emoji": processing_reaction_emoji,
146162
},
@@ -384,6 +400,10 @@ def inspect_lark_event_inbox(
384400
"configured": True,
385401
"capture_scope": config["capture_scope"],
386402
"thread_complete": config["thread_complete"],
403+
"reply_guidance": {
404+
"placement_policy": config["reply"]["placement_policy"],
405+
"editorial_style": config["reply"]["editorial_style"],
406+
},
387407
"coverage_warning": (
388408
None
389409
if config["thread_complete"]
@@ -401,7 +421,8 @@ def inspect_lark_event_inbox(
401421
"For an actionable item, first run `loopx lark-inbox processing` for "
402422
"its message_id, then translate it into a todo, vision correction, PR "
403423
"update, or no-follow-up rationale. Send and verify any required reply "
404-
"before acknowledging the message_id. If no reply is required, run "
424+
"before acknowledging the message_id. Follow reply_guidance for "
425+
"placement and editorial style. If no reply is required, run "
405426
"`loopx lark-inbox reaction-complete` before acknowledging it."
406427
),
407428
}

‎loopx/extensions/lark/goal_topic_connections.py‎

Lines changed: 99 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ class LarkTopicEventDecisionReason(str, Enum):
9999
BINDING_UNAVAILABLE = "binding_unavailable"
100100
CHAT_MISMATCH = "chat_mismatch"
101101
TOPIC_MISMATCH = "topic_mismatch"
102+
ROUTE_AMBIGUOUS = "route_ambiguous"
102103
SELF_MESSAGE = "self_message"
103104
INVALID_ROUTING_STATE = "invalid_routing_state"
104105
NOT_ADDRESSED = "not_addressed"
@@ -825,6 +826,7 @@ def list_lark_connections(
825826
"binding_unavailable",
826827
"chat_mismatch",
827828
"topic_mismatch",
829+
"route_ambiguous",
828830
}
829831
):
830832
event_blocker = "lark_event_route_mismatch"
@@ -1034,7 +1036,7 @@ def decide_lark_topic_event(
10341036
}
10351037
has_binding = False
10361038
matched_chat = False
1037-
matched_topic = False
1039+
candidates: list[dict[str, Any]] = []
10381040
for goal_id, payload in binding_payloads.items():
10391041
binding = binding_for_goal(payload, goal_id)
10401042
if not binding or binding.get("enabled") is not True:
@@ -1045,27 +1047,14 @@ def decide_lark_topic_event(
10451047
if target is None:
10461048
continue
10471049
channel = target.get("channel") if isinstance(target.get("channel"), Mapping) else {}
1050+
if str(channel.get("chat_id") or "") != chat_id:
1051+
continue
1052+
matched_chat = True
10481053
identity = target.get("identity") if isinstance(target.get("identity"), Mapping) else {}
10491054
topic = binding.get("topic") if isinstance(binding.get("topic"), Mapping) else {}
10501055
binding_channel = binding.get("channel") if isinstance(binding.get("channel"), Mapping) else {}
10511056
topic_root = str(topic.get("root_message_id") or binding_channel.get("pinned_message_id") or "")
10521057
routing = binding.get("routing") if isinstance(binding.get("routing"), Mapping) else {}
1053-
if str(channel.get("chat_id") or "") != chat_id:
1054-
continue
1055-
matched_chat = True
1056-
if topic_root != root_id:
1057-
continue
1058-
matched_topic = True
1059-
sender_id = str(event.get("sender_id") or "")
1060-
if sender_id and sender_id in {
1061-
str(identity.get("bot_app_id") or ""),
1062-
str(identity.get("bot_open_id") or ""),
1063-
}:
1064-
return {
1065-
"matched": False,
1066-
"reason": LarkTopicEventDecisionReason.SELF_MESSAGE.value,
1067-
"route": None,
1068-
}
10691058
try:
10701059
capture_scope = _routing_value(
10711060
CaptureScope,
@@ -1108,57 +1097,103 @@ def decide_lark_topic_event(
11081097
"reason": LarkTopicEventDecisionReason.INVALID_ROUTING_STATE.value,
11091098
"route": None,
11101099
}
1111-
if capture_scope != "configured_chat_all" and not is_event_addressed_to_bot(
1112-
event, identity
1113-
):
1114-
return {
1115-
"matched": False,
1116-
"reason": LarkTopicEventDecisionReason.NOT_ADDRESSED.value,
1117-
"route": None,
1100+
candidates.append(
1101+
{
1102+
"goal_id": goal_id,
1103+
"binding": binding,
1104+
"identity": identity,
1105+
"target_ref": target_ref,
1106+
"topic_root": topic_root,
1107+
"capture_scope": capture_scope,
1108+
"ingress_mode": ingress_mode,
1109+
"reply_mode": reply_mode,
1110+
"routing": routing,
1111+
"connector": connector,
11181112
}
1119-
route = {
1120-
"app_ref": str(identity.get("sender_profile") or "default"),
1121-
"goal_id": goal_id,
1122-
"message_id": message_id,
1123-
"reply_mode": reply_mode,
1124-
"target_ref": target_ref,
1125-
"topic_root_message_id": topic_root,
1113+
)
1114+
1115+
exact_topic_candidates = [
1116+
candidate for candidate in candidates if candidate["topic_root"] == root_id
1117+
]
1118+
eligible = exact_topic_candidates or [
1119+
candidate
1120+
for candidate in candidates
1121+
if candidate["capture_scope"] == CaptureScope.CONFIGURED_CHAT_ALL.value
1122+
]
1123+
if len(eligible) > 1:
1124+
return {
1125+
"matched": False,
1126+
"reason": LarkTopicEventDecisionReason.ROUTE_AMBIGUOUS.value,
1127+
"route": None,
11261128
}
1127-
if ingress_mode != "direct_session" or binding.get("agent_id"):
1128-
route.update(
1129-
{
1130-
"agent_id": str(binding.get("agent_id") or ""),
1131-
"session_id": str(binding.get("session_id") or ""),
1132-
"capture_scope": capture_scope,
1133-
"ingress_mode": ingress_mode,
1134-
"inbox_config_ref": str(routing.get("inbox_config_ref") or ""),
1135-
**(
1136-
{"connector": dict(connector)}
1137-
if isinstance(connector, Mapping)
1138-
else {}
1139-
),
1140-
**(
1141-
{"event_id": str(event.get("event_id") or message_id)}
1142-
if isinstance(connector, Mapping)
1143-
else {}
1144-
),
1145-
}
1146-
)
1129+
if not eligible:
1130+
reason = (
1131+
LarkTopicEventDecisionReason.BINDING_UNAVAILABLE
1132+
if not has_binding
1133+
else LarkTopicEventDecisionReason.CHAT_MISMATCH
1134+
if not matched_chat
1135+
else LarkTopicEventDecisionReason.TOPIC_MISMATCH
1136+
)
1137+
return {"matched": False, "reason": reason.value, "route": None}
1138+
1139+
selected = eligible[0]
1140+
identity = selected["identity"]
1141+
sender_id = str(event.get("sender_id") or "")
1142+
if sender_id and sender_id in {
1143+
str(identity.get("bot_app_id") or ""),
1144+
str(identity.get("bot_open_id") or ""),
1145+
}:
1146+
return {
1147+
"matched": False,
1148+
"reason": LarkTopicEventDecisionReason.SELF_MESSAGE.value,
1149+
"route": None,
1150+
}
1151+
capture_scope = str(selected["capture_scope"])
1152+
if capture_scope != CaptureScope.CONFIGURED_CHAT_ALL.value and not is_event_addressed_to_bot(
1153+
event, identity
1154+
):
11471155
return {
1148-
"matched": True,
1149-
"reason": LarkTopicEventDecisionReason.MATCHED.value,
1150-
"route": route,
1156+
"matched": False,
1157+
"reason": LarkTopicEventDecisionReason.NOT_ADDRESSED.value,
1158+
"route": None,
11511159
}
1152-
reason = (
1153-
LarkTopicEventDecisionReason.BINDING_UNAVAILABLE
1154-
if not has_binding
1155-
else LarkTopicEventDecisionReason.CHAT_MISMATCH
1156-
if not matched_chat
1157-
else LarkTopicEventDecisionReason.TOPIC_MISMATCH
1158-
if not matched_topic
1159-
else LarkTopicEventDecisionReason.BINDING_UNAVAILABLE
1160-
)
1161-
return {"matched": False, "reason": reason.value, "route": None}
1160+
binding = selected["binding"]
1161+
ingress_mode = str(selected["ingress_mode"])
1162+
routing = selected["routing"]
1163+
connector = selected["connector"]
1164+
route = {
1165+
"app_ref": str(identity.get("sender_profile") or "default"),
1166+
"goal_id": str(selected["goal_id"]),
1167+
"message_id": message_id,
1168+
"reply_mode": str(selected["reply_mode"]),
1169+
"target_ref": str(selected["target_ref"]),
1170+
"topic_root_message_id": str(selected["topic_root"]),
1171+
}
1172+
if ingress_mode != IngressMode.DIRECT_SESSION.value or binding.get("agent_id"):
1173+
route.update(
1174+
{
1175+
"agent_id": str(binding.get("agent_id") or ""),
1176+
"session_id": str(binding.get("session_id") or ""),
1177+
"capture_scope": capture_scope,
1178+
"ingress_mode": ingress_mode,
1179+
"inbox_config_ref": str(routing.get("inbox_config_ref") or ""),
1180+
**(
1181+
{"connector": dict(connector)}
1182+
if isinstance(connector, Mapping)
1183+
else {}
1184+
),
1185+
**(
1186+
{"event_id": str(event.get("event_id") or message_id)}
1187+
if isinstance(connector, Mapping)
1188+
else {}
1189+
),
1190+
}
1191+
)
1192+
return {
1193+
"matched": True,
1194+
"reason": LarkTopicEventDecisionReason.MATCHED.value,
1195+
"route": route,
1196+
}
11621197

11631198

11641199
def route_lark_topic_event(

‎loopx/extensions/lark/goal_topic_runtime.py‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,23 @@ def _topic_roots_for_target(
169169
return roots
170170

171171

172+
def _binding_payloads_for_target(
173+
binding_payloads: Mapping[str, Any], *, target_ref: str
174+
) -> dict[str, Mapping[str, Any]]:
175+
selected: dict[str, Mapping[str, Any]] = {}
176+
for goal_id, payload in binding_payloads.items():
177+
if not isinstance(payload, Mapping):
178+
continue
179+
binding = binding_for_goal(payload, str(goal_id))
180+
if (
181+
binding
182+
and binding.get("enabled") is True
183+
and str(binding.get("target_ref") or "") == target_ref
184+
):
185+
selected[str(goal_id)] = payload
186+
return selected
187+
188+
172189
def _event_payloads(stdout: Any) -> list[Mapping[str, Any]]:
173190
events: list[Mapping[str, Any]] = []
174191
for line in str(stdout or "").splitlines():
@@ -266,11 +283,10 @@ def poll_lark_goal_topic_profile_once(
266283
try:
267284
event_result = process_lark_goal_topic_event(
268285
target_payload=target_payload,
269-
binding_payloads={
270-
str(goal_id): payload
271-
for goal_id, payload in binding_payloads.items()
272-
if isinstance(payload, Mapping)
273-
},
286+
binding_payloads=_binding_payloads_for_target(
287+
binding_payloads,
288+
target_ref=target_ref,
289+
),
274290
event=routed_event,
275291
runtime_root=runtime_root,
276292
goal_contexts=(
@@ -766,6 +782,8 @@ def _inbox_config(
766782
"sender_identity": "bot",
767783
"bot_display_name": bot_display_name,
768784
"chat_id": chat_id,
785+
"placement_policy": "source_context",
786+
"editorial_style": "bullet_points_preferred",
769787
},
770788
}
771789
config_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)

0 commit comments

Comments
 (0)