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
2 changes: 2 additions & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,7 @@ export type LarkGoalConnection = {
health_error_code: string | null;
incoming_mode: "mentions" | "all";
event_count: number;
last_event_reason: string | null;
last_event_status: string | null;
listener_error_code: string | null;
listener_status: "starting" | "listening" | "retrying" | "stopped" | null;
Expand All @@ -1027,6 +1028,7 @@ const larkConnectionsSchema = z.object({
health_error_code: z.string().nullable().default(null),
incoming_mode: z.enum(["mentions", "all"]),
event_count: z.number().int().nonnegative().default(0),
last_event_reason: z.string().nullable().default(null),
last_event_status: z.string().nullable().default(null),
listener_error_code: z.string().nullable().default(null),
listener_status: z.enum(["starting", "listening", "retrying", "stopped"]).nullable().default(null),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,33 @@ function larkConnectionHealth(connection: LarkGoalConnection): { label: string;
if (connection.last_event_status === "processing_failed") {
return { label: "消息处理失败", detail: "已收到消息事件,但 Agent 处理失败。请查看本地诊断后重试。", ready: false };
}
if (connection.last_event_status === "ignored" && connection.last_event_reason === "not_addressed") {
return {
label: "最近消息未直接 @ 机器人",
detail: "监听正常;当前连接只响应直接 @ 机器人或对机器人的回复。",
ready: true,
};
}
if (connection.last_event_status === "ignored" && connection.last_event_reason === "self_message") {
return { label: "监听中", detail: "已忽略机器人自身发送的消息,避免重复回复。", ready: true };
}
if (
connection.health_error_code === "lark_event_route_mismatch"
|| ["chat_mismatch", "topic_mismatch"].includes(connection.last_event_reason ?? "")
) {
return {
label: "消息未匹配当前 Goal Topic",
detail: "事件来自其他群聊或 Topic。请重新选择群聊并连接该 Goal,然后发送一条新的 @ 消息。",
ready: false,
};
}
if (["invalid_event", "binding_unavailable"].includes(connection.last_event_reason ?? "")) {
return {
label: "消息无法路由到 Goal",
detail: "当前连接信息不完整。请重新连接该 Goal 后再发送一条新的 @ 消息。",
ready: false,
};
}
if (connection.last_event_status === "replied_and_acknowledged") {
return { label: "监听中", detail: `已处理 ${connection.event_count} 条事件,成功回复 ${connection.replied_count} 条。`, ready: true };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,8 @@ assert.match(larkSettings, /im:message\.group_at_msg:readonly/, "Group mention p
assert.match(larkSettings, /发布新版/, "Permission guidance reminds operators to publish a new app version");
assert.match(larkSettings, /未收到消息事件/, "Connections explain when Feishu event delivery has not been observed");
assert.match(larkSettings, /message_context_permission_required/, "Received events with missing context permissions get an actionable repair hint");
assert.match(larkSettings, /最近消息未直接 @ 机器人/, "Ignored unaddressed messages explain why LoopX did not reply");
assert.match(larkSettings, /消息未匹配当前 Goal Topic/, "Route mismatches receive an actionable connection repair hint");
assert.match(larkSettings, /connectLarkGoalTopic\([^)]*execute:\s*false/s, "Connect flow previews before execution");
assert.match(larkSettings, /connectLarkGoalTopic\([^)]*execute:\s*true/s, "Connect flow performs the approved external write");
assert.match(larkSettings, /Register another Lark App/, "App chooser exposes Feishu registration");
Expand Down
24 changes: 23 additions & 1 deletion examples/personal-workspace-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ async function installApi(page) {
larkWrites: [],
actionTransitions: [],
turnRequests: [],
get larkConnections() { return runtime.larkConnections; },
};
await page.route(`http://127.0.0.1:${port}/status.json`, async (route) => {
const fixture = require(resolve(repoRoot, "examples/status.example.json"));
Expand Down Expand Up @@ -157,7 +158,7 @@ async function installApi(page) {
app_label: "LoopX Mew", app_ref: body.app_ref, chat_name: body.chat_name, enabled: true,
event_count: 0, health_error_code: "lark_event_delivery_unverified",
goal_id: body.goal_id, goal_title: goal?.id ?? body.goal_id, incoming_mode: body.incoming_mode,
last_event_status: null, listener_error_code: null, listener_status: "listening", replied_count: 0,
last_event_reason: null, last_event_status: null, listener_error_code: null, listener_status: "listening", replied_count: 0,
reply_mode: "topic_reply", target_ref: "product-group", topic_name: goal?.id ?? body.goal_id,
topic_setup_required: false, reply_ready: false,
});
Expand Down Expand Up @@ -581,6 +582,27 @@ async function main() {
if (!(await connectedRow.getByText("事件订阅待验证", { exact: false }).isVisible())) throw new Error("A zero-event listener was presented as automatic-reply ready");
if (!(await connectedRow.getByRole("link", { name: "查看飞书事件配置" }).isVisible())) throw new Error("An unverified Lark event subscription lacked repair guidance");
if (api.larkWrites.length !== 1 || api.larkWrites[0].execute !== true) throw new Error("Lark connect did not perform exactly one approved external write");
Object.assign(api.larkConnections[0], {
event_count: 1,
health_error_code: "lark_event_route_mismatch",
last_event_reason: "topic_mismatch",
last_event_status: "ignored",
});
const mismatchReadback = await page.evaluate(async () => (await fetch("/api/chat/lark/connections")).json());
if (mismatchReadback.connections?.[0]?.last_event_reason !== "topic_mismatch") {
throw new Error(`Lark route mismatch API readback mismatch: ${JSON.stringify(mismatchReadback)}`);
}
await page.getByRole("button", { name: "关闭 Lark 设置" }).click();
await page.reload({ waitUntil: "networkidle" });
await page.getByTestId("personal-goal-home").waitFor({ state: "visible" });
await page.getByRole("button", { name: "通知设置", exact: true }).click();
const routeMismatchRow = page.locator(".personal-lark-table-row", { hasText: "Product group" });
try {
await routeMismatchRow.getByText("消息未匹配当前 Goal Topic", { exact: false }).waitFor({ state: "visible" });
} catch (error) {
throw new Error(`${error.message}; body=${(await page.locator("body").innerText()).slice(0, 4000)}`);
}
await routeMismatchRow.getByText("请重新选择群聊并连接该 Goal", { exact: false }).waitFor({ state: "visible" });
await page.locator(".personal-lark-table-row", { hasText: "Product group" }).getByRole("button", { name: /配置/ }).click();
await page.getByRole("dialog", { name: "Edit Lark Connection" }).waitFor({ state: "visible" });
await page.getByRole("dialog", { name: "Edit Lark Connection" }).getByRole("button", { name: "Cancel" }).click();
Expand Down
5 changes: 5 additions & 0 deletions loopx/canary/module_metric_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
"dict_any_count": 0,
"lines": 1984
},
"loopx/chat_server.py": {
"any_count": 25,
"dict_any_count": 0,
"lines": 1513
},
"loopx/capabilities/auto_research/demo_e2e.py": {
"any_count": 0,
"dict_any_count": 0,
Expand Down
6 changes: 3 additions & 3 deletions loopx/extensions/lark/event_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,10 @@ def _jq_projection(chat_id: str) -> str:
return (
f"select(.chat_id == {chat_literal}) | "
'{schema_version:"lark_event_inbox_event_v0",'
"event_id:(.event_id // .message_id),message_id:.message_id,"
"event_id:(.event_id // .message_id // .id),"
"message_id:(.message_id // .id),"
"create_time:.create_time,content:.content,sender_id:.sender_id,"
"chat_id:.chat_id,root_id:.root_id,parent_id:.parent_id,"
"mentions:(.mentions // [])}"
"chat_id:.chat_id}"
)


Expand Down
13 changes: 12 additions & 1 deletion loopx/extensions/lark/event_collector_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,18 @@ def enrich_lark_event_reply_context(
if current is None or str(current.get("chat_id") or "") != configured_chat_id:
return enriched

# The compact lark-cli event stream intentionally carries only stable event
# envelope fields. Message-level routing fields live on the message lookup
# response, so copy them into the canonical event before deciding whether
# this bot was addressed and which Goal Topic owns the message.
for field in ("content", "mentions", "mentioned"):
value = current.get(field)
if value not in (None, "", [], False):
enriched[field] = value
current_sender_type, current_sender_id = _sender_identity(current)
if current_sender_id:
enriched["sender_id"] = current_sender_id

parent_id = str(current.get("parent_id") or "").strip()
root_id = str(current.get("root_id") or "").strip()
if MESSAGE_ID_PATTERN.fullmatch(root_id):
Expand All @@ -269,7 +281,6 @@ def enrich_lark_event_reply_context(
enriched["message_context_status"] = parent_status
if parent is None or str(parent.get("chat_id") or "") != configured_chat_id:
return enriched
current_sender_type, _ = _sender_identity(current)
parent_sender_type, parent_sender_id = _sender_identity(parent)
enriched["reply_context_verified"] = True
enriched["message_context_status"] = "message_context_verified"
Expand Down
26 changes: 26 additions & 0 deletions loopx/extensions/lark/event_inbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@ def _event_attention_kind(
bot_display_name: str,
capture_scope: str,
) -> str | None:
if lark_event_mentions_bot(event, bot_display_name=bot_display_name):
return "direct_mention"
normalized = dict(event)
normalized["reply_to_operator"] = bool(
event.get("reply_context_verified") is True
Expand All @@ -268,6 +270,30 @@ def _event_attention_kind(
return "reply_to_bot" if kind == "reply_to_operator" else kind


def _normalized_mention_name(value: Any) -> str:
return " ".join(str(value or "").strip().lstrip("@").split()).casefold()


def lark_event_mentions_bot(
event: Mapping[str, Any], *, bot_display_name: str
) -> bool:
"""Recognize provider-native direct mentions without message readback."""

if event.get("mentioned") is True:
return True
expected = _normalized_mention_name(bot_display_name)
mentions = event.get("mentions")
return bool(
expected
and isinstance(mentions, list)
and any(
isinstance(mention, Mapping)
and _normalized_mention_name(mention.get("name")) == expected
for mention in mentions
)
)


def ingest_lark_event_inbox(
*,
project: str | Path,
Expand Down
95 changes: 58 additions & 37 deletions loopx/extensions/lark/goal_topic_connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@ def list_lark_connections(
else listener_status in {"starting", "listening"}
)
last_event_status = str(listener.get("last_event_status") or "")
last_event_reason = str(listener.get("last_event_reason") or "")
event_count = int(listener.get("event_count") or 0)
event_blocker = (
last_event_status
Expand All @@ -514,12 +515,26 @@ def list_lark_connections(
"message_context_permission_required",
"message_context_lookup_failed",
"message_context_unavailable",
"topic_context_ambiguous",
"topic_context_missing",
"processing_failed",
"answer_empty",
"reply_failed",
}
else None
)
if (
event_blocker is None
and last_event_status == "ignored"
and last_event_reason
in {
"invalid_event",
"binding_unavailable",
"chat_mismatch",
"topic_mismatch",
}
):
event_blocker = "lark_event_route_mismatch"
event_delivery_unverified = bool(
runtime_health is not None
and listener_status == "listening"
Expand Down Expand Up @@ -569,19 +584,22 @@ def list_lark_connections(
"listener_status": listener_status or None,
"listener_error_code": listener.get("error_code"),
"last_event_status": last_event_status or None,
"last_event_reason": last_event_reason or None,
"event_count": event_count,
"replied_count": int(listener.get("replied_count") or 0),
}
)
return sorted(rows, key=lambda row: (str(row["app_label"]).casefold(), str(row["goal_title"]).casefold()))


def route_lark_topic_event(
def decide_lark_topic_event(
*,
target_payload: Mapping[str, Any],
binding_payloads: Mapping[str, Mapping[str, Any]],
event: Mapping[str, Any],
) -> dict[str, str] | None:
) -> dict[str, Any]:
"""Return a content-free routing decision for one provider event."""

chat_id = str(event.get("chat_id") or "")
root_id = str(event.get("root_id") or "")
message_id = str(event.get("message_id") or "")
Expand All @@ -590,11 +608,15 @@ def route_lark_topic_event(
and MESSAGE_ID_PATTERN.fullmatch(root_id)
and MESSAGE_ID_PATTERN.fullmatch(message_id)
):
return None
return {"matched": False, "reason": "invalid_event", "route": None}
has_binding = False
matched_chat = False
matched_topic = False
for goal_id, payload in binding_payloads.items():
binding = binding_for_goal(payload, goal_id)
if not binding or binding.get("enabled") is not True:
continue
has_binding = True
target_ref = str(binding.get("target_ref") or "")
target = goal_channel_target_for_name(target_payload, target_ref)
if target is None:
Expand All @@ -605,49 +627,48 @@ def route_lark_topic_event(
binding_channel = binding.get("channel") if isinstance(binding.get("channel"), Mapping) else {}
topic_root = str(topic.get("root_message_id") or binding_channel.get("pinned_message_id") or "")
routing = binding.get("routing") if isinstance(binding.get("routing"), Mapping) else {}
incoming_mode = str(routing.get("incoming_mode") or "mentions")
if str(channel.get("chat_id") or "") != chat_id or topic_root != root_id:
if str(channel.get("chat_id") or "") != chat_id:
continue
matched_chat = True
if topic_root != root_id:
continue
matched_topic = True
if str(event.get("sender_id") or "") == str(identity.get("bot_app_id") or ""):
return None
bot_display_name = " ".join(str(identity.get("bot_display_name") or "").split())
provider_mentions = event.get("mentions")
provider_mentioned = bool(
bot_display_name
and isinstance(provider_mentions, list)
and any(
isinstance(mention, Mapping)
and " ".join(str(mention.get("name") or "").split()).casefold()
== bot_display_name.casefold()
for mention in provider_mentions
)
)
rendered_content = " ".join(str(event.get("content") or "").split())
rendered_mentioned = bool(
bot_display_name
and "@" in rendered_content
and bot_display_name.casefold() in rendered_content.casefold()
)
addressed = bool(
event.get("mentioned") is True
or provider_mentioned
or rendered_mentioned
or (
event.get("reply_context_verified") is True
and event.get("reply_to_bot") is True
)
)
if incoming_mode == "mentions" and not addressed:
return None
return {
return {"matched": False, "reason": "self_message", "route": None}
route = {
"app_ref": str(identity.get("sender_profile") or "default"),
"goal_id": goal_id,
"message_id": message_id,
"reply_mode": str(routing.get("reply_mode") or "topic_reply"),
"target_ref": target_ref,
"topic_root_message_id": topic_root,
}
return None
return {"matched": True, "reason": "matched", "route": route}
reason = (
"binding_unavailable"
if not has_binding
else "chat_mismatch"
if not matched_chat
else "topic_mismatch"
if not matched_topic
else "binding_unavailable"
)
return {"matched": False, "reason": reason, "route": None}


def route_lark_topic_event(
*,
target_payload: Mapping[str, Any],
binding_payloads: Mapping[str, Mapping[str, Any]],
event: Mapping[str, Any],
) -> dict[str, str] | None:
decision = decide_lark_topic_event(
target_payload=target_payload,
binding_payloads=binding_payloads,
event=event,
)
route = decision.get("route")
return dict(route) if isinstance(route, Mapping) else None


def reply_lark_goal_topic(
Expand Down
Loading
Loading