forked from bai/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 type { FastifyBaseLogger } from "fastify";
|
||||
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js";
|
||||
import type { SenderNameCache } from "./senderCache.js";
|
||||
|
||||
export interface FeishuConfig {
|
||||
readonly appId: string;
|
||||
@@ -95,6 +96,14 @@ export interface CardActionEvent {
|
||||
readonly token?: string;
|
||||
}
|
||||
|
||||
type ContactUserResponse = {
|
||||
readonly data?: {
|
||||
readonly user?: {
|
||||
readonly name?: string | undefined;
|
||||
} | undefined;
|
||||
} | undefined;
|
||||
} | null;
|
||||
|
||||
// --- Message helpers ---
|
||||
|
||||
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 ---
|
||||
|
||||
/** 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,
|
||||
parsePostMessage,
|
||||
resolveCard,
|
||||
resolveSenderName,
|
||||
type CardActionEvent,
|
||||
type FeishuRuntime,
|
||||
type MessageReceiveEvent,
|
||||
@@ -35,9 +36,11 @@ import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
|
||||
import { readFeishuContext } from "./read.js";
|
||||
import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js";
|
||||
import { ApprovalManager } from "./approval.js";
|
||||
import { SenderNameCache } from "./senderCache.js";
|
||||
|
||||
export { ApprovalManager } from "./approval.js";
|
||||
export type { ApprovalResult, PendingApproval } from "./approval.js";
|
||||
export const senderNameCache = new SenderNameCache();
|
||||
|
||||
interface TriggerDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
@@ -138,7 +141,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
projectId,
|
||||
...(roleDecision.actorUserId !== undefined ? { actorUserId: roleDecision.actorUserId } : {}),
|
||||
action: "trigger.role_denied",
|
||||
metadata: { roleId, decision: auditDecision(roleDecision) },
|
||||
metadata: {
|
||||
roleId,
|
||||
decision: auditDecision(roleDecision),
|
||||
sender: await senderAuditMetadata(rt, senderOpenId),
|
||||
},
|
||||
});
|
||||
await sendText(rt, chatId, `无权限使用角色 ${roleId}。`);
|
||||
return;
|
||||
@@ -208,6 +215,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
roleId,
|
||||
model,
|
||||
prompt: agentPrompt.slice(0, 200),
|
||||
sender: await senderAuditMetadata(rt, senderOpenId),
|
||||
...(feishuTriggerContext === null ? {} : { feishuTriggerContext }),
|
||||
},
|
||||
});
|
||||
@@ -220,7 +228,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
where: { id: run.id },
|
||||
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 sendText(rt, chatId, "项目正在处理中,请稍候。");
|
||||
return;
|
||||
@@ -340,16 +353,17 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
const messageId = nonEmpty(event.context?.open_message_id);
|
||||
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);
|
||||
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 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
|
||||
// would spawn a second AgentRun — the lock would refuse it, but a FAILED
|
||||
@@ -412,7 +426,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
projectId,
|
||||
...(triggerDecision.actorUserId !== undefined ? { actorUserId: triggerDecision.actorUserId } : {}),
|
||||
action: "trigger.denied",
|
||||
metadata: { decision: auditDecision(triggerDecision) },
|
||||
metadata: {
|
||||
decision: auditDecision(triggerDecision),
|
||||
sender: await senderAuditMetadata(rt, senderOpenId),
|
||||
},
|
||||
});
|
||||
await sendText(rt, chatId, "无权限触发。");
|
||||
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 {
|
||||
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. " +
|
||||
|
||||
@@ -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