feat(plugin): extract rule engine and harden channel/session handling

This commit is contained in:
2026-02-25 10:39:47 +00:00
parent e020f1f026
commit 83b9d517ec
2 changed files with 66 additions and 51 deletions

View File

@@ -1,60 +1,32 @@
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;
};
import { evaluateDecision, type Decision, type WhisperGateConfig } from "./rules.js";
const sessionDecision = new Map<string, Decision>();
const MAX_SESSION_DECISIONS = 2000;
function getLastChar(input: string): string {
const t = input.trim();
return t.length ? t[t.length - 1] : "";
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 shouldUseNoReply(params: {
config: Config;
channel?: string;
senderId?: string;
content?: string;
}): Decision {
const { config } = params;
if (config.enabled === false) {
return { shouldUseNoReply: false, reason: "disabled" };
function pruneDecisionMap() {
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);
}
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;
const config = (api.pluginConfig || {}) as WhisperGateConfig;
api.registerHook("message:received", async (event, ctx) => {
try {
@@ -71,15 +43,11 @@ export default {
: undefined;
const content = typeof e.content === "string" ? e.content : "";
const channel =
typeof c.commandSource === "string"
? c.commandSource
: typeof c.channelId === "string"
? c.channelId
: "";
const channel = normalizeChannel(c);
const decision = shouldUseNoReply({ config, channel, senderId, content });
const decision = evaluateDecision({ config, channel, senderId, content });
sessionDecision.set(sessionKey, decision);
pruneDecisionMap();
api.logger.debug?.(`whispergate: session=${sessionKey} decision=${decision.reason}`);
} catch (err) {
api.logger.warn(`whispergate: message hook failed: ${String(err)}`);

47
plugin/rules.ts Normal file
View File

@@ -0,0 +1,47 @@
export type WhisperGateConfig = {
enabled?: boolean;
discordOnly?: boolean;
bypassUserIds?: string[];
endSymbols?: string[];
noReplyProvider: string;
noReplyModel: string;
};
export type Decision = {
shouldUseNoReply: boolean;
reason: string;
};
function getLastChar(input: string): string {
const t = input.trim();
return t.length ? t[t.length - 1] : "";
}
export function evaluateDecision(params: {
config: WhisperGateConfig;
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" };
}