forked from EduCraft/curriculum-project-hub
feat: add sender profile cache with LRU eviction
- SenderNameCache: TTL 10min, LRU max 2048 entries, getOrFetch pattern - resolveSenderName: API call with cache integration - Trigger uses cache for approval card resolutions and audit metadata - Add unit tests (9) for cache lifecycle, LRU, expiry, and null handling
This commit is contained in:
@@ -10,6 +10,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js";
|
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js";
|
||||||
|
import type { SenderNameCache } from "./senderCache.js";
|
||||||
|
|
||||||
export interface FeishuConfig {
|
export interface FeishuConfig {
|
||||||
readonly appId: string;
|
readonly appId: string;
|
||||||
@@ -95,6 +96,14 @@ export interface CardActionEvent {
|
|||||||
readonly token?: string;
|
readonly token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ContactUserResponse = {
|
||||||
|
readonly data?: {
|
||||||
|
readonly user?: {
|
||||||
|
readonly name?: string | undefined;
|
||||||
|
} | undefined;
|
||||||
|
} | undefined;
|
||||||
|
} | null;
|
||||||
|
|
||||||
// --- Message helpers ---
|
// --- Message helpers ---
|
||||||
|
|
||||||
export function buildOutboundPayload(content: string): OutboundPayload {
|
export function buildOutboundPayload(content: string): OutboundPayload {
|
||||||
@@ -356,6 +365,35 @@ export async function removeReaction(rt: FeishuRuntime, messageId: string, react
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a Feishu sender display name from open_id, optionally using a cache. */
|
||||||
|
export async function resolveSenderName(
|
||||||
|
rt: FeishuRuntime,
|
||||||
|
openId: string,
|
||||||
|
cache?: SenderNameCache,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (openId === "") return null;
|
||||||
|
|
||||||
|
const fetchName = async (id: string): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const res = await rt.client.request({
|
||||||
|
method: "GET",
|
||||||
|
url: `/open-apis/contact/v3/users/${encodeURIComponent(id)}`,
|
||||||
|
params: { user_id_type: "open_id" },
|
||||||
|
}) as ContactUserResponse;
|
||||||
|
const name = res?.data?.user?.name;
|
||||||
|
return typeof name === "string" && name !== "" ? name : null;
|
||||||
|
} catch (e) {
|
||||||
|
rt.logger.warn({ openId: id, err: e instanceof Error ? e.message : String(e) }, "resolveSenderName failed");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cache !== undefined) {
|
||||||
|
return cache.getOrFetch(openId, fetchName);
|
||||||
|
}
|
||||||
|
return fetchName(openId);
|
||||||
|
}
|
||||||
|
|
||||||
// --- File helpers ---
|
// --- File helpers ---
|
||||||
|
|
||||||
/** Download a file/image from a Feishu message to a local path. */
|
/** Download a file/image from a Feishu message to a local path. */
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
export interface SenderCacheOptions {
|
||||||
|
readonly ttlMs?: number;
|
||||||
|
readonly maxSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SenderCacheEntry {
|
||||||
|
readonly name: string;
|
||||||
|
readonly expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TTL_MS = 600_000;
|
||||||
|
const DEFAULT_MAX_SIZE = 2_048;
|
||||||
|
|
||||||
|
export class SenderNameCache {
|
||||||
|
private readonly ttlMs: number;
|
||||||
|
private readonly maxSize: number;
|
||||||
|
private readonly entries = new Map<string, SenderCacheEntry>();
|
||||||
|
|
||||||
|
constructor(options: SenderCacheOptions = {}) {
|
||||||
|
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
||||||
|
this.maxSize = Math.max(0, options.maxSize ?? DEFAULT_MAX_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(openId: string): string | null {
|
||||||
|
const entry = this.entries.get(openId);
|
||||||
|
if (entry === undefined) return null;
|
||||||
|
|
||||||
|
if (entry.expiresAt <= Date.now()) {
|
||||||
|
this.entries.delete(openId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.entries.delete(openId);
|
||||||
|
this.entries.set(openId, entry);
|
||||||
|
return entry.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(openId: string, name: string): void {
|
||||||
|
if (this.maxSize === 0) return;
|
||||||
|
|
||||||
|
this.entries.delete(openId);
|
||||||
|
this.entries.set(openId, { name, expiresAt: Date.now() + this.ttlMs });
|
||||||
|
|
||||||
|
while (this.entries.size > this.maxSize) {
|
||||||
|
const oldest = this.entries.keys().next().value;
|
||||||
|
if (oldest === undefined) break;
|
||||||
|
this.entries.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrFetch(
|
||||||
|
openId: string,
|
||||||
|
fetchFn: (openId: string) => Promise<string | null>,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const cached = this.get(openId);
|
||||||
|
if (cached !== null) return cached;
|
||||||
|
|
||||||
|
const fetched = await fetchFn(openId);
|
||||||
|
if (fetched !== null) {
|
||||||
|
this.set(openId, fetched);
|
||||||
|
}
|
||||||
|
return fetched;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.entries.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
size(): number {
|
||||||
|
this.pruneExpired();
|
||||||
|
return this.entries.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneExpired(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [openId, entry] of this.entries) {
|
||||||
|
if (entry.expiresAt <= now) {
|
||||||
|
this.entries.delete(openId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
downloadMessageFile,
|
downloadMessageFile,
|
||||||
parsePostMessage,
|
parsePostMessage,
|
||||||
resolveCard,
|
resolveCard,
|
||||||
|
resolveSenderName,
|
||||||
type CardActionEvent,
|
type CardActionEvent,
|
||||||
type FeishuRuntime,
|
type FeishuRuntime,
|
||||||
type MessageReceiveEvent,
|
type MessageReceiveEvent,
|
||||||
@@ -35,9 +36,11 @@ import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
|
|||||||
import { readFeishuContext } from "./read.js";
|
import { readFeishuContext } from "./read.js";
|
||||||
import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js";
|
import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js";
|
||||||
import { ApprovalManager } from "./approval.js";
|
import { ApprovalManager } from "./approval.js";
|
||||||
|
import { SenderNameCache } from "./senderCache.js";
|
||||||
|
|
||||||
export { ApprovalManager } from "./approval.js";
|
export { ApprovalManager } from "./approval.js";
|
||||||
export type { ApprovalResult, PendingApproval } from "./approval.js";
|
export type { ApprovalResult, PendingApproval } from "./approval.js";
|
||||||
|
export const senderNameCache = new SenderNameCache();
|
||||||
|
|
||||||
interface TriggerDeps {
|
interface TriggerDeps {
|
||||||
readonly prisma: PrismaClient;
|
readonly prisma: PrismaClient;
|
||||||
@@ -138,7 +141,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
projectId,
|
projectId,
|
||||||
...(roleDecision.actorUserId !== undefined ? { actorUserId: roleDecision.actorUserId } : {}),
|
...(roleDecision.actorUserId !== undefined ? { actorUserId: roleDecision.actorUserId } : {}),
|
||||||
action: "trigger.role_denied",
|
action: "trigger.role_denied",
|
||||||
metadata: { roleId, decision: auditDecision(roleDecision) },
|
metadata: {
|
||||||
|
roleId,
|
||||||
|
decision: auditDecision(roleDecision),
|
||||||
|
sender: await senderAuditMetadata(rt, senderOpenId),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await sendText(rt, chatId, `无权限使用角色 ${roleId}。`);
|
await sendText(rt, chatId, `无权限使用角色 ${roleId}。`);
|
||||||
return;
|
return;
|
||||||
@@ -208,6 +215,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
roleId,
|
roleId,
|
||||||
model,
|
model,
|
||||||
prompt: agentPrompt.slice(0, 200),
|
prompt: agentPrompt.slice(0, 200),
|
||||||
|
sender: await senderAuditMetadata(rt, senderOpenId),
|
||||||
...(feishuTriggerContext === null ? {} : { feishuTriggerContext }),
|
...(feishuTriggerContext === null ? {} : { feishuTriggerContext }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -220,7 +228,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
where: { id: run.id },
|
where: { id: run.id },
|
||||||
data: { status: "FAILED", error: "lock race", finishedAt: new Date() },
|
data: { status: "FAILED", error: "lock race", finishedAt: new Date() },
|
||||||
});
|
});
|
||||||
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.lock_race", metadata: {} });
|
await writeAudit(deps.prisma, {
|
||||||
|
runId: run.id,
|
||||||
|
projectId,
|
||||||
|
action: "run.lock_race",
|
||||||
|
metadata: { sender: await senderAuditMetadata(rt, senderOpenId) },
|
||||||
|
});
|
||||||
await reactToMessage(rt, msg.message_id, "OnIt");
|
await reactToMessage(rt, msg.message_id, "OnIt");
|
||||||
await sendText(rt, chatId, "项目正在处理中,请稍候。");
|
await sendText(rt, chatId, "项目正在处理中,请稍候。");
|
||||||
return;
|
return;
|
||||||
@@ -340,16 +353,17 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
const messageId = nonEmpty(event.context?.open_message_id);
|
const messageId = nonEmpty(event.context?.open_message_id);
|
||||||
if (messageId === undefined || !approvalManager.hasPending(messageId)) return;
|
if (messageId === undefined || !approvalManager.hasPending(messageId)) return;
|
||||||
|
|
||||||
const resolvedBy = nonEmpty(event.operator.open_id) ?? "unknown";
|
const operatorOpenId = nonEmpty(event.operator.open_id);
|
||||||
|
const resolvedBy = operatorOpenId ?? "unknown";
|
||||||
|
const resolvedByName =
|
||||||
|
operatorOpenId === undefined ? "unknown" : await senderDisplayNameOrOpenId(rt, operatorOpenId);
|
||||||
const resolution = approvalResolutionFor(action);
|
const resolution = approvalResolutionFor(action);
|
||||||
approvalManager.resolve(messageId, action, resolvedBy);
|
approvalManager.resolve(messageId, action, resolvedBy);
|
||||||
await resolveCard(rt, messageId, resolution.icon, resolution.label, resolution.template, resolvedBy);
|
await resolveCard(rt, messageId, resolution.icon, resolution.label, resolution.template, resolvedByName);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMessage = async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void> => {
|
const onMessage = async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void> => {
|
||||||
const msg = event.message;
|
const msg = event.message;
|
||||||
const sender = event.sender;
|
|
||||||
void sender; // sender→user mapping is OPEN (principal sub-typology)
|
|
||||||
|
|
||||||
// Dedup: the lark ws client redelivers at-least-once. A duplicate event
|
// Dedup: the lark ws client redelivers at-least-once. A duplicate event
|
||||||
// would spawn a second AgentRun — the lock would refuse it, but a FAILED
|
// would spawn a second AgentRun — the lock would refuse it, but a FAILED
|
||||||
@@ -412,7 +426,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
projectId,
|
projectId,
|
||||||
...(triggerDecision.actorUserId !== undefined ? { actorUserId: triggerDecision.actorUserId } : {}),
|
...(triggerDecision.actorUserId !== undefined ? { actorUserId: triggerDecision.actorUserId } : {}),
|
||||||
action: "trigger.denied",
|
action: "trigger.denied",
|
||||||
metadata: { decision: auditDecision(triggerDecision) },
|
metadata: {
|
||||||
|
decision: auditDecision(triggerDecision),
|
||||||
|
sender: await senderAuditMetadata(rt, senderOpenId),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await sendText(rt, chatId, "无权限触发。");
|
await sendText(rt, chatId, "无权限触发。");
|
||||||
return;
|
return;
|
||||||
@@ -712,6 +729,19 @@ function auditDecision(decision: AuthorizationDecision): Record<string, unknown>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function senderDisplayNameOrOpenId(rt: FeishuRuntime, openId: string): Promise<string> {
|
||||||
|
return (await resolveSenderName(rt, openId, senderNameCache)) ?? openId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function senderAuditMetadata(rt: FeishuRuntime, openId: string): Promise<Prisma.InputJsonObject> {
|
||||||
|
const sender: Record<string, Prisma.InputJsonValue> = { open_id: openId };
|
||||||
|
const name = await resolveSenderName(rt, openId, senderNameCache);
|
||||||
|
if (name !== null) {
|
||||||
|
sender["name"] = name;
|
||||||
|
}
|
||||||
|
return sender as Prisma.InputJsonObject;
|
||||||
|
}
|
||||||
|
|
||||||
function withFileDeliveryInstructions(systemPrompt: string | undefined): string {
|
function withFileDeliveryInstructions(systemPrompt: string | undefined): string {
|
||||||
const fileDeliveryPrompt =
|
const fileDeliveryPrompt =
|
||||||
"When the user asks you to send, resend, attach, or provide a file, call the cph_hub send_file tool with the actual existing file path. " +
|
"When the user asks you to send, resend, attach, or provide a file, call the cph_hub send_file tool with the actual existing file path. " +
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { SenderNameCache } from "../../src/feishu/senderCache.js";
|
||||||
|
|
||||||
|
describe("SenderNameCache", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get returns null for missing key", () => {
|
||||||
|
const cache = new SenderNameCache();
|
||||||
|
|
||||||
|
expect(cache.get("ou_user")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("set then get returns the name", () => {
|
||||||
|
const cache = new SenderNameCache();
|
||||||
|
|
||||||
|
cache.set("ou_user", "Alice");
|
||||||
|
|
||||||
|
expect(cache.get("ou_user")).toBe("Alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expired entry returns null", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
const cache = new SenderNameCache({ ttlMs: 100 });
|
||||||
|
|
||||||
|
cache.set("ou_user", "Alice");
|
||||||
|
vi.advanceTimersByTime(101);
|
||||||
|
|
||||||
|
expect(cache.get("ou_user")).toBeNull();
|
||||||
|
expect(cache.size()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getOrFetch uses cache on hit", async () => {
|
||||||
|
const cache = new SenderNameCache();
|
||||||
|
const fetchFn = vi.fn(async () => "Fetched Alice");
|
||||||
|
cache.set("ou_user", "Alice");
|
||||||
|
|
||||||
|
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
|
||||||
|
|
||||||
|
expect(fetchFn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getOrFetch calls fetchFn on miss", async () => {
|
||||||
|
const cache = new SenderNameCache();
|
||||||
|
const fetchFn = vi.fn(async () => "Alice");
|
||||||
|
|
||||||
|
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
|
||||||
|
|
||||||
|
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchFn).toHaveBeenCalledWith("ou_user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getOrFetch caches the result of fetchFn", async () => {
|
||||||
|
const cache = new SenderNameCache();
|
||||||
|
const fetchFn = vi.fn(async () => "Alice");
|
||||||
|
|
||||||
|
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
|
||||||
|
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
|
||||||
|
|
||||||
|
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evicts the least recently used entry when full", () => {
|
||||||
|
const cache = new SenderNameCache({ maxSize: 2 });
|
||||||
|
|
||||||
|
cache.set("ou_1", "Alice");
|
||||||
|
cache.set("ou_2", "Bob");
|
||||||
|
expect(cache.get("ou_1")).toBe("Alice");
|
||||||
|
cache.set("ou_3", "Carol");
|
||||||
|
|
||||||
|
expect(cache.get("ou_1")).toBe("Alice");
|
||||||
|
expect(cache.get("ou_2")).toBeNull();
|
||||||
|
expect(cache.get("ou_3")).toBe("Carol");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clear empties the cache", () => {
|
||||||
|
const cache = new SenderNameCache();
|
||||||
|
|
||||||
|
cache.set("ou_user", "Alice");
|
||||||
|
cache.clear();
|
||||||
|
|
||||||
|
expect(cache.get("ou_user")).toBeNull();
|
||||||
|
expect(cache.size()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("size returns correct count", () => {
|
||||||
|
const cache = new SenderNameCache();
|
||||||
|
|
||||||
|
cache.set("ou_1", "Alice");
|
||||||
|
cache.set("ou_2", "Bob");
|
||||||
|
|
||||||
|
expect(cache.size()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getOrFetch with null fetchFn result does not cache null", async () => {
|
||||||
|
const cache = new SenderNameCache();
|
||||||
|
const fetchFn = vi.fn(async () => null);
|
||||||
|
|
||||||
|
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBeNull();
|
||||||
|
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBeNull();
|
||||||
|
|
||||||
|
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(cache.size()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user