Files
Dirigent/plugin/index.ts
zhi f23d9049a7 fix: bypass DM sessions without metadata and make tool globally visible
1. DM bypass: when neither senderId nor channelId can be extracted from
   the prompt (DM sessions lack untrusted conversation info), skip the
   no-reply gate and allow the message through with end-marker injection.

2. Tool visibility: change whispergateway_tools registration from
   optional=true to optional=false so all agents can see the tool
   without needing explicit tools.allow entries.
2026-02-27 14:14:39 +00:00

513 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from "node:fs";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { evaluateDecision, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js";
type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list";
type DecisionRecord = {
decision: Decision;
createdAt: number;
needsRestore?: boolean;
};
type PolicyState = {
filePath: string;
channelPolicies: Record<string, ChannelPolicy>;
};
type DebugConfig = {
enableDebugLogs?: boolean;
debugLogChannelIds?: string[];
};
const sessionDecision = new Map<string, DecisionRecord>();
const MAX_SESSION_DECISIONS = 2000;
const DECISION_TTL_MS = 5 * 60 * 1000;
const END_MARKER_INSTRUCTION = "你的这次发言必须以🔚作为结尾。";
const policyState: PolicyState = {
filePath: "",
channelPolicies: {},
};
function normalizeChannel(ctx: Record<string, unknown>): string {
const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel];
for (const c of candidates) {
if (typeof c === "string" && c.trim()) return c.trim().toLowerCase();
}
return "";
}
function normalizeSender(event: Record<string, unknown>, ctx: Record<string, unknown>): string | undefined {
const direct = [ctx.senderId, ctx.from, event.from];
for (const v of direct) {
if (typeof v === "string" && v.trim()) return v.trim();
}
const meta = (event.metadata || ctx.metadata) as Record<string, unknown> | undefined;
if (!meta) return undefined;
const metaCandidates = [meta.senderId, meta.sender_id, meta.userId, meta.user_id];
for (const v of metaCandidates) {
if (typeof v === "string" && v.trim()) return v.trim();
}
return undefined;
}
function extractUntrustedConversationInfo(text: string): Record<string, unknown> | undefined {
const marker = "Conversation info (untrusted metadata):";
const idx = text.indexOf(marker);
if (idx < 0) return undefined;
const tail = text.slice(idx + marker.length);
const m = tail.match(/```json\s*([\s\S]*?)\s*```/i);
if (!m) return undefined;
try {
const parsed = JSON.parse(m[1]);
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : undefined;
} catch {
return undefined;
}
}
function deriveDecisionInputFromPrompt(
prompt: string,
messageProvider?: string,
): {
channel: string;
channelId?: string;
senderId?: string;
content: string;
conv: Record<string, unknown>;
} {
const conv = extractUntrustedConversationInfo(prompt) || {};
const channel = (messageProvider || "").toLowerCase();
const channelId =
(typeof conv.channel_id === "string" && conv.channel_id) ||
(typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:")
? conv.chat_id.slice("channel:".length)
: undefined);
const senderId =
(typeof conv.sender_id === "string" && conv.sender_id) ||
(typeof conv.sender === "string" && conv.sender) ||
undefined;
return { channel, channelId, senderId, content: prompt, conv };
}
function pruneDecisionMap(now = Date.now()) {
for (const [k, v] of sessionDecision.entries()) {
if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k);
}
if (sessionDecision.size <= MAX_SESSION_DECISIONS) return;
const keys = sessionDecision.keys();
while (sessionDecision.size > MAX_SESSION_DECISIONS) {
const k = keys.next();
if (k.done) break;
sessionDecision.delete(k.value);
}
}
function getLivePluginConfig(api: OpenClawPluginApi, fallback: WhisperGateConfig): WhisperGateConfig {
const root = (api.config as Record<string, unknown>) || {};
const plugins = (root.plugins as Record<string, unknown>) || {};
const entries = (plugins.entries as Record<string, unknown>) || {};
const entry = (entries.whispergate as Record<string, unknown>) || {};
const cfg = (entry.config as Record<string, unknown>) || {};
if (Object.keys(cfg).length > 0) {
// Merge with defaults to ensure optional fields have values
return {
enableDiscordControlTool: true,
enableWhispergatePolicyTool: true,
discordControlApiBaseUrl: "http://127.0.0.1:8790",
enableDebugLogs: false,
debugLogChannelIds: [],
...cfg,
} as WhisperGateConfig;
}
return fallback;
}
function resolvePoliciesPath(api: OpenClawPluginApi, config: WhisperGateConfig): string {
return api.resolvePath(config.channelPoliciesFile || "~/.openclaw/whispergate-channel-policies.json");
}
function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: WhisperGateConfig) {
if (policyState.filePath) return;
const filePath = resolvePoliciesPath(api, config);
policyState.filePath = filePath;
try {
if (!fs.existsSync(filePath)) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, "{}\n", "utf8");
policyState.channelPolicies = {};
return;
}
const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw) as Record<string, ChannelPolicy>;
policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {};
} catch (err) {
api.logger.warn(`whispergate: failed init policy file ${filePath}: ${String(err)}`);
policyState.channelPolicies = {};
}
}
function persistPolicies(api: OpenClawPluginApi): void {
const filePath = policyState.filePath;
if (!filePath) throw new Error("policy file path not initialized");
const before = JSON.stringify(policyState.channelPolicies, null, 2) + "\n";
const tmp = `${filePath}.tmp`;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(tmp, before, "utf8");
fs.renameSync(tmp, filePath);
api.logger.info(`whispergate: policy file persisted: ${filePath}`);
}
function pickDefined(input: Record<string, unknown>) {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(input)) {
if (v !== undefined) out[k] = v;
}
return out;
}
function shouldDebugLog(cfg: DebugConfig, channelId?: string): boolean {
if (!cfg.enableDebugLogs) return false;
const allow = Array.isArray(cfg.debugLogChannelIds) ? cfg.debugLogChannelIds : [];
if (allow.length === 0) return true;
if (!channelId) return true; // 允许打印,方便排查 channelId 为空的场景
return allow.includes(channelId);
}
function debugCtxSummary(ctx: Record<string, unknown>, event: Record<string, unknown>) {
const meta = ((ctx.metadata || event.metadata || {}) as Record<string, unknown>) || {};
return {
sessionKey: typeof ctx.sessionKey === "string" ? ctx.sessionKey : undefined,
commandSource: typeof ctx.commandSource === "string" ? ctx.commandSource : undefined,
messageProvider: typeof ctx.messageProvider === "string" ? ctx.messageProvider : undefined,
channel: typeof ctx.channel === "string" ? ctx.channel : undefined,
channelId: typeof ctx.channelId === "string" ? ctx.channelId : undefined,
senderId: typeof ctx.senderId === "string" ? ctx.senderId : undefined,
from: typeof ctx.from === "string" ? ctx.from : undefined,
metaSenderId:
typeof meta.senderId === "string"
? meta.senderId
: typeof meta.sender_id === "string"
? meta.sender_id
: undefined,
metaUserId:
typeof meta.userId === "string"
? meta.userId
: typeof meta.user_id === "string"
? meta.user_id
: undefined,
};
}
export default {
id: "whispergate",
name: "WhisperGate",
register(api: OpenClawPluginApi) {
// Merge pluginConfig with defaults (in case config is missing from openclaw.json)
const baseConfig = {
enableDiscordControlTool: true,
enableWhispergatePolicyTool: true,
discordControlApiBaseUrl: "http://127.0.0.1:8790",
...(api.pluginConfig || {}),
} as WhisperGateConfig & {
enableDiscordControlTool: boolean;
discordControlApiBaseUrl: string;
discordControlApiToken?: string;
discordControlCallerId?: string;
enableWhispergatePolicyTool: boolean;
};
const liveAtRegister = getLivePluginConfig(api, baseConfig as WhisperGateConfig);
ensurePolicyStateLoaded(api, liveAtRegister);
api.registerTool(
{
name: "whispergateway_tools",
description: "WhisperGate unified tool: Discord admin actions + in-memory policy management.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
action: {
type: "string",
enum: ["channel-private-create", "channel-private-update", "member-list", "policy-get", "policy-set-channel", "policy-delete-channel"],
},
guildId: { type: "string" },
name: { type: "string" },
type: { type: "number" },
parentId: { type: "string" },
topic: { type: "string" },
position: { type: "number" },
nsfw: { type: "boolean" },
allowedUserIds: { type: "array", items: { type: "string" } },
allowedRoleIds: { type: "array", items: { type: "string" } },
allowMask: { type: "string" },
denyEveryoneMask: { type: "string" },
channelId: { type: "string" },
mode: { type: "string", enum: ["merge", "replace"] },
addUserIds: { type: "array", items: { type: "string" } },
addRoleIds: { type: "array", items: { type: "string" } },
removeTargetIds: { type: "array", items: { type: "string" } },
denyMask: { type: "string" },
limit: { type: "number" },
after: { type: "string" },
fields: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }] },
dryRun: { type: "boolean" },
listMode: { type: "string", enum: ["human-list", "agent-list"] },
humanList: { type: "array", items: { type: "string" } },
agentList: { type: "array", items: { type: "string" } },
endSymbols: { type: "array", items: { type: "string" } },
},
required: ["action"],
},
async execute(_id: string, params: Record<string, unknown>) {
const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & {
discordControlApiBaseUrl?: string;
discordControlApiToken?: string;
discordControlCallerId?: string;
enableDiscordControlTool?: boolean;
enableWhispergatePolicyTool?: boolean;
};
ensurePolicyStateLoaded(api, live);
const action = String(params.action || "");
const discordActions = new Set(["channel-private-create", "channel-private-update", "member-list"]);
if (discordActions.has(action)) {
if (live.enableDiscordControlTool === false) {
return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true };
}
const baseUrl = (live.discordControlApiBaseUrl || "http://127.0.0.1:8790").replace(/\/$/, "");
const body = pickDefined({ ...params, action: action as DiscordControlAction });
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (live.discordControlApiToken) headers.Authorization = `Bearer ${live.discordControlApiToken}`;
if (live.discordControlCallerId) headers["X-OpenClaw-Caller-Id"] = live.discordControlCallerId;
const r = await fetch(`${baseUrl}/v1/discord/action`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
const text = await r.text();
if (!r.ok) {
return {
content: [{ type: "text", text: `whispergateway_tools discord failed (${r.status}): ${text}` }],
isError: true,
};
}
return { content: [{ type: "text", text }] };
}
if (live.enableWhispergatePolicyTool === false) {
return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true };
}
if (action === "policy-get") {
return {
content: [{ type: "text", text: JSON.stringify({ file: policyState.filePath, policies: policyState.channelPolicies }, null, 2) }],
};
}
if (action === "policy-set-channel") {
const channelId = String(params.channelId || "").trim();
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
const prev = JSON.parse(JSON.stringify(policyState.channelPolicies));
try {
const next: ChannelPolicy = {
listMode: (params.listMode as "human-list" | "agent-list" | undefined) || undefined,
humanList: Array.isArray(params.humanList) ? (params.humanList as string[]) : undefined,
agentList: Array.isArray(params.agentList) ? (params.agentList as string[]) : undefined,
endSymbols: Array.isArray(params.endSymbols) ? (params.endSymbols as string[]) : undefined,
};
policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record<string, unknown>) as ChannelPolicy;
persistPolicies(api);
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, policy: policyState.channelPolicies[channelId] }) }] };
} catch (err) {
policyState.channelPolicies = prev;
return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true };
}
}
if (action === "policy-delete-channel") {
const channelId = String(params.channelId || "").trim();
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
const prev = JSON.parse(JSON.stringify(policyState.channelPolicies));
try {
delete policyState.channelPolicies[channelId];
persistPolicies(api);
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, deleted: true }) }] };
} catch (err) {
policyState.channelPolicies = prev;
return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true };
}
}
return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true };
},
},
{ optional: false },
);
api.on("message_received", async (event, ctx) => {
try {
const c = (ctx || {}) as Record<string, unknown>;
const e = (event || {}) as Record<string, unknown>;
const preChannelId = typeof c.channelId === "string" ? c.channelId : undefined;
const livePre = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
if (shouldDebugLog(livePre, preChannelId)) {
api.logger.info(`whispergate: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`);
}
} catch (err) {
api.logger.warn(`whispergate: message hook failed: ${String(err)}`);
}
});
api.on("before_model_resolve", async (event, ctx) => {
const key = ctx.sessionKey;
if (!key) return;
const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
ensurePolicyStateLoaded(api, live);
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
if (live.enableDebugLogs) {
api.logger.info(
`whispergate: DEBUG_BEFORE_MODEL_RESOLVE ctx=${JSON.stringify({ sessionKey: ctx.sessionKey, messageProvider: ctx.messageProvider, agentId: ctx.agentId })} ` +
`promptPreview=${prompt.slice(0, 300)}`,
);
}
const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider);
// Only proceed if: discord channel AND prompt contains untrusted metadata
const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):");
if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return;
let rec = sessionDecision.get(key);
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
if (rec) sessionDecision.delete(key);
const decision = evaluateDecision({
config: live,
channel: derived.channel,
channelId: derived.channelId,
channelPolicies: policyState.channelPolicies,
senderId: derived.senderId,
content: derived.content,
});
rec = { decision, createdAt: Date.now() };
sessionDecision.set(key, rec);
pruneDecisionMap();
if (shouldDebugLog(live, derived.channelId)) {
api.logger.info(
`whispergate: debug before_model_resolve recompute session=${key} ` +
`channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` +
`convSenderId=${String((derived.conv as Record<string, unknown>).sender_id ?? "")} ` +
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
`convChannelId=${String((derived.conv as Record<string, unknown>).channel_id ?? "")} ` +
`decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`,
);
}
}
if (!rec.decision.shouldUseNoReply) {
// 如果之前有 no-reply 执行过,现在不需要了,清除 override 恢复原模型
if (rec.needsRestore) {
sessionDecision.delete(key);
return {
providerOverride: undefined,
modelOverride: undefined,
};
}
return;
}
// 标记这次执行了 no-reply下次需要恢复模型
rec.needsRestore = true;
sessionDecision.set(key, rec);
// 无论是否有缓存,只要 debug flag 开启就打印决策详情
if (live.enableDebugLogs) {
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):");
api.logger.info(
`whispergate: DEBUG_NO_REPLY_TRIGGER session=${key} ` +
`channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` +
`convSenderId=${String((derived.conv as Record<string, unknown>).sender_id ?? "")} ` +
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
`decision=${rec.decision.reason} ` +
`shouldNoReply=${rec.decision.shouldUseNoReply} shouldInject=${rec.decision.shouldInjectEndMarkerPrompt} ` +
`hasConvMarker=${hasConvMarker} promptLen=${prompt.length}`,
);
}
api.logger.info(
`whispergate: override model for session=${key}, provider=${live.noReplyProvider}, model=${live.noReplyModel}, reason=${rec.decision.reason}`,
);
return {
providerOverride: live.noReplyProvider,
modelOverride: live.noReplyModel,
};
});
api.on("before_prompt_build", async (event, ctx) => {
const key = ctx.sessionKey;
if (!key) return;
const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
ensurePolicyStateLoaded(api, live);
let rec = sessionDecision.get(key);
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
if (rec) sessionDecision.delete(key);
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider);
const decision = evaluateDecision({
config: live,
channel: derived.channel,
channelId: derived.channelId,
channelPolicies: policyState.channelPolicies,
senderId: derived.senderId,
content: derived.content,
});
rec = { decision, createdAt: Date.now() };
if (shouldDebugLog(live, derived.channelId)) {
api.logger.info(
`whispergate: debug before_prompt_build recompute session=${key} ` +
`channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` +
`convSenderId=${String((derived.conv as Record<string, unknown>).sender_id ?? "")} ` +
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
`convChannelId=${String((derived.conv as Record<string, unknown>).channel_id ?? "")} ` +
`decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`,
);
}
}
sessionDecision.delete(key);
if (!rec.decision.shouldInjectEndMarkerPrompt) {
if (shouldDebugLog(live, undefined)) {
api.logger.info(
`whispergate: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`,
);
}
return;
}
api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason}`);
return { prependContext: END_MARKER_INSTRUCTION };
});
},
};