forked from bai/curriculum-project-hub
feat: add deployable alpha silo
This commit is contained in:
@@ -25,6 +25,7 @@ export interface FeishuConfig {
|
||||
export interface FeishuRuntime {
|
||||
readonly client: lark.Client;
|
||||
readonly logger: FastifyBaseLogger;
|
||||
readonly isListenerReady?: () => boolean;
|
||||
}
|
||||
|
||||
export interface OutboundPayload {
|
||||
@@ -494,6 +495,7 @@ export async function downloadMessageFile(
|
||||
workspaceDir: string,
|
||||
workspaceRelativePath: string,
|
||||
resourceType: "image" | "file",
|
||||
maxBytes?: number,
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await withRetry(async () => {
|
||||
@@ -509,6 +511,7 @@ export async function downloadMessageFile(
|
||||
workspaceDir,
|
||||
workspaceRelativePath,
|
||||
source,
|
||||
maxBytes,
|
||||
);
|
||||
} catch (error) {
|
||||
source.destroy();
|
||||
@@ -911,20 +914,41 @@ export function createLarkClient(config: FeishuConfig): lark.Client {
|
||||
});
|
||||
}
|
||||
|
||||
export function startFeishuListenerWithClient(
|
||||
export async function startFeishuListenerWithClient(
|
||||
config: FeishuConfig,
|
||||
client: lark.Client,
|
||||
logger: FastifyBaseLogger,
|
||||
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
||||
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
|
||||
): FeishuRuntime {
|
||||
onTerminalError?: (error: Error) => void,
|
||||
): Promise<FeishuRuntime> {
|
||||
let state: "STARTING" | "READY" | "FAILED" = "STARTING";
|
||||
let resolveReady!: () => void;
|
||||
let rejectReady!: (error: Error) => void;
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
resolveReady = resolve;
|
||||
rejectReady = reject;
|
||||
});
|
||||
const wsClient = new lark.WSClient({
|
||||
appId: config.appId,
|
||||
appSecret: config.appSecret,
|
||||
domain: lark.Domain.Feishu,
|
||||
loggerLevel: lark.LoggerLevel.info,
|
||||
handshakeTimeoutMs: 10_000,
|
||||
onReady: () => {
|
||||
state = "READY";
|
||||
resolveReady();
|
||||
logger.info("Feishu listener ready");
|
||||
},
|
||||
onError: (error) => {
|
||||
const wasStarting = state === "STARTING";
|
||||
state = "FAILED";
|
||||
logger.error({ err: error }, "Feishu listener terminal failure");
|
||||
if (wasStarting) rejectReady(error);
|
||||
else onTerminalError?.(error);
|
||||
},
|
||||
});
|
||||
const rt: FeishuRuntime = { client, logger };
|
||||
const rt: FeishuRuntime = { client, logger, isListenerReady: () => state === "READY" };
|
||||
const handlers: Record<string, (data: unknown) => Promise<void>> = {
|
||||
"im.message.receive_v1": async (data) => {
|
||||
try { await onMessage(data as MessageReceiveEvent, rt); }
|
||||
@@ -938,15 +962,25 @@ export function startFeishuListenerWithClient(
|
||||
.catch((e) => { logger.error({ err: e }, "feishu card action handler threw"); });
|
||||
};
|
||||
}
|
||||
void wsClient.start({ eventDispatcher: new lark.EventDispatcher({}).register(handlers) });
|
||||
await wsClient.start({ eventDispatcher: new lark.EventDispatcher({}).register(handlers) });
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const startupTimeout = new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error("Feishu listener did not become ready within 15 seconds")), 15_000);
|
||||
timeout.unref();
|
||||
});
|
||||
try {
|
||||
await Promise.race([ready, startupTimeout]);
|
||||
} finally {
|
||||
if (timeout !== undefined) clearTimeout(timeout);
|
||||
}
|
||||
return rt;
|
||||
}
|
||||
|
||||
export function startFeishuListener(
|
||||
export async function startFeishuListener(
|
||||
config: FeishuConfig,
|
||||
logger: FastifyBaseLogger,
|
||||
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
||||
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
|
||||
): FeishuRuntime {
|
||||
): Promise<FeishuRuntime> {
|
||||
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage, onCardAction);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { lockActiveOrganization } from "../org/status.js";
|
||||
|
||||
export type FeishuPrincipalKind = "USER" | "CHAT" | "DEPARTMENT" | "USER_GROUP" | "EVENT" | "CALLBACK";
|
||||
|
||||
export interface ScopedFeishuIdentity {
|
||||
readonly identityId: string;
|
||||
readonly connectionId: string;
|
||||
readonly organizationId: string;
|
||||
readonly userId: string;
|
||||
readonly openId: string;
|
||||
readonly unionId: string | null;
|
||||
readonly principalId: string;
|
||||
readonly displayName: string;
|
||||
readonly avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export function scopedFeishuPrincipalId(
|
||||
kind: FeishuPrincipalKind,
|
||||
connectionId: string,
|
||||
providerLocalId: string,
|
||||
): string {
|
||||
const normalizedConnectionId = nonEmpty(connectionId, "Feishu connectionId");
|
||||
const normalizedProviderLocalId = nonEmpty(providerLocalId, `Feishu ${kind} identifier`);
|
||||
const digest = createHash("sha256")
|
||||
.update("cph-feishu-principal\0")
|
||||
.update(kind, "utf8")
|
||||
.update("\0")
|
||||
.update(normalizedConnectionId, "utf8")
|
||||
.update("\0")
|
||||
.update(normalizedProviderLocalId, "utf8")
|
||||
.digest("base64url");
|
||||
return `feishu:${kind.toLowerCase()}:${normalizedConnectionId}:${digest}`;
|
||||
}
|
||||
|
||||
export async function upsertScopedFeishuIdentity(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly connectionId: string;
|
||||
readonly openId: string;
|
||||
readonly unionId?: string | undefined;
|
||||
readonly displayName: string;
|
||||
readonly avatarUrl?: string | undefined;
|
||||
},
|
||||
): Promise<ScopedFeishuIdentity> {
|
||||
return prisma.$transaction((tx) => upsertScopedFeishuIdentityInTransaction(tx, input));
|
||||
}
|
||||
|
||||
export async function upsertScopedFeishuIdentityInTransaction(
|
||||
prisma: Prisma.TransactionClient,
|
||||
input: {
|
||||
readonly connectionId: string;
|
||||
readonly expectedOrganizationId?: string | undefined;
|
||||
readonly openId: string;
|
||||
readonly unionId?: string | undefined;
|
||||
readonly displayName: string;
|
||||
readonly avatarUrl?: string | undefined;
|
||||
},
|
||||
): Promise<ScopedFeishuIdentity> {
|
||||
const connectionId = nonEmpty(input.connectionId, "Feishu connectionId");
|
||||
const openId = nonEmpty(input.openId, "Feishu openId");
|
||||
const displayName = nonEmpty(input.displayName, "Feishu displayName");
|
||||
const unionId = optionalNonEmpty(input.unionId, "Feishu unionId");
|
||||
const avatarUrl = optionalNonEmpty(input.avatarUrl, "Feishu avatarUrl");
|
||||
const principalId = scopedFeishuPrincipalId("USER", connectionId, openId);
|
||||
const userId = deterministicFeishuUserId(connectionId, openId);
|
||||
|
||||
const connection = await lockActiveConnection(prisma, connectionId);
|
||||
if (input.expectedOrganizationId !== undefined &&
|
||||
input.expectedOrganizationId !== connection.organizationId) {
|
||||
throw new Error("Feishu identity Organization scope mismatch");
|
||||
}
|
||||
const identity = await prisma.feishuUserIdentity.upsert({
|
||||
where: { connectionId_openId: { connectionId, openId } },
|
||||
update: {
|
||||
...(unionId !== undefined ? { unionId } : {}),
|
||||
user: {
|
||||
update: {
|
||||
displayName,
|
||||
...(avatarUrl !== undefined ? { avatarUrl } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
create: {
|
||||
openId,
|
||||
...(unionId !== undefined ? { unionId } : {}),
|
||||
connection: { connect: { id: connectionId } },
|
||||
user: {
|
||||
connectOrCreate: {
|
||||
where: { id: userId },
|
||||
create: {
|
||||
id: userId,
|
||||
feishuOpenId: principalId,
|
||||
displayName,
|
||||
...(avatarUrl !== undefined ? { avatarUrl } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
return scopedIdentity(connection.organizationId, identity);
|
||||
}
|
||||
|
||||
export async function resolveScopedFeishuIdentity(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly connectionId: string;
|
||||
readonly openId: string;
|
||||
readonly expectedOrganizationId?: string | undefined;
|
||||
},
|
||||
): Promise<ScopedFeishuIdentity | null> {
|
||||
const connectionId = nonEmpty(input.connectionId, "Feishu connectionId");
|
||||
const openId = nonEmpty(input.openId, "Feishu openId");
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const connection = await lockActiveConnection(tx, connectionId);
|
||||
if (input.expectedOrganizationId !== undefined &&
|
||||
input.expectedOrganizationId !== connection.organizationId) {
|
||||
throw new Error("Feishu identity Organization scope mismatch");
|
||||
}
|
||||
const identity = await tx.feishuUserIdentity.findUnique({
|
||||
where: { connectionId_openId: { connectionId, openId } },
|
||||
include: { user: true },
|
||||
});
|
||||
return identity === null ? null : scopedIdentity(connection.organizationId, identity);
|
||||
});
|
||||
}
|
||||
|
||||
async function lockActiveConnection(
|
||||
prisma: Prisma.TransactionClient,
|
||||
connectionId: string,
|
||||
): Promise<{ readonly organizationId: string }> {
|
||||
const scope = await prisma.organizationFeishuApplicationConnection.findUnique({
|
||||
where: { id: connectionId },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
if (scope === null) throw new Error("active Feishu Application Connection not found");
|
||||
await lockActiveOrganization(prisma, scope.organizationId);
|
||||
await prisma.$queryRaw`
|
||||
SELECT "id" FROM "OrganizationFeishuApplicationConnection"
|
||||
WHERE "id" = ${connectionId} FOR SHARE
|
||||
`;
|
||||
const connection = await prisma.organizationFeishuApplicationConnection.findUnique({
|
||||
where: { id: connectionId },
|
||||
select: {
|
||||
organizationId: true,
|
||||
status: true,
|
||||
activeSecretVersion: { select: { connectionId: true, retiredAt: true } },
|
||||
},
|
||||
});
|
||||
if (connection === null || connection.status !== "ACTIVE" || connection.activeSecretVersion === null ||
|
||||
connection.organizationId !== scope.organizationId ||
|
||||
connection.activeSecretVersion.connectionId !== connectionId ||
|
||||
connection.activeSecretVersion.retiredAt !== null) {
|
||||
throw new Error("active Feishu Application Connection not found");
|
||||
}
|
||||
return { organizationId: connection.organizationId };
|
||||
}
|
||||
|
||||
function scopedIdentity(
|
||||
organizationId: string,
|
||||
identity: {
|
||||
readonly id: string;
|
||||
readonly connectionId: string;
|
||||
readonly userId: string;
|
||||
readonly openId: string;
|
||||
readonly unionId: string | null;
|
||||
readonly user: {
|
||||
readonly displayName: string;
|
||||
readonly avatarUrl: string | null;
|
||||
};
|
||||
},
|
||||
): ScopedFeishuIdentity {
|
||||
return {
|
||||
identityId: identity.id,
|
||||
connectionId: identity.connectionId,
|
||||
organizationId,
|
||||
userId: identity.userId,
|
||||
openId: identity.openId,
|
||||
unionId: identity.unionId,
|
||||
principalId: scopedFeishuPrincipalId("USER", identity.connectionId, identity.openId),
|
||||
displayName: identity.user.displayName,
|
||||
avatarUrl: identity.user.avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function deterministicFeishuUserId(connectionId: string, openId: string): string {
|
||||
const digest = createHash("sha256")
|
||||
.update("cph-feishu-user\0")
|
||||
.update(connectionId, "utf8")
|
||||
.update("\0")
|
||||
.update(openId, "utf8")
|
||||
.digest("base64url");
|
||||
return `feishu_${digest}`;
|
||||
}
|
||||
|
||||
function nonEmpty(value: string, label: string): string {
|
||||
const normalized = value.trim();
|
||||
if (normalized === "") throw new Error(`${label} is required`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function optionalNonEmpty(value: string | undefined, label: string): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return nonEmpty(value, label);
|
||||
}
|
||||
@@ -37,8 +37,12 @@ export async function stageMessageResources(
|
||||
messageId: string,
|
||||
requests: readonly MessageResourceStageRequest[],
|
||||
workspaceRoot: string,
|
||||
limits?: { readonly maxFiles: number; readonly maxBytesPerFile: number },
|
||||
): Promise<StagedMessageResourceBatch | null> {
|
||||
if (requests.length === 0) return null;
|
||||
if (limits !== undefined && requests.length > limits.maxFiles) {
|
||||
throw new Error(`Feishu message has ${requests.length} resources; limit is ${limits.maxFiles}`);
|
||||
}
|
||||
const stagingBase = await requirePrivateStagingBase(workspaceRoot);
|
||||
const stagingRoot = join(stagingBase, randomUUID());
|
||||
const resources: StagedMessageResource[] = [];
|
||||
@@ -53,6 +57,7 @@ export async function stageMessageResources(
|
||||
stagingRoot,
|
||||
`resource-${index}`,
|
||||
request.resourceType,
|
||||
limits?.maxBytesPerFile,
|
||||
);
|
||||
resources.push({
|
||||
resourceType: request.resourceType,
|
||||
|
||||
+132
-11
@@ -68,6 +68,7 @@ import {
|
||||
type OnboardingProjectOption,
|
||||
type ProjectOnboardingActionValue,
|
||||
} from "./projectOnboardingCard.js";
|
||||
import { SiloFixedWindowRateLimiter } from "../deployment/siloRateLimit.js";
|
||||
|
||||
export { ApprovalManager } from "./approval.js";
|
||||
export type { ApprovalResult, PendingApproval } from "./approval.js";
|
||||
@@ -84,6 +85,15 @@ interface TriggerDeps {
|
||||
readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
|
||||
readonly triggerQueue?: TriggerQueue | undefined;
|
||||
readonly projectWorkspaceRoot: string;
|
||||
/** Alpha Silo policy: never acknowledge work into the process-local queue. */
|
||||
readonly rejectWhenBusy?: boolean | undefined;
|
||||
readonly resourceLimits?: {
|
||||
readonly maxFilesPerMessage: number;
|
||||
readonly maxBytesPerFile: number;
|
||||
} | undefined;
|
||||
readonly allowLegacyFeishuIdentity?: boolean | undefined;
|
||||
/** Alpha Silo aggregate ingress ceiling across message and card events. */
|
||||
readonly maxFeishuEventsPerMinute?: number | undefined;
|
||||
}
|
||||
|
||||
interface TriggerActor {
|
||||
@@ -102,6 +112,13 @@ interface TriggerRunContext {
|
||||
|
||||
type StartAgentRunOutcome = "started" | "queued" | "rejected" | "locked" | "skipped";
|
||||
|
||||
class InstanceCapacityExhaustedError extends Error {
|
||||
constructor(readonly limit: number) {
|
||||
super(`Silo Agent capacity exhausted (${limit} active run(s))`);
|
||||
this.name = "InstanceCapacityExhaustedError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface TriggerHandler {
|
||||
(event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void>;
|
||||
readonly onCardAction: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>;
|
||||
@@ -120,6 +137,9 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
const runAgent = deps.runAgent ?? defaultRunAgent;
|
||||
const approvalManager = new ApprovalManager();
|
||||
const triggerQueue = deps.triggerQueue ?? defaultTriggerQueue;
|
||||
const feishuEventLimiter = deps.maxFeishuEventsPerMinute === undefined
|
||||
? undefined
|
||||
: new SiloFixedWindowRateLimiter(deps.maxFeishuEventsPerMinute, 60_000);
|
||||
const slashCommands = createSlashCommandRegistry({
|
||||
prisma: deps.prisma,
|
||||
settings: deps.settings,
|
||||
@@ -162,6 +182,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
// ADR-0002: if locked by another run, enqueue the trigger for this project.
|
||||
const existing = await currentLockRunId(deps.prisma, projectId);
|
||||
if (existing !== null) {
|
||||
if (deps.rejectWhenBusy === true) {
|
||||
await sendText(rt, chatId, "当前项目正在处理中,请稍后重试。", sendOptions);
|
||||
return "rejected";
|
||||
}
|
||||
if (!queueIfLocked) return "locked";
|
||||
return enqueueLockedTrigger(context, cleanPrompt);
|
||||
}
|
||||
@@ -224,7 +248,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
senderOpenId,
|
||||
});
|
||||
const senderMetadata = await senderAuditMetadata(rt, senderOpenId);
|
||||
const stagedResources = await stageTriggerMessageResources(rt, msg, projectWorkspaceRoot);
|
||||
const stagedResources = await stageTriggerMessageResources(
|
||||
rt,
|
||||
msg,
|
||||
projectWorkspaceRoot,
|
||||
deps.resourceLimits,
|
||||
);
|
||||
const agentPrompt = appendStagedResourcePaths(parsedAgentPrompt, stagedResources, project.workspaceDir);
|
||||
const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext);
|
||||
|
||||
@@ -239,6 +268,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
try {
|
||||
admission = await deps.prisma.$transaction(async (tx) => {
|
||||
await lockActiveOrganization(tx, roleDecision.organizationId);
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('cph-alpha-silo-agent-capacity'))`;
|
||||
const activeRuns = await tx.agentRun.count({ where: { status: "ACTIVE" } });
|
||||
if (activeRuns >= runPolicy.maxConcurrentRuns) {
|
||||
throw new InstanceCapacityExhaustedError(runPolicy.maxConcurrentRuns);
|
||||
}
|
||||
|
||||
// ADR-0017: provider+role+model-bound session. Reuse if one exists for
|
||||
// this tuple; otherwise create. Role remains part of the key.
|
||||
@@ -299,9 +333,21 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
await sendText(rt, chatId, "组织当前不可用,拒绝触发。", sendOptions);
|
||||
return "skipped";
|
||||
}
|
||||
if (error instanceof InstanceCapacityExhaustedError) {
|
||||
deps.logger.info(
|
||||
{ projectId, maxConcurrentRuns: error.limit },
|
||||
"feishu trigger: Silo Agent capacity exhausted",
|
||||
);
|
||||
await sendText(rt, chatId, "当前组织处理任务已满,请稍后重试。", sendOptions);
|
||||
return "rejected";
|
||||
}
|
||||
if (!isPrismaUniqueConstraintError(error)) throw error;
|
||||
const current = await currentLockRunId(deps.prisma, projectId);
|
||||
if (current === null) throw error;
|
||||
if (deps.rejectWhenBusy === true) {
|
||||
await sendText(rt, chatId, "当前项目正在处理中,请稍后重试。", sendOptions);
|
||||
return "rejected";
|
||||
}
|
||||
if (!queueIfLocked) return "locked";
|
||||
return enqueueLockedTrigger(context, cleanPrompt);
|
||||
}
|
||||
@@ -409,6 +455,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
// Abort handle for this run; the interrupt card action calls abort() to
|
||||
// stop the SDK query. Cleared in the finally below.
|
||||
const abortController = new AbortController();
|
||||
let wallTimeExceeded = false;
|
||||
const wallTimeTimer = setTimeout(() => {
|
||||
wallTimeExceeded = true;
|
||||
abortController.abort("run_wall_time_exceeded");
|
||||
}, runPolicy.maxRunSeconds * 1000);
|
||||
wallTimeTimer.unref();
|
||||
activeRuns.set(run.id, abortController);
|
||||
const agentExecution = (async () => {
|
||||
const providerLease = await provider.openAgentLease({ runId: run.id });
|
||||
@@ -477,7 +529,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
})();
|
||||
agentExecution
|
||||
.then(async (result) => {
|
||||
const interrupted = result.status === "interrupted";
|
||||
const interrupted = result.status === "interrupted" && !wallTimeExceeded;
|
||||
const finalText =
|
||||
result.text !== ""
|
||||
? result.text
|
||||
@@ -495,7 +547,15 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
await deps.prisma.agentRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
status: interrupted ? "CANCELED" : result.status === "completed" ? "COMPLETED" : result.status === "length" ? "TIMED_OUT" : "FAILED",
|
||||
status: wallTimeExceeded
|
||||
? "TIMED_OUT"
|
||||
: interrupted
|
||||
? "CANCELED"
|
||||
: result.status === "completed"
|
||||
? "COMPLETED"
|
||||
: result.status === "length"
|
||||
? "TIMED_OUT"
|
||||
: "FAILED",
|
||||
inputTokens: result.usage.inputTokens,
|
||||
outputTokens: result.usage.outputTokens,
|
||||
...(result.costUsd !== undefined ? { costUsd: result.costUsd, costSource: "provider_reported" } : {}),
|
||||
@@ -528,6 +588,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.failed", metadata: { error: String(e) } });
|
||||
})
|
||||
.finally(async () => {
|
||||
clearTimeout(wallTimeTimer);
|
||||
try {
|
||||
await releaseLock(deps.prisma, run.id);
|
||||
} catch (e) {
|
||||
@@ -621,6 +682,18 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
}
|
||||
|
||||
const onCardAction = async (event: CardActionEvent, rt: FeishuRuntime): Promise<void> => {
|
||||
const rateDecision = feishuEventLimiter?.consume();
|
||||
if (rateDecision !== undefined && !rateDecision.allowed) {
|
||||
deps.logger.warn(
|
||||
{
|
||||
retryAfterSeconds: rateDecision.retryAfterSeconds,
|
||||
chatId: nonEmpty(event.context?.open_chat_id),
|
||||
operatorOpenId: nonEmpty(event.operator.open_id),
|
||||
},
|
||||
"feishu trigger: Silo event rate exceeded; card action rejected",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Approval buttons (file-delivery / explicit approval cards).
|
||||
const approvalAction = approvalActionFromValue(event.action.value);
|
||||
if (approvalAction !== null) {
|
||||
@@ -803,6 +876,28 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
});
|
||||
}
|
||||
|
||||
const rateDecision = feishuEventLimiter?.consume();
|
||||
if (rateDecision !== undefined && !rateDecision.allowed) {
|
||||
deps.logger.warn(
|
||||
{
|
||||
retryAfterSeconds: rateDecision.retryAfterSeconds,
|
||||
chatId: msg.chat_id,
|
||||
messageId: msg.message_id,
|
||||
senderOpenId: event.sender.sender_id.open_id,
|
||||
},
|
||||
"feishu trigger: Silo event rate exceeded; message rejected",
|
||||
);
|
||||
if (msg.chat_id !== "") {
|
||||
await sendText(
|
||||
rt,
|
||||
msg.chat_id,
|
||||
`当前服务请求过多,请约 ${rateDecision.retryAfterSeconds} 秒后重试。`,
|
||||
sendOptionsForTriggerMessage(msg),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only react to supported inbound message types in group chats.
|
||||
if (msg.message_type !== "text" && msg.message_type !== "file" && msg.message_type !== "image" && msg.message_type !== "post") return;
|
||||
const chatId = msg.chat_id;
|
||||
@@ -913,6 +1008,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
if (msg.message_type === "text") {
|
||||
const existing = await currentLockRunId(deps.prisma, projectId);
|
||||
if (existing !== null) {
|
||||
if (deps.rejectWhenBusy === true) {
|
||||
await sendText(rt, chatId, "当前项目正在处理中,请稍后重试。", sendOptionsForTriggerMessage(msg));
|
||||
return;
|
||||
}
|
||||
await enqueueLockedTrigger(runContext, cleanPrompt);
|
||||
return;
|
||||
}
|
||||
@@ -975,10 +1074,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
}
|
||||
| { readonly status: "error"; readonly message: string }
|
||||
> {
|
||||
const user = await deps.prisma.user.findUnique({
|
||||
where: { feishuOpenId },
|
||||
const identity = await deps.prisma.feishuUserIdentity.findFirst({
|
||||
where: {
|
||||
openId: feishuOpenId,
|
||||
connection: { status: "ACTIVE", organization: { status: "ACTIVE" } },
|
||||
},
|
||||
select: {
|
||||
organizationMemberships: {
|
||||
user: { select: { organizationMemberships: {
|
||||
where: {
|
||||
revokedAt: null,
|
||||
organization: { status: "ACTIVE" },
|
||||
@@ -988,12 +1090,22 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
organization: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
} } },
|
||||
},
|
||||
});
|
||||
if (user === null) {
|
||||
return { status: "error", message: "请先登录并加入组织后,再绑定项目。" };
|
||||
}
|
||||
const user = identity?.user ?? (deps.allowLegacyFeishuIdentity === true
|
||||
? await deps.prisma.user.findUnique({
|
||||
where: { feishuOpenId },
|
||||
select: {
|
||||
organizationMemberships: {
|
||||
where: { revokedAt: null, organization: { status: "ACTIVE" } },
|
||||
select: { role: true, organization: { select: { id: true, name: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
},
|
||||
})
|
||||
: null);
|
||||
if (user === null) return { status: "error", message: "请先登录并加入组织后,再绑定项目。" };
|
||||
if (user.organizationMemberships.length === 0) {
|
||||
return { status: "error", message: "你还不属于任何可用组织,请联系组织管理员。" };
|
||||
}
|
||||
@@ -1378,6 +1490,7 @@ async function stageTriggerMessageResources(
|
||||
rt: FeishuRuntime,
|
||||
msg: MessageReceiveEvent["message"],
|
||||
workspaceRoot: string,
|
||||
limits?: TriggerDeps["resourceLimits"],
|
||||
): Promise<StagedMessageResourceBatch | null> {
|
||||
const requests: MessageResourceStageRequest[] = [];
|
||||
if (msg.message_type === "post") {
|
||||
@@ -1407,7 +1520,15 @@ async function stageTriggerMessageResources(
|
||||
});
|
||||
}
|
||||
}
|
||||
return stageMessageResources(rt, msg.message_id, requests, workspaceRoot);
|
||||
return stageMessageResources(
|
||||
rt,
|
||||
msg.message_id,
|
||||
requests,
|
||||
workspaceRoot,
|
||||
limits === undefined
|
||||
? undefined
|
||||
: { maxFiles: limits.maxFilesPerMessage, maxBytesPerFile: limits.maxBytesPerFile },
|
||||
);
|
||||
}
|
||||
|
||||
function appendStagedResourcePaths(
|
||||
|
||||
Reference in New Issue
Block a user