Compare commits

..

3 Commits

7 changed files with 117 additions and 51 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
.env
.DS_Store
*.log

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
services:
whispergate-no-reply-api:
build:
context: ./no-reply-api
container_name: whispergate-no-reply-api
ports:
- "8787:8787"
environment:
- PORT=8787
- NO_REPLY_MODEL=whispergate-no-reply-v1
restart: unless-stopped

View File

@@ -0,0 +1,2 @@
node_modules
npm-debug.log

7
no-reply-api/Dockerfile Normal file
View File

@@ -0,0 +1,7 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
COPY server.mjs ./
EXPOSE 8787
ENV PORT=8787
CMD ["node", "server.mjs"]

27
plugin/README.md Normal file
View File

@@ -0,0 +1,27 @@
# WhisperGate Plugin
## Hook strategy
- `message:received` caches a per-session decision from deterministic rules.
- `before_model_resolve` applies `providerOverride + modelOverride` when decision says no-reply.
## Rules (in order)
1. non-discord -> skip
2. bypass sender -> skip
3. end symbol matched -> skip
4. else -> no-reply override
## Config
See `docs/CONFIG.example.json`.
Required:
- `noReplyProvider`
- `noReplyModel`
Optional:
- `enabled` (default true)
- `discordOnly` (default true)
- `bypassUserIds` (default [])
- `endSymbols` (default punctuation set)

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" };
}