fix: tighten feishu dedupe boundary (#23377) (thanks @SidQin-cyber)

This commit is contained in:
Peter Steinberger
2026-02-22 11:12:21 +01:00
parent 9e5e555ba3
commit bf56196de3
3 changed files with 1 additions and 108 deletions

View File

@@ -20,7 +20,7 @@ Docs: https://docs.openclaw.ai
- Gateway/Restart: fix restart-loop edge cases by keeping `openclaw.mjs -> dist/entry.js` bootstrap detection explicit, reacquiring the gateway lock for in-process restart fallback paths, and tightening restart-loop regression coverage. (#23416) Thanks @jeffwnli.
- Signal/Monitor: treat user-initiated abort shutdowns as clean exits when auto-started `signal-cli` is terminated, while still surfacing unexpected daemon exits as startup/runtime failures. (#23379) Thanks @frankekn.
- Channels/Dedupe: centralize plugin dedupe primitives in plugin SDK (memory + persistent), move Feishu inbound dedupe to a namespace-scoped persistent store, and reuse shared dedupe cache logic for Zalo webhook replay + Tlon processed-message tracking to reduce duplicate handling during reconnect/replay paths.
- Channels/Dedupe: centralize plugin dedupe primitives in plugin SDK (memory + persistent), move Feishu inbound dedupe to a namespace-scoped persistent store, and reuse shared dedupe cache logic for Zalo webhook replay + Tlon processed-message tracking to reduce duplicate handling during reconnect/replay paths. (#23377) Thanks @SidQin-cyber.
- ACP/Gateway: wait for gateway hello before opening ACP requests, and fail fast on pre-hello connect failures to avoid startup hangs and early `gateway not connected` request races. (#23390) Thanks @janckerchen.
- Security/Audit: add `openclaw security audit` detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (`security.exposure.open_groups_with_runtime_or_fs`).
- Security/Exec env: block request-scoped `HOME` and `ZDOTDIR` overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. This ships in the next npm release. Thanks @tdjackey for reporting.

View File

@@ -1,104 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const DEFAULT_DEDUP_DIR = path.join(os.homedir(), ".openclaw", "feishu", "dedup");
const MAX_ENTRIES_PER_FILE = 10_000;
const CLEANUP_PROBABILITY = 0.02;
const CACHE_STALE_MS = 30_000;
type DedupData = Record<string, number>;
/**
* Filesystem-backed dedup store. Each "namespace" (typically a Feishu account
* ID) maps to a single JSON file containing `{ messageId: timestampMs }` pairs.
*
* Writes use atomic rename to avoid partial-read corruption. Probabilistic
* cleanup keeps the file size bounded without adding latency to every call.
*/
export class DedupStore {
private readonly dir: string;
private cache = new Map<string, DedupData>();
private cacheLoadedAt = new Map<string, number>();
constructor(dir?: string) {
this.dir = dir ?? DEFAULT_DEDUP_DIR;
}
private filePath(namespace: string): string {
const safe = namespace.replace(/[^a-zA-Z0-9_-]/g, "_");
return path.join(this.dir, `${safe}.json`);
}
async load(namespace: string): Promise<DedupData> {
const loadedAt = this.cacheLoadedAt.get(namespace);
if (loadedAt != null && Date.now() - loadedAt > CACHE_STALE_MS) {
this.cache.delete(namespace);
this.cacheLoadedAt.delete(namespace);
}
const cached = this.cache.get(namespace);
if (cached) return cached;
try {
const raw = await fs.promises.readFile(this.filePath(namespace), "utf-8");
const data: DedupData = JSON.parse(raw);
this.cache.set(namespace, data);
this.cacheLoadedAt.set(namespace, Date.now());
return data;
} catch {
const data: DedupData = {};
this.cache.set(namespace, data);
this.cacheLoadedAt.set(namespace, Date.now());
return data;
}
}
async has(namespace: string, messageId: string, ttlMs: number): Promise<boolean> {
const data = await this.load(namespace);
const ts = data[messageId];
if (ts == null) return false;
if (Date.now() - ts > ttlMs) {
// Expired — treat as absent. Skip the delete here to avoid silent
// cache/disk divergence; actual cleanup happens probabilistically
// inside record().
return false;
}
return true;
}
async record(namespace: string, messageId: string, ttlMs: number): Promise<void> {
const data = await this.load(namespace);
data[messageId] = Date.now();
if (Math.random() < CLEANUP_PROBABILITY) {
this.evict(data, ttlMs);
}
await this.flush(namespace, data);
}
private evict(data: DedupData, ttlMs: number): void {
const now = Date.now();
for (const key of Object.keys(data)) {
if (now - data[key] > ttlMs) delete data[key];
}
const keys = Object.keys(data);
if (keys.length > MAX_ENTRIES_PER_FILE) {
keys
.sort((a, b) => data[a] - data[b])
.slice(0, keys.length - MAX_ENTRIES_PER_FILE)
.forEach((k) => delete data[k]);
}
}
private async flush(namespace: string, data: DedupData): Promise<void> {
await fs.promises.mkdir(this.dir, { recursive: true });
const fp = this.filePath(namespace);
const tmp = `${fp}.tmp.${process.pid}`;
await fs.promises.writeFile(tmp, JSON.stringify(data), "utf-8");
await fs.promises.rename(tmp, fp);
this.cacheLoadedAt.set(namespace, Date.now());
}
}

View File

@@ -14,9 +14,6 @@ function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string {
if (stateOverride) {
return stateOverride;
}
if (env.VITEST || env.NODE_ENV === "test") {
return path.join(os.tmpdir(), "openclaw-vitest-" + process.pid);
}
return path.join(os.homedir(), ".openclaw");
}