feat(plugin): add WhisperGate rule engine and model override hook

This commit is contained in:
2026-02-25 10:36:59 +00:00
parent 1140a928f3
commit 7728892d15
2 changed files with 125 additions and 0 deletions

105
plugin/index.ts Normal file
View File

@@ -0,0 +1,105 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
type Config = {
enabled?: boolean;
discordOnly?: boolean;
bypassUserIds?: string[];
endSymbols?: string[];
noReplyProvider: string;
noReplyModel: string;
};
type Decision = {
shouldUseNoReply: boolean;
reason: string;
};
const sessionDecision = new Map<string, Decision>();
function getLastChar(input: string): string {
const t = input.trim();
return t.length ? t[t.length - 1] : "";
}
function shouldUseNoReply(params: {
config: Config;
channel?: string;
senderId?: string;
content?: string;
}): Decision {
const { config } = params;
if (config.enabled === false) {
return { shouldUseNoReply: false, reason: "disabled" };
}
const channel = (params.channel || "").toLowerCase();
if (config.discordOnly !== false && channel !== "discord") {
return { shouldUseNoReply: false, reason: "non_discord" };
}
if (params.senderId && (config.bypassUserIds || []).includes(params.senderId)) {
return { shouldUseNoReply: false, reason: "bypass_sender" };
}
const lastChar = getLastChar(params.content || "");
if (lastChar && (config.endSymbols || []).includes(lastChar)) {
return { shouldUseNoReply: false, reason: `end_symbol:${lastChar}` };
}
return { shouldUseNoReply: true, reason: "rule_match_no_end_symbol" };
}
export default {
id: "whispergate",
name: "WhisperGate",
register(api: OpenClawPluginApi) {
const config = (api.pluginConfig || {}) as Config;
api.registerHook("message:received", async (event, ctx) => {
try {
const c = (ctx || {}) as Record<string, unknown>;
const e = (event || {}) as Record<string, unknown>;
const sessionKey = typeof c.sessionKey === "string" ? c.sessionKey : undefined;
if (!sessionKey) return;
const senderId =
typeof c.senderId === "string"
? c.senderId
: typeof e.from === "string"
? e.from
: undefined;
const content = typeof e.content === "string" ? e.content : "";
const channel =
typeof c.commandSource === "string"
? c.commandSource
: typeof c.channelId === "string"
? c.channelId
: "";
const decision = shouldUseNoReply({ config, channel, senderId, content });
sessionDecision.set(sessionKey, decision);
api.logger.debug?.(`whispergate: session=${sessionKey} decision=${decision.reason}`);
} 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 decision = sessionDecision.get(key);
if (!decision?.shouldUseNoReply) return;
api.logger.info(
`whispergate: override model for session=${key}, provider=${config.noReplyProvider}, model=${config.noReplyModel}, reason=${decision.reason}`,
);
return {
providerOverride: config.noReplyProvider,
modelOverride: config.noReplyModel,
};
});
},
};

View File

@@ -0,0 +1,20 @@
{
"id": "whispergate",
"name": "WhisperGate",
"version": "0.1.0",
"description": "Rule-based no-reply gate with provider/model override",
"entry": "./index.ts",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean", "default": true },
"discordOnly": { "type": "boolean", "default": true },
"bypassUserIds": { "type": "array", "items": { "type": "string" }, "default": [] },
"endSymbols": { "type": "array", "items": { "type": "string" }, "default": ["。", "", "", ".", "!", "?"] },
"noReplyProvider": { "type": "string" },
"noReplyModel": { "type": "string" }
},
"required": ["noReplyProvider", "noReplyModel"]
}
}