Inbound `message.created` already carries `xType` (dm / triage / group /
broadcast / etc.) — record it in a per-channel cache so other plugins
can answer "is this channel a DM?" without poking the Center API.
New module src/channel-meta.ts:
- in-memory Map<channelId, xType>
- lazily loaded from ~/.openclaw/fabric-channel-meta.json on first
access (so first-ever DM after a fresh gateway start still hits
cache from the previous run)
- debounced 250ms flush on dirty; force-flush on gateway_stop
- recordChannelType(channelId, xType): called from inbound
- getChannelType(channelId): null if unknown — caller MUST treat null
as "don't know", NOT as "assume DM" (would re-introduce the false-
positive on group channels we're trying to eliminate)
Wiring:
- inbound.ts socket.on('message.created'): records xType BEFORE the
self-author / dedup gates (channel type is observer-agnostic)
- index.ts: installs globalThis.__fabric = { getChannelType } on
registerFull(); flushes on gateway_stop
Consumer: ClawPrompts' fabric-chat-injector will start gating its prompt
injection on getChannelType(channelId) === 'dm' (companion PR on
ClawPrompts). Removes the phase-1 "any fabric channel" false-positive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
109 lines
3.5 KiB
TypeScript
109 lines
3.5 KiB
TypeScript
/**
|
|
* 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<string, string>;
|
|
}
|
|
|
|
let memory = new Map<string, string>();
|
|
let loaded = false;
|
|
let dirty = false;
|
|
let flushTimer: ReturnType<typeof setTimeout> | 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;
|