/** * Channel-meta cache. Records (channelId → xType) for every fabric * channel the gateway has seen at least one inbound message in. * * Populated lazily from inbound (`recordChannelType` is called for * every `message.created` event with non-empty `xType`). Persisted to * `~/.openclaw/fabric-channel-meta.json` so the cache survives * gateway restarts (so the very first DM after restart still gets the * right xType without waiting for a fresh inbound). * * Exposed cross-plugin via `globalThis.__fabric.getChannelType`. Used * by ClawPrompts' fabric-chat-injector to narrow its prompt injection * to xType==='dm' only. * * Failure mode: lookup misses (channel never seen / inbound dropped * xType) return null. Callers MUST treat null as "unknown" — DO NOT * fall back to "assume DM" or you re-introduce the false-positive on * group channels. */ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; const CACHE_FILE = join(homedir(), '.openclaw', 'fabric-channel-meta.json'); interface ChannelMetaFile { // channelId → xType ('dm' | 'triage' | 'group' | etc.) channels: Record; } let memory = new Map(); let loaded = false; let dirty = false; let flushTimer: ReturnType | null = null; function load(): void { if (loaded) return; loaded = true; try { if (!existsSync(CACHE_FILE)) return; const raw = readFileSync(CACHE_FILE, 'utf8'); const parsed = JSON.parse(raw) as ChannelMetaFile; for (const [k, v] of Object.entries(parsed.channels ?? {})) { if (typeof k === 'string' && typeof v === 'string') memory.set(k, v); } } catch { // ignore — start with empty cache on corruption } } function scheduleFlush(): void { if (flushTimer) return; // Debounce writes — many inbound messages may arrive in a burst. // 250ms coalesces them; on gateway_stop the channel plugin can force // a synchronous flush via flushChannelMeta(). flushTimer = setTimeout(() => { flushTimer = null; if (!dirty) return; dirty = false; flushSync(); }, 250); } function flushSync(): void { try { const dir = dirname(CACHE_FILE); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const out: ChannelMetaFile = { channels: Object.fromEntries(memory) }; const tmp = CACHE_FILE + '.tmp'; writeFileSync(tmp, JSON.stringify(out, null, 2) + '\n', 'utf8'); renameSync(tmp, CACHE_FILE); } catch { // swallow — cache is an optimization; loss-on-write is recoverable } } /** Called by inbound on every message.created. xType empty → no-op. */ export function recordChannelType(channelId: string, xType: string | undefined): void { if (!channelId || !xType) return; load(); const existing = memory.get(channelId); if (existing === xType) return; memory.set(channelId, xType); dirty = true; scheduleFlush(); } /** Cross-plugin lookup. null when channel never seen / unknown. */ export function getChannelType(channelId: string): string | null { if (!channelId) return null; load(); return memory.get(channelId) ?? null; } /** Force-flush — called on plugin shutdown to make sure recently * recorded entries hit disk before the gateway dies. */ export function flushChannelMeta(): void { if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } if (dirty) { dirty = false; flushSync(); } } export const CHANNEL_META_PATH = CACHE_FILE;