Tak Hoffman 89e3969d64
feat(feishu): add ACP and subagent session binding (#46819)
* feat(feishu): add ACP session support

* fix(feishu): preserve sender-scoped ACP rebinding

* fix(feishu): recover sender scope from bound ACP sessions

* fix(feishu): support DM ACP binding placement

* feat(feishu): add current-conversation session binding

* fix(feishu): avoid DM parent binding fallback

* fix(feishu): require canonical topic sender ids

* fix(feishu): honor sender-scoped ACP bindings

* fix(feishu): allow user-id ACP DM bindings

* fix(feishu): recover user-id ACP DM bindings
2026-03-15 10:33:49 -05:00

265 lines
9.0 KiB
TypeScript

import { buildFeishuConversationId } from "../../../../extensions/feishu/src/conversation-id.js";
import {
buildTelegramTopicConversationId,
normalizeConversationText,
parseTelegramChatIdFromTarget,
} from "../../../acp/conversation-id.js";
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js";
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import { parseAgentSessionKey } from "../../../routing/session-key.js";
import type { HandleCommandsParams } from "../commands-types.js";
import { parseDiscordParentChannelFromSessionKey } from "../discord-parent-channel.js";
import { resolveTelegramConversationId } from "../telegram-context.js";
function parseFeishuTargetId(raw: unknown): string | undefined {
const target = normalizeConversationText(raw);
if (!target) {
return undefined;
}
const withoutProvider = target.replace(/^(feishu|lark):/i, "").trim();
if (!withoutProvider) {
return undefined;
}
const lowered = withoutProvider.toLowerCase();
for (const prefix of ["chat:", "group:", "channel:", "user:", "dm:", "open_id:"]) {
if (lowered.startsWith(prefix)) {
return normalizeConversationText(withoutProvider.slice(prefix.length));
}
}
return withoutProvider;
}
function parseFeishuDirectConversationId(raw: unknown): string | undefined {
const target = normalizeConversationText(raw);
if (!target) {
return undefined;
}
const withoutProvider = target.replace(/^(feishu|lark):/i, "").trim();
if (!withoutProvider) {
return undefined;
}
const lowered = withoutProvider.toLowerCase();
for (const prefix of ["user:", "dm:", "open_id:"]) {
if (lowered.startsWith(prefix)) {
return normalizeConversationText(withoutProvider.slice(prefix.length));
}
}
const id = parseFeishuTargetId(target);
if (!id) {
return undefined;
}
if (id.startsWith("ou_") || id.startsWith("on_")) {
return id;
}
return undefined;
}
function resolveFeishuSenderScopedConversationId(params: {
accountId: string;
parentConversationId?: string;
threadId?: string;
senderId?: string;
sessionKey?: string;
parentSessionKey?: string;
}): string | undefined {
const parentConversationId = normalizeConversationText(params.parentConversationId);
const threadId = normalizeConversationText(params.threadId);
const senderId = normalizeConversationText(params.senderId);
const expectedScopePrefix = `feishu:group:${parentConversationId?.toLowerCase()}:topic:${threadId?.toLowerCase()}:sender:`;
const isSenderScopedSession = [params.sessionKey, params.parentSessionKey].some((candidate) => {
const scopedRest = parseAgentSessionKey(candidate)?.rest?.trim().toLowerCase() ?? "";
return Boolean(scopedRest && expectedScopePrefix && scopedRest.startsWith(expectedScopePrefix));
});
if (!parentConversationId || !threadId || !senderId) {
return undefined;
}
if (!isSenderScopedSession && params.sessionKey?.trim()) {
const boundConversation = getSessionBindingService()
.listBySession(params.sessionKey)
.find((binding) => {
if (
binding.conversation.channel !== "feishu" ||
binding.conversation.accountId !== params.accountId
) {
return false;
}
return (
binding.conversation.conversationId ===
buildFeishuConversationId({
chatId: parentConversationId,
scope: "group_topic_sender",
topicId: threadId,
senderOpenId: senderId,
})
);
});
if (boundConversation) {
return boundConversation.conversation.conversationId;
}
return undefined;
}
return buildFeishuConversationId({
chatId: parentConversationId,
scope: "group_topic_sender",
topicId: threadId,
senderOpenId: senderId,
});
}
export function resolveAcpCommandChannel(params: HandleCommandsParams): string {
const raw =
params.ctx.OriginatingChannel ??
params.command.channel ??
params.ctx.Surface ??
params.ctx.Provider;
return normalizeConversationText(raw).toLowerCase();
}
export function resolveAcpCommandAccountId(params: HandleCommandsParams): string {
const accountId = normalizeConversationText(params.ctx.AccountId);
return accountId || "default";
}
export function resolveAcpCommandThreadId(params: HandleCommandsParams): string | undefined {
const threadId =
params.ctx.MessageThreadId != null
? normalizeConversationText(String(params.ctx.MessageThreadId))
: "";
return threadId || undefined;
}
export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined {
const channel = resolveAcpCommandChannel(params);
if (channel === "telegram") {
const telegramConversationId = resolveTelegramConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
});
if (telegramConversationId) {
return telegramConversationId;
}
const threadId = resolveAcpCommandThreadId(params);
const parentConversationId = resolveAcpCommandParentConversationId(params);
if (threadId && parentConversationId) {
return (
buildTelegramTopicConversationId({
chatId: parentConversationId,
topicId: threadId,
}) ?? threadId
);
}
}
if (channel === "feishu") {
const threadId = resolveAcpCommandThreadId(params);
const parentConversationId = resolveAcpCommandParentConversationId(params);
if (threadId && parentConversationId) {
const senderScopedConversationId = resolveFeishuSenderScopedConversationId({
accountId: resolveAcpCommandAccountId(params),
parentConversationId,
threadId,
senderId: params.command.senderId ?? params.ctx.SenderId,
sessionKey: params.sessionKey,
parentSessionKey: params.ctx.ParentSessionKey,
});
return (
senderScopedConversationId ??
buildFeishuConversationId({
chatId: parentConversationId,
scope: "group_topic",
topicId: threadId,
})
);
}
return (
parseFeishuDirectConversationId(params.ctx.OriginatingTo) ??
parseFeishuDirectConversationId(params.command.to) ??
parseFeishuDirectConversationId(params.ctx.To)
);
}
return resolveConversationIdFromTargets({
threadId: params.ctx.MessageThreadId,
targets: [params.ctx.OriginatingTo, params.command.to, params.ctx.To],
});
}
function parseDiscordParentChannelFromContext(raw: unknown): string | undefined {
const parentId = normalizeConversationText(raw);
if (!parentId) {
return undefined;
}
return parentId;
}
export function resolveAcpCommandParentConversationId(
params: HandleCommandsParams,
): string | undefined {
const channel = resolveAcpCommandChannel(params);
if (channel === "telegram") {
return (
parseTelegramChatIdFromTarget(params.ctx.OriginatingTo) ??
parseTelegramChatIdFromTarget(params.command.to) ??
parseTelegramChatIdFromTarget(params.ctx.To)
);
}
if (channel === "feishu") {
const threadId = resolveAcpCommandThreadId(params);
if (!threadId) {
return undefined;
}
return (
parseFeishuTargetId(params.ctx.OriginatingTo) ??
parseFeishuTargetId(params.command.to) ??
parseFeishuTargetId(params.ctx.To)
);
}
if (channel === DISCORD_THREAD_BINDING_CHANNEL) {
const threadId = resolveAcpCommandThreadId(params);
if (!threadId) {
return undefined;
}
const fromContext = parseDiscordParentChannelFromContext(params.ctx.ThreadParentId);
if (fromContext && fromContext !== threadId) {
return fromContext;
}
const fromParentSession = parseDiscordParentChannelFromSessionKey(params.ctx.ParentSessionKey);
if (fromParentSession && fromParentSession !== threadId) {
return fromParentSession;
}
const fromTargets = resolveConversationIdFromTargets({
targets: [params.ctx.OriginatingTo, params.command.to, params.ctx.To],
});
if (fromTargets && fromTargets !== threadId) {
return fromTargets;
}
}
return undefined;
}
export function isAcpCommandDiscordChannel(params: HandleCommandsParams): boolean {
return resolveAcpCommandChannel(params) === DISCORD_THREAD_BINDING_CHANNEL;
}
export function resolveAcpCommandBindingContext(params: HandleCommandsParams): {
channel: string;
accountId: string;
threadId?: string;
conversationId?: string;
parentConversationId?: string;
} {
const parentConversationId = resolveAcpCommandParentConversationId(params);
return {
channel: resolveAcpCommandChannel(params),
accountId: resolveAcpCommandAccountId(params),
threadId: resolveAcpCommandThreadId(params),
conversationId: resolveAcpCommandConversationId(params),
...(parentConversationId ? { parentConversationId } : {}),
};
}