openclaw/extensions/feishu/src/subagent-hooks.ts

342 lines
10 KiB
TypeScript

import type { OpenClawPluginApi } from "../runtime-api.js";
import { buildFeishuConversationId, parseFeishuConversationId } from "./conversation-id.js";
import { normalizeFeishuTarget } from "./targets.js";
import { getFeishuThreadBindingManager } from "./thread-bindings.js";
function summarizeError(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
if (typeof err === "string") {
return err;
}
return "error";
}
function stripProviderPrefix(raw: string): string {
return raw.replace(/^(feishu|lark):/i, "").trim();
}
function resolveFeishuRequesterConversation(params: {
accountId?: string;
to?: string;
threadId?: string | number;
requesterSessionKey?: string;
}): {
accountId: string;
conversationId: string;
parentConversationId?: string;
} | null {
const manager = getFeishuThreadBindingManager(params.accountId);
if (!manager) {
return null;
}
const rawTo = params.to?.trim();
const withoutProviderPrefix = rawTo ? stripProviderPrefix(rawTo) : "";
const normalizedTarget = rawTo ? normalizeFeishuTarget(rawTo) : null;
const threadId =
params.threadId != null && params.threadId !== "" ? String(params.threadId).trim() : "";
const isChatTarget = /^(chat|group|channel):/i.test(withoutProviderPrefix);
const parsedRequesterTopic =
normalizedTarget && threadId && isChatTarget
? parseFeishuConversationId({
conversationId: buildFeishuConversationId({
chatId: normalizedTarget,
scope: "group_topic",
topicId: threadId,
}),
parentConversationId: normalizedTarget,
})
: null;
const requesterSessionKey = params.requesterSessionKey?.trim();
if (requesterSessionKey) {
const existingBindings = manager.listBySessionKey(requesterSessionKey);
if (existingBindings.length === 1) {
const existing = existingBindings[0];
return {
accountId: existing.accountId,
conversationId: existing.conversationId,
parentConversationId: existing.parentConversationId,
};
}
if (existingBindings.length > 1) {
if (rawTo && normalizedTarget && !threadId && !isChatTarget) {
const directMatches = existingBindings.filter(
(entry) =>
entry.accountId === manager.accountId &&
entry.conversationId === normalizedTarget &&
!entry.parentConversationId,
);
if (directMatches.length === 1) {
const existing = directMatches[0];
return {
accountId: existing.accountId,
conversationId: existing.conversationId,
parentConversationId: existing.parentConversationId,
};
}
return null;
}
if (parsedRequesterTopic) {
const matchingTopicBindings = existingBindings.filter((entry) => {
const parsed = parseFeishuConversationId({
conversationId: entry.conversationId,
parentConversationId: entry.parentConversationId,
});
return (
parsed?.chatId === parsedRequesterTopic.chatId &&
parsed?.topicId === parsedRequesterTopic.topicId
);
});
if (matchingTopicBindings.length === 1) {
const existing = matchingTopicBindings[0];
return {
accountId: existing.accountId,
conversationId: existing.conversationId,
parentConversationId: existing.parentConversationId,
};
}
const senderScopedTopicBindings = matchingTopicBindings.filter((entry) => {
const parsed = parseFeishuConversationId({
conversationId: entry.conversationId,
parentConversationId: entry.parentConversationId,
});
return parsed?.scope === "group_topic_sender";
});
if (
senderScopedTopicBindings.length === 1 &&
matchingTopicBindings.length === senderScopedTopicBindings.length
) {
const existing = senderScopedTopicBindings[0];
return {
accountId: existing.accountId,
conversationId: existing.conversationId,
parentConversationId: existing.parentConversationId,
};
}
return null;
}
}
}
if (!rawTo) {
return null;
}
if (!normalizedTarget) {
return null;
}
if (threadId) {
if (!isChatTarget) {
return null;
}
return {
accountId: manager.accountId,
conversationId: buildFeishuConversationId({
chatId: normalizedTarget,
scope: "group_topic",
topicId: threadId,
}),
parentConversationId: normalizedTarget,
};
}
if (isChatTarget) {
return null;
}
return {
accountId: manager.accountId,
conversationId: normalizedTarget,
};
}
function resolveFeishuDeliveryOrigin(params: {
conversationId: string;
parentConversationId?: string;
accountId: string;
deliveryTo?: string;
deliveryThreadId?: string;
}): {
channel: "feishu";
accountId: string;
to: string;
threadId?: string;
} {
const deliveryTo = params.deliveryTo?.trim();
const deliveryThreadId = params.deliveryThreadId?.trim();
if (deliveryTo) {
return {
channel: "feishu",
accountId: params.accountId,
to: deliveryTo,
...(deliveryThreadId ? { threadId: deliveryThreadId } : {}),
};
}
const parsed = parseFeishuConversationId({
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
});
if (parsed?.topicId) {
return {
channel: "feishu",
accountId: params.accountId,
to: `chat:${params.parentConversationId?.trim() || parsed.chatId}`,
threadId: parsed.topicId,
};
}
return {
channel: "feishu",
accountId: params.accountId,
to: `user:${params.conversationId}`,
};
}
function resolveMatchingChildBinding(params: {
accountId?: string;
childSessionKey: string;
requesterSessionKey?: string;
requesterOrigin?: {
to?: string;
threadId?: string | number;
};
}) {
const manager = getFeishuThreadBindingManager(params.accountId);
if (!manager) {
return null;
}
const childBindings = manager.listBySessionKey(params.childSessionKey.trim());
if (childBindings.length === 0) {
return null;
}
const requesterConversation = resolveFeishuRequesterConversation({
accountId: manager.accountId,
to: params.requesterOrigin?.to,
threadId: params.requesterOrigin?.threadId,
requesterSessionKey: params.requesterSessionKey,
});
if (requesterConversation) {
const matched = childBindings.find(
(entry) =>
entry.accountId === requesterConversation.accountId &&
entry.conversationId === requesterConversation.conversationId &&
(entry.parentConversationId?.trim() || undefined) ===
(requesterConversation.parentConversationId?.trim() || undefined),
);
if (matched) {
return matched;
}
}
return childBindings.length === 1 ? childBindings[0] : null;
}
export function registerFeishuSubagentHooks(api: OpenClawPluginApi) {
api.on("subagent_spawning", async (event, ctx) => {
if (!event.threadRequested) {
return;
}
const requesterChannel = event.requester?.channel?.trim().toLowerCase();
if (requesterChannel !== "feishu") {
return;
}
const manager = getFeishuThreadBindingManager(event.requester?.accountId);
if (!manager) {
return {
status: "error" as const,
error:
"Feishu current-conversation binding is unavailable because the Feishu account monitor is not active.",
};
}
const conversation = resolveFeishuRequesterConversation({
accountId: event.requester?.accountId,
to: event.requester?.to,
threadId: event.requester?.threadId,
requesterSessionKey: ctx.requesterSessionKey,
});
if (!conversation) {
return {
status: "error" as const,
error:
"Feishu current-conversation binding is only available in direct messages or topic conversations.",
};
}
try {
const binding = manager.bindConversation({
conversationId: conversation.conversationId,
parentConversationId: conversation.parentConversationId,
targetKind: "subagent",
targetSessionKey: event.childSessionKey,
metadata: {
agentId: event.agentId,
label: event.label,
boundBy: "system",
deliveryTo: event.requester?.to,
deliveryThreadId:
event.requester?.threadId != null && event.requester.threadId !== ""
? String(event.requester.threadId)
: undefined,
},
});
if (!binding) {
return {
status: "error" as const,
error:
"Unable to bind this Feishu conversation to the spawned subagent session. Session mode is unavailable for this target.",
};
}
return {
status: "ok" as const,
threadBindingReady: true,
};
} catch (err) {
return {
status: "error" as const,
error: `Feishu conversation bind failed: ${summarizeError(err)}`,
};
}
});
api.on("subagent_delivery_target", (event) => {
if (!event.expectsCompletionMessage) {
return;
}
const requesterChannel = event.requesterOrigin?.channel?.trim().toLowerCase();
if (requesterChannel !== "feishu") {
return;
}
const binding = resolveMatchingChildBinding({
accountId: event.requesterOrigin?.accountId,
childSessionKey: event.childSessionKey,
requesterSessionKey: event.requesterSessionKey,
requesterOrigin: {
to: event.requesterOrigin?.to,
threadId: event.requesterOrigin?.threadId,
},
});
if (!binding) {
return;
}
return {
origin: resolveFeishuDeliveryOrigin({
conversationId: binding.conversationId,
parentConversationId: binding.parentConversationId,
accountId: binding.accountId,
deliveryTo: binding.deliveryTo,
deliveryThreadId: binding.deliveryThreadId,
}),
};
});
api.on("subagent_ended", (event) => {
const manager = getFeishuThreadBindingManager(event.accountId);
manager?.unbindBySessionKey(event.targetSessionKey);
});
}