openclaw/extensions/feishu/src/comment-handler.ts

248 lines
7.8 KiB
TypeScript

import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
import {
createChannelPairingController,
type ClawdbotConfig,
type RuntimeEnv,
} from "../runtime-api.js";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { createFeishuCommentReplyDispatcher } from "./comment-dispatcher.js";
import { buildFeishuCommentTarget } from "./comment-target.js";
import { replyComment } from "./drive.js";
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
import {
resolveDriveCommentEventTurn,
type FeishuDriveCommentNoticeEvent,
} from "./monitor.comment.js";
import { resolveFeishuAllowlistMatch } from "./policy.js";
import { getFeishuRuntime } from "./runtime.js";
import type { DynamicAgentCreationConfig } from "./types.js";
type HandleFeishuCommentEventParams = {
cfg: ClawdbotConfig;
accountId: string;
runtime?: RuntimeEnv;
event: FeishuDriveCommentNoticeEvent;
botOpenId?: string;
};
function buildCommentSessionKey(params: {
core: ReturnType<typeof getFeishuRuntime>;
route: ResolvedAgentRoute;
commentTarget: string;
}): string {
return params.core.channel.routing.buildAgentSessionKey({
agentId: params.route.agentId,
channel: "feishu",
accountId: params.route.accountId,
peer: {
kind: "direct",
id: params.commentTarget,
},
dmScope: "per-account-channel-peer",
});
}
function parseTimestampMs(value: string | undefined): number {
const parsed = value ? Number.parseInt(value, 10) : Number.NaN;
return Number.isFinite(parsed) ? parsed : Date.now();
}
export async function handleFeishuCommentEvent(
params: HandleFeishuCommentEventParams,
): Promise<void> {
const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId });
const feishuCfg = account.config;
const core = getFeishuRuntime();
const log = params.runtime?.log ?? console.log;
const error = params.runtime?.error ?? console.error;
const runtime = (params.runtime ?? { log, error }) as RuntimeEnv;
const turn = await resolveDriveCommentEventTurn({
cfg: params.cfg,
accountId: account.accountId,
event: params.event,
botOpenId: params.botOpenId,
logger: log,
});
if (!turn) {
log(
`feishu[${account.accountId}]: drive comment notice skipped ` +
`event=${params.event.event_id ?? "unknown"} comment=${params.event.comment_id ?? "unknown"}`,
);
return;
}
const commentTarget = buildFeishuCommentTarget({
fileType: turn.fileType,
fileToken: turn.fileToken,
commentId: turn.commentId,
});
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
const configAllowFrom = feishuCfg?.allowFrom ?? [];
const pairing = createChannelPairingController({
core,
channel: "feishu",
accountId: account.accountId,
});
const storeAllowFrom =
dmPolicy !== "allowlist" && dmPolicy !== "open"
? await pairing.readAllowFromStore().catch(() => [])
: [];
const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const senderAllowed = resolveFeishuAllowlistMatch({
allowFrom: effectiveDmAllowFrom,
senderId: turn.senderId,
senderIds: [turn.senderUserId],
}).allowed;
if (dmPolicy !== "open" && !senderAllowed) {
if (dmPolicy === "pairing") {
const client = createFeishuClient(account);
await pairing.issueChallenge({
senderId: turn.senderId,
senderIdLine: `Your Feishu user id: ${turn.senderId}`,
meta: { name: turn.senderId },
onCreated: ({ code }) => {
log(
`feishu[${account.accountId}]: comment pairing request sender=${turn.senderId} code=${code}`,
);
},
sendPairingReply: async (text) => {
await replyComment(client, {
file_token: turn.fileToken,
file_type: turn.fileType,
comment_id: turn.commentId,
content: text,
});
},
onReplyError: (err) => {
log(
`feishu[${account.accountId}]: comment pairing reply failed for ${turn.senderId}: ${String(err)}`,
);
},
});
} else {
log(
`feishu[${account.accountId}]: blocked unauthorized comment sender ${turn.senderId} ` +
`(dmPolicy=${dmPolicy}, comment=${turn.commentId})`,
);
}
return;
}
let effectiveCfg = params.cfg;
let route = core.channel.routing.resolveAgentRoute({
cfg: params.cfg,
channel: "feishu",
accountId: account.accountId,
peer: {
kind: "direct",
id: turn.senderId,
},
});
if (route.matchedBy === "default") {
const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined;
if (dynamicCfg?.enabled) {
const dynamicResult = await maybeCreateDynamicAgent({
cfg: params.cfg,
runtime: core,
senderOpenId: turn.senderId,
dynamicCfg,
log: (message) => log(message),
});
if (dynamicResult.created) {
effectiveCfg = dynamicResult.updatedCfg;
route = core.channel.routing.resolveAgentRoute({
cfg: dynamicResult.updatedCfg,
channel: "feishu",
accountId: account.accountId,
peer: {
kind: "direct",
id: turn.senderId,
},
});
log(
`feishu[${account.accountId}]: dynamic agent created for comment flow, route=${route.sessionKey}`,
);
}
}
}
const commentSessionKey = buildCommentSessionKey({
core,
route,
commentTarget,
});
const bodyForAgent = `[message_id: ${turn.messageId}]\n${turn.prompt}`;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: bodyForAgent,
BodyForAgent: bodyForAgent,
RawBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt,
CommandBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt,
From: `feishu:${turn.senderId}`,
To: commentTarget,
SessionKey: commentSessionKey,
AccountId: route.accountId,
ChatType: "direct",
ConversationLabel: turn.documentTitle
? `Feishu comment · ${turn.documentTitle}`
: "Feishu comment",
SenderName: turn.senderId,
SenderId: turn.senderId,
Provider: "feishu",
Surface: "feishu-comment",
MessageSid: turn.messageId,
Timestamp: parseTimestampMs(turn.timestamp),
WasMentioned: turn.isMentioned,
CommandAuthorized: false,
OriginatingChannel: "feishu",
OriginatingTo: commentTarget,
});
const storePath = core.channel.session.resolveStorePath(effectiveCfg.session?.store, {
agentId: route.agentId,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: commentSessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
error(
`feishu[${account.accountId}]: failed to record comment inbound session ${commentSessionKey}: ${String(err)}`,
);
},
});
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuCommentReplyDispatcher({
cfg: effectiveCfg,
agentId: route.agentId,
runtime,
accountId: account.accountId,
fileToken: turn.fileToken,
fileType: turn.fileType,
commentId: turn.commentId,
});
log(
`feishu[${account.accountId}]: dispatching drive comment to agent ` +
`(session=${commentSessionKey} comment=${turn.commentId} type=${turn.noticeType})`,
);
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg: effectiveCfg,
dispatcher,
replyOptions,
}),
});
log(
`feishu[${account.accountId}]: drive comment dispatch complete ` +
`(queuedFinal=${queuedFinal}, replies=${counts.final}, session=${commentSessionKey})`,
);
}