From 7728892d1546fe10a780e6ed563a4ce7cf835717 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 10:36:59 +0000 Subject: [PATCH] feat(plugin): add WhisperGate rule engine and model override hook --- plugin/index.ts | 105 ++++++++++++++++++++++++++++++++++++ plugin/openclaw.plugin.json | 20 +++++++ 2 files changed, 125 insertions(+) create mode 100644 plugin/index.ts create mode 100644 plugin/openclaw.plugin.json diff --git a/plugin/index.ts b/plugin/index.ts new file mode 100644 index 0000000..7c98502 --- /dev/null +++ b/plugin/index.ts @@ -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(); + +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; + const e = (event || {}) as Record; + 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, + }; + }); + }, +}; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json new file mode 100644 index 0000000..bfba37e --- /dev/null +++ b/plugin/openclaw.plugin.json @@ -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"] + } +}