forked from EduCraft/curriculum-project-hub
feat: redesign Feishu project console
This commit is contained in:
+281
-108
@@ -52,22 +52,34 @@ import {
|
||||
type StagedMessageResourceBatch,
|
||||
} from "./resourceStaging.js";
|
||||
import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js";
|
||||
import { createSlashCommandRegistry, formatSlashHelpTarget, parseSlashHelpSubcommand, parseSlashInvocation } from "./slashCommands.js";
|
||||
import { createSlashCommandRegistry, parseSlashInvocation } from "./slashCommands.js";
|
||||
import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js";
|
||||
import {
|
||||
bindFeishuChatToProject,
|
||||
createFolder,
|
||||
createProjectFromFeishuChat,
|
||||
ensureOrganizationProjectSettings,
|
||||
moveProjectToFolder,
|
||||
renameProjectForActor,
|
||||
} from "../projectOnboarding.js";
|
||||
import {
|
||||
buildProjectManagementCard,
|
||||
buildMoveProjectCard,
|
||||
buildSessionHistoryCard,
|
||||
buildProjectOnboardingResolvedCard,
|
||||
buildUnboundChatOnboardingCard,
|
||||
projectOnboardingActionFromValue,
|
||||
type ProjectOnboardingActionValue,
|
||||
type ProjectOnboardingView,
|
||||
} from "./projectOnboardingCard.js";
|
||||
import {
|
||||
archiveCurrentRoleSession,
|
||||
browseFolderDestinations,
|
||||
listRoleSessionHistory,
|
||||
loadProjectConsole,
|
||||
resumeRoleSession,
|
||||
selectProjectGroupRole,
|
||||
} from "./projectConsole.js";
|
||||
import { SiloFixedWindowRateLimiter } from "../deployment/siloRateLimit.js";
|
||||
import { browseBindableFolder, discoverBindableProjects } from "../projectDiscovery.js";
|
||||
|
||||
@@ -113,6 +125,7 @@ interface TriggerRunContext {
|
||||
readonly projectId: string;
|
||||
readonly senderOpenId: string;
|
||||
readonly actor: TriggerActor;
|
||||
readonly roleId: string;
|
||||
}
|
||||
|
||||
type StartAgentRunOutcome = "started" | "queued" | "rejected" | "locked" | "skipped";
|
||||
@@ -150,9 +163,6 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
: new SiloFixedWindowRateLimiter(deps.maxFeishuEventsPerMinute, 60_000);
|
||||
const slashCommands = createSlashCommandRegistry({
|
||||
prisma: deps.prisma,
|
||||
settings: deps.settings,
|
||||
logger: deps.logger,
|
||||
triggerQueue,
|
||||
});
|
||||
const batchContexts = new Map<string, TriggerRunContext>();
|
||||
// runId → AbortController for live runs. Registered when a run starts,
|
||||
@@ -178,10 +188,48 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
}
|
||||
}, deps.messageBatcherOptions);
|
||||
|
||||
async function projectConsoleCard(
|
||||
projectId: string,
|
||||
chatId: string,
|
||||
actorOpenId: string,
|
||||
title?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const state = await loadProjectConsole(deps.prisma, { projectId, chatId });
|
||||
const actor = { feishuOpenId: actorOpenId, chatId };
|
||||
const [manageDecision, organization, roleDecisions] = await Promise.all([
|
||||
authorizer.can({ actor, action: "collaborator.manage", resource: { type: "PROJECT", id: projectId } }),
|
||||
resolveSingleActiveOrganizationForFeishuUser(actorOpenId),
|
||||
Promise.all(state.roles.map(async (role) => ({
|
||||
role,
|
||||
decision: await authorizer.can({
|
||||
actor,
|
||||
action: "role.trigger",
|
||||
resource: { type: "PROJECT", id: projectId },
|
||||
roleId: role.roleId,
|
||||
}),
|
||||
}))),
|
||||
]);
|
||||
const visibleRoles = roleDecisions.filter(({ decision }) => decision.allowed).map(({ role }) => role);
|
||||
const isOrgAdmin = organization.status === "ok" &&
|
||||
organization.organizationId === state.organizationId && isOrgAdminRole(organization.role);
|
||||
return buildProjectManagementCard({
|
||||
organizationId: state.organizationId,
|
||||
projectId: state.projectId,
|
||||
projectName: state.projectName,
|
||||
breadcrumb: state.breadcrumb,
|
||||
selectedRole: state.selectedRole,
|
||||
roles: visibleRoles,
|
||||
currentSession: state.currentSession,
|
||||
canManageProject: manageDecision.allowed || isOrgAdmin,
|
||||
canCreateFolder: isOrgAdmin,
|
||||
...(title !== undefined ? { title } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function startAgentRun(
|
||||
context: TriggerRunContext,
|
||||
cleanPrompt: string,
|
||||
options: { readonly queueIfLocked?: boolean } = {},
|
||||
options: { readonly queueIfLocked?: boolean; readonly nativeSlash?: boolean } = {},
|
||||
): Promise<StartAgentRunOutcome> {
|
||||
const { msg, rt, chatId, projectId, senderOpenId, actor } = context;
|
||||
const sendOptions = sendOptionsForTriggerMessage(msg);
|
||||
@@ -195,7 +243,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
return "rejected";
|
||||
}
|
||||
if (!queueIfLocked) return "locked";
|
||||
return enqueueLockedTrigger(context, cleanPrompt);
|
||||
return enqueueLockedTrigger(context, cleanPrompt, options.nativeSlash === true ? "native_compact" : "conversation");
|
||||
}
|
||||
|
||||
const project = await deps.prisma.project.findUnique({
|
||||
@@ -207,15 +255,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
// ADR-0017: role-as-data. Parse a leading `/<role>` command; unknown
|
||||
// slashes are left as literal text (extractRole returns null). Falls back
|
||||
// to "draft" when no role is named — the registry degrades gracefully.
|
||||
const models = await deps.settings.modelRegistry({ projectId });
|
||||
const { roleId: parsedRole, prompt: parsedAgentPrompt } = extractRole(cleanPrompt, models);
|
||||
const roleId = parsedRole ?? "draft";
|
||||
const roleId = context.roleId;
|
||||
// ADR-0019: role trigger is the second gate after project agent.trigger.
|
||||
// Unconfigured roles remain open for back-compat; configured roles require
|
||||
// a matching active RoleTriggerGrant for any resolved principal.
|
||||
// Configured roles require a matching active RoleTriggerGrant for any
|
||||
// resolved principal; otherwise the role remains open within the project.
|
||||
const roleDecision = await authorizer.can({
|
||||
actor,
|
||||
action: "role.trigger",
|
||||
@@ -262,8 +306,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
projectWorkspaceRoot,
|
||||
deps.resourceLimits,
|
||||
);
|
||||
const agentPrompt = appendStagedResourcePaths(parsedAgentPrompt, stagedResources, project.workspaceDir);
|
||||
const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext);
|
||||
const agentPrompt = appendStagedResourcePaths(cleanPrompt, stagedResources, project.workspaceDir);
|
||||
const promptForAgent = options.nativeSlash === true
|
||||
? cleanPrompt
|
||||
: withFeishuTriggerContext(agentPrompt, feishuTriggerContext);
|
||||
|
||||
// Linearization point for org lifecycle + session/run/lock admission.
|
||||
// FOR SHARE on the Organization row conflicts with a concurrent status
|
||||
@@ -623,10 +669,16 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
return "started";
|
||||
}
|
||||
|
||||
async function enqueueLockedTrigger(context: TriggerRunContext, cleanPrompt: string): Promise<StartAgentRunOutcome> {
|
||||
async function enqueueLockedTrigger(
|
||||
context: TriggerRunContext,
|
||||
cleanPrompt: string,
|
||||
executionKind: QueuedTrigger["executionKind"] = "conversation",
|
||||
): Promise<StartAgentRunOutcome> {
|
||||
const position = triggerQueue.enqueue(context.projectId, {
|
||||
chatId: context.chatId,
|
||||
prompt: cleanPrompt,
|
||||
roleId: context.roleId,
|
||||
executionKind,
|
||||
msg: context.msg,
|
||||
senderOpenId: context.senderOpenId,
|
||||
actor: context.actor,
|
||||
@@ -664,7 +716,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
|
||||
const next = triggerQueue.dequeue(projectId);
|
||||
if (next === null) return;
|
||||
const outcome = await startAgentRun(contextFromQueuedTrigger(next, rt), next.prompt, { queueIfLocked: false });
|
||||
const outcome = await startAgentRun(contextFromQueuedTrigger(next, rt), next.prompt, {
|
||||
queueIfLocked: false,
|
||||
nativeSlash: next.executionKind === "native_compact",
|
||||
});
|
||||
if (outcome === "started") return;
|
||||
if (outcome === "locked") {
|
||||
requeueTrigger(next);
|
||||
@@ -681,6 +736,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
projectId: trigger.projectId,
|
||||
senderOpenId: trigger.senderOpenId,
|
||||
actor: trigger.actor,
|
||||
roleId: trigger.roleId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -688,6 +744,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
const position = triggerQueue.enqueue(trigger.projectId, {
|
||||
chatId: trigger.chatId,
|
||||
prompt: trigger.prompt,
|
||||
roleId: trigger.roleId,
|
||||
executionKind: trigger.executionKind,
|
||||
msg: trigger.msg,
|
||||
senderOpenId: trigger.senderOpenId,
|
||||
actor: trigger.actor,
|
||||
@@ -761,6 +819,169 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
if (organization.organizationId !== action.organization_id) {
|
||||
throw new Error("project onboarding organization mismatch");
|
||||
}
|
||||
if (
|
||||
action.action === "select_agent_role" ||
|
||||
action.action === "new_agent_session" ||
|
||||
action.action === "show_session_history" ||
|
||||
action.action === "resume_agent_session" ||
|
||||
action.action === "compact_agent_session"
|
||||
) {
|
||||
const projectId = action.project_id;
|
||||
if (projectId === undefined) throw new Error(`${action.action} requires project_id`);
|
||||
const binding = await deps.prisma.projectGroupBinding.findFirst({
|
||||
where: { projectId, chatId, archivedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (binding === null) throw new Error("project console action requires the project to be bound to this chat");
|
||||
const state = await loadProjectConsole(deps.prisma, { projectId, chatId });
|
||||
const targetRole = action.action === "select_agent_role"
|
||||
? state.roles.find((role) => role.id === action.agent_role_id)
|
||||
: state.selectedRole;
|
||||
if (targetRole === undefined) throw new Error("selected Agent role is unavailable");
|
||||
const actor = { feishuOpenId: operatorOpenId, chatId };
|
||||
const [triggerDecision, roleDecision] = await Promise.all([
|
||||
authorizer.can({ actor, action: "agent.trigger", resource: { type: "PROJECT", id: projectId } }),
|
||||
authorizer.can({
|
||||
actor,
|
||||
action: "role.trigger",
|
||||
resource: { type: "PROJECT", id: projectId },
|
||||
roleId: targetRole.roleId,
|
||||
}),
|
||||
]);
|
||||
if (!triggerDecision.allowed || !roleDecision.allowed || roleDecision.actorUserId === undefined) {
|
||||
throw new Error(`not authorized for Agent role ${targetRole.roleId}`);
|
||||
}
|
||||
|
||||
if (action.action === "select_agent_role") {
|
||||
await selectProjectGroupRole(deps.prisma, {
|
||||
projectId,
|
||||
chatId,
|
||||
agentRoleId: targetRole.id,
|
||||
actorUserId: roleDecision.actorUserId,
|
||||
});
|
||||
if (messageId === undefined) throw new Error("role selection requires message id");
|
||||
await patchCard(rt, messageId, await projectConsoleCard(
|
||||
projectId,
|
||||
chatId,
|
||||
operatorOpenId,
|
||||
`已切换至 ${targetRole.label}`,
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (action.action === "new_agent_session") {
|
||||
await archiveCurrentRoleSession(deps.prisma, {
|
||||
projectId,
|
||||
chatId,
|
||||
actorUserId: roleDecision.actorUserId,
|
||||
});
|
||||
if (messageId === undefined) throw new Error("new session requires message id");
|
||||
await patchCard(rt, messageId, await projectConsoleCard(projectId, chatId, operatorOpenId, "已新开会话"));
|
||||
return;
|
||||
}
|
||||
if (action.action === "show_session_history") {
|
||||
const sessions = await listRoleSessionHistory(deps.prisma, { projectId, chatId });
|
||||
if (messageId === undefined) throw new Error("session history requires message id");
|
||||
await patchCard(rt, messageId, buildSessionHistoryCard({
|
||||
organizationId: state.organizationId,
|
||||
projectId,
|
||||
roleLabel: state.selectedRole.label,
|
||||
sessions,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (action.action === "resume_agent_session") {
|
||||
if (action.session_id === undefined) throw new Error("resume_agent_session requires session_id");
|
||||
await resumeRoleSession(deps.prisma, {
|
||||
projectId,
|
||||
chatId,
|
||||
sessionId: action.session_id,
|
||||
actorUserId: roleDecision.actorUserId,
|
||||
});
|
||||
if (messageId === undefined) throw new Error("session resume requires message id");
|
||||
await patchCard(rt, messageId, await projectConsoleCard(projectId, chatId, operatorOpenId, "已恢复历史会话"));
|
||||
return;
|
||||
}
|
||||
if (state.currentSession === null || !state.currentSession.sdkSessionReady) {
|
||||
throw new Error("当前角色还没有可压缩的 Claude 会话");
|
||||
}
|
||||
const commandMessage: MessageReceiveEvent["message"] = {
|
||||
message_id: messageId ?? randomUUID(),
|
||||
chat_id: chatId,
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "/compact" }),
|
||||
mentions: [],
|
||||
};
|
||||
await startAgentRun({
|
||||
msg: commandMessage,
|
||||
rt,
|
||||
chatId,
|
||||
projectId,
|
||||
senderOpenId: operatorOpenId,
|
||||
actor,
|
||||
roleId: state.selectedRole.roleId,
|
||||
}, "/compact", { nativeSlash: true });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
action.action === "browse_move_destination" ||
|
||||
action.action === "move_project" ||
|
||||
action.action === "create_folder"
|
||||
) {
|
||||
const projectId = action.project_id;
|
||||
if (projectId === undefined) throw new Error(`${action.action} requires project_id`);
|
||||
const state = await loadProjectConsole(deps.prisma, { projectId, chatId });
|
||||
const isOrgAdmin = isOrgAdminRole(organization.role);
|
||||
const manageDecision = await authorizer.can({
|
||||
actor: { feishuOpenId: operatorOpenId, chatId },
|
||||
action: "collaborator.manage",
|
||||
resource: { type: "PROJECT", id: projectId },
|
||||
});
|
||||
if (!isOrgAdmin && !manageDecision.allowed) throw new Error("project MANAGE permission is required");
|
||||
|
||||
if (action.action === "browse_move_destination") {
|
||||
const page = await browseFolderDestinations(deps.prisma, {
|
||||
organizationId: state.organizationId,
|
||||
folderId: action.folder_id ?? null,
|
||||
});
|
||||
if (messageId === undefined) throw new Error("folder navigation requires message id");
|
||||
await patchCard(rt, messageId, buildMoveProjectCard({
|
||||
organizationId: state.organizationId,
|
||||
projectId,
|
||||
projectName: state.projectName,
|
||||
...page,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (action.action === "move_project") {
|
||||
await moveProjectToFolder(deps.prisma, { projectId, folderId: action.folder_id ?? null });
|
||||
await writeAudit(deps.prisma, {
|
||||
projectId,
|
||||
...(manageDecision.actorUserId !== undefined ? { actorUserId: manageDecision.actorUserId } : {}),
|
||||
action: "project.moved_from_feishu",
|
||||
metadata: { folderId: action.folder_id ?? null },
|
||||
});
|
||||
if (messageId === undefined) throw new Error("project move requires message id");
|
||||
await patchCard(rt, messageId, await projectConsoleCard(projectId, chatId, operatorOpenId, "项目已移动"));
|
||||
return;
|
||||
}
|
||||
if (!isOrgAdmin) throw new Error("creating an Organization folder requires OWNER or ADMIN");
|
||||
const folderName = event.action.form_value?.["folder_name"];
|
||||
if (typeof folderName !== "string") throw new Error("create folder form is missing folder_name");
|
||||
const folder = await createFolder(deps.prisma, {
|
||||
organizationId: state.organizationId,
|
||||
name: folderName.slice(0, 100),
|
||||
...(state.folderId !== null ? { parentId: state.folderId } : {}),
|
||||
});
|
||||
await writeAudit(deps.prisma, {
|
||||
projectId,
|
||||
action: "folder.created_from_feishu",
|
||||
metadata: { folderId: folder.id, parentId: state.folderId, name: folder.name },
|
||||
});
|
||||
if (messageId === undefined) throw new Error("folder creation requires message id");
|
||||
await patchCard(rt, messageId, await projectConsoleCard(projectId, chatId, operatorOpenId, "目录已创建"));
|
||||
return;
|
||||
}
|
||||
if (action.action === "rename_project") {
|
||||
const projectId = action.project_id;
|
||||
if (projectId === undefined) throw new Error("rename_project action requires project_id");
|
||||
@@ -778,12 +999,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
name: submittedName.slice(0, 100),
|
||||
});
|
||||
if (messageId === undefined) throw new Error("project rename requires message id");
|
||||
await patchCard(rt, messageId, buildProjectManagementCard({
|
||||
organizationId: organization.organizationId,
|
||||
projectId: renamed.projectId,
|
||||
projectName: renamed.name,
|
||||
title: "项目名称已更新",
|
||||
}));
|
||||
await patchCard(rt, messageId, await projectConsoleCard(
|
||||
renamed.projectId,
|
||||
chatId,
|
||||
operatorOpenId,
|
||||
"项目名称已更新",
|
||||
));
|
||||
deps.logger.info({ chatId, projectId, operatorOpenId }, "project onboarding: project renamed");
|
||||
return;
|
||||
}
|
||||
@@ -834,12 +1055,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
folderId: action.folder_id,
|
||||
});
|
||||
if (messageId !== undefined) {
|
||||
await patchCard(rt, messageId, buildProjectManagementCard({
|
||||
organizationId: organization.organizationId,
|
||||
projectId: project.projectId,
|
||||
projectName: name,
|
||||
title: "已创建并绑定项目",
|
||||
}));
|
||||
await patchCard(rt, messageId, await projectConsoleCard(
|
||||
project.projectId,
|
||||
chatId,
|
||||
operatorOpenId,
|
||||
"已创建并绑定项目",
|
||||
));
|
||||
}
|
||||
deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: created project from chat");
|
||||
return;
|
||||
@@ -863,16 +1084,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
chatId,
|
||||
});
|
||||
if (messageId !== undefined) {
|
||||
const boundProject = await deps.prisma.project.findUniqueOrThrow({
|
||||
where: { id: project.projectId },
|
||||
select: { name: true },
|
||||
});
|
||||
await patchCard(rt, messageId, buildProjectManagementCard({
|
||||
organizationId: organization.organizationId,
|
||||
projectId: project.projectId,
|
||||
projectName: boundProject.name,
|
||||
title: "已绑定项目",
|
||||
}));
|
||||
await patchCard(rt, messageId, await projectConsoleCard(
|
||||
project.projectId,
|
||||
chatId,
|
||||
operatorOpenId,
|
||||
"已绑定项目",
|
||||
));
|
||||
}
|
||||
deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: bound existing project");
|
||||
} catch (e) {
|
||||
@@ -905,7 +1122,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
}
|
||||
const binding = await deps.prisma.projectGroupBinding.findFirst({
|
||||
where: { chatId, archivedAt: null },
|
||||
select: { projectId: true },
|
||||
select: { projectId: true, selectedRole: { select: { roleId: true } } },
|
||||
});
|
||||
if (binding === null) {
|
||||
deps.logger.debug({ chatId }, "feishu interrupt: chat not bound to any project");
|
||||
@@ -1010,7 +1227,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
// ADR-0001: resolve chat → project. Unknown chat ⇒ not a project group.
|
||||
const binding = await deps.prisma.projectGroupBinding.findFirst({
|
||||
where: { chatId, archivedAt: null },
|
||||
select: { projectId: true },
|
||||
select: { projectId: true, selectedRole: { select: { roleId: true } } },
|
||||
});
|
||||
if (binding === null) {
|
||||
deps.logger.debug({ chatId }, "feishu trigger: chat not bound to any project");
|
||||
@@ -1082,53 +1299,33 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
}
|
||||
}
|
||||
if (cleanPrompt === null) return;
|
||||
const runContext: TriggerRunContext = { msg, rt, chatId, projectId, senderOpenId, actor };
|
||||
const runContext: TriggerRunContext = {
|
||||
msg,
|
||||
rt,
|
||||
chatId,
|
||||
projectId,
|
||||
senderOpenId,
|
||||
actor,
|
||||
roleId: binding.selectedRole.roleId,
|
||||
};
|
||||
|
||||
// Slash commands: session management, not agent runs. These bypass the
|
||||
// batcher. Session commands bypass the lock because they don't create runs;
|
||||
// role/unknown slash prompts still use the normal run path and queue if locked.
|
||||
// Hub owns a closed slash-command protocol. Unknown commands fail visibly;
|
||||
// they are never downgraded to Agent text or forwarded to the SDK.
|
||||
if (cleanPrompt.startsWith("/")) {
|
||||
const invocation = parseSlashInvocation(cleanPrompt);
|
||||
const helpSubcommandTarget = invocation === null ? null : parseSlashHelpSubcommand(invocation);
|
||||
if (helpSubcommandTarget !== null) {
|
||||
// Help is generated on demand because role/tool configuration is runtime data.
|
||||
const models = await deps.settings.modelRegistry({ projectId });
|
||||
await sendText(rt, chatId, formatSlashHelpTarget(helpSubcommandTarget, models, slashCommands), sendOptionsForTriggerMessage(msg));
|
||||
return;
|
||||
}
|
||||
|
||||
if (invocation !== null) {
|
||||
if (invocation.name === "project") {
|
||||
if (invocation.args.length > 0) {
|
||||
await sendText(rt, chatId, "用法: /project(打开当前项目管理卡片)", sendOptionsForTriggerMessage(msg));
|
||||
await sendText(rt, chatId, "用法: /project", sendOptionsForTriggerMessage(msg));
|
||||
return;
|
||||
}
|
||||
const project = await deps.prisma.project.findUniqueOrThrow({
|
||||
where: { id: projectId },
|
||||
select: { id: true, name: true, organizationId: true },
|
||||
});
|
||||
const organization = await resolveSingleActiveOrganizationForFeishuUser(senderOpenId);
|
||||
if (organization.status !== "ok" || organization.organizationId !== project.organizationId) {
|
||||
await sendText(rt, chatId, "当前身份不属于该项目组织。", sendOptionsForTriggerMessage(msg));
|
||||
return;
|
||||
}
|
||||
if (!isOrgAdminRole(organization.role)) {
|
||||
const decision = await authorizer.can({
|
||||
actor,
|
||||
action: "collaborator.manage",
|
||||
resource: { type: "PROJECT", id: projectId },
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
await sendText(rt, chatId, "你没有管理当前项目的权限。", sendOptionsForTriggerMessage(msg));
|
||||
return;
|
||||
}
|
||||
}
|
||||
await sendCard(rt, chatId, buildProjectManagementCard({
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
}), sendOptionsForTriggerMessage(msg));
|
||||
deps.logger.info({ chatId, projectId, senderOpenId }, "feishu project management card opened");
|
||||
await sendCard(
|
||||
rt,
|
||||
chatId,
|
||||
await projectConsoleCard(projectId, chatId, senderOpenId),
|
||||
sendOptionsForTriggerMessage(msg),
|
||||
);
|
||||
deps.logger.info({ chatId, projectId, senderOpenId }, "feishu project console opened");
|
||||
return;
|
||||
}
|
||||
const slashCommand = slashCommands.get(invocation.name);
|
||||
@@ -1146,8 +1343,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await startAgentRun(runContext, cleanPrompt);
|
||||
const name = invocation?.name ?? cleanPrompt.slice(1).trim();
|
||||
await sendText(rt, chatId, `未知 slash 命令 /${name}。使用 /help 查看可用命令。`, sendOptionsForTriggerMessage(msg));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1161,11 +1358,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
await enqueueLockedTrigger(runContext, cleanPrompt);
|
||||
return;
|
||||
}
|
||||
const key = messageBatchKey(chatId, senderOpenId);
|
||||
const key = messageBatchKey(chatId, senderOpenId, runContext.roleId);
|
||||
if (!batchContexts.has(key)) {
|
||||
batchContexts.set(key, runContext);
|
||||
}
|
||||
await messageBatcher.enqueue(chatId, senderOpenId, cleanPrompt);
|
||||
await messageBatcher.enqueue(chatId, senderOpenId, cleanPrompt, runContext.roleId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1750,27 +1947,3 @@ async function compensateFailedResourceAdmission(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a leading `/<role>` command from a prompt (e.g. `/draft 帮我写教案`).
|
||||
* Returns the role id and the remaining prompt with the command stripped.
|
||||
* Control/help commands already handled upstream are ignored here — they never
|
||||
* reach this function.
|
||||
*
|
||||
* Unknown `/foo` that isn't a registered role: returns `null` role and the
|
||||
* original prompt unchanged (the slash is treated as literal text). Role
|
||||
* resolution still happens via the registry's fallback.
|
||||
*/
|
||||
export function extractRole(
|
||||
prompt: string,
|
||||
registry: { role(id: string): unknown },
|
||||
): { roleId: string | null; prompt: string } {
|
||||
const m = /^\/(\S+)\s*/.exec(prompt);
|
||||
if (m === null || m[1] === undefined) return { roleId: null, prompt };
|
||||
const roleId = m[1];
|
||||
if (registry.role(roleId) === undefined) {
|
||||
// Unknown slash command — leave the prompt as-is (no silent role switch).
|
||||
return { roleId: null, prompt };
|
||||
}
|
||||
return { roleId, prompt: prompt.slice(m[0].length) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user