diff --git a/plugin/index.ts b/plugin/index.ts index 0561df2..20ac75e 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,7 +1,7 @@ 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"; +import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js"; type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; @@ -24,7 +24,10 @@ type DebugConfig = { const sessionDecision = new Map(); const MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; -const END_MARKER_INSTRUCTION = "你的这次发言必须以🔚作为结尾。"; +function buildEndMarkerInstruction(endSymbols: string[]): string { + const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚"; + return `你的这次发言必须以${symbols}作为结尾。除非你的回复是 gateway 关键词(如 NO_REPLY、HEARTBEAT_OK),这些关键词不要加${symbols}。`; +} const policyState: PolicyState = { filePath: "", @@ -355,7 +358,7 @@ export default { return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true }; }, }, - { optional: true }, + { optional: false }, ); api.on("message_received", async (event, ctx) => { @@ -505,8 +508,14 @@ export default { return; } + // Resolve end symbols from config/policy for dynamic instruction + const prompt = ((event as Record).prompt as string) || ""; + const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider); + const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies); + const instruction = buildEndMarkerInstruction(policy.endSymbols); + api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason}`); - return { prependContext: END_MARKER_INSTRUCTION }; + return { prependContext: instruction }; }); }, }; diff --git a/plugin/rules.ts b/plugin/rules.ts index 42b50d1..ca00593 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -25,12 +25,31 @@ export type Decision = { reason: string; }; -function getLastChar(input: string): string { - const t = input.trim(); - return t.length ? t[t.length - 1] : ""; +/** + * Strip trailing OpenClaw metadata blocks from the prompt to get the actual message content. + * The prompt format is: [prepend instruction]\n\n[message]\n\nConversation info (untrusted metadata):\n```json\n{...}\n```\n\nSender (untrusted metadata):\n```json\n{...}\n``` + */ +function stripTrailingMetadata(input: string): string { + // Remove all trailing "XXX (untrusted metadata):\n```json\n...\n```" blocks + let text = input; + // eslint-disable-next-line no-constant-condition + while (true) { + const m = text.match(/\n*[A-Z][^\n]*\(untrusted metadata\):\s*```json\s*[\s\S]*?```\s*$/); + if (!m) break; + text = text.slice(0, text.length - m[0].length); + } + return text; } -function resolvePolicy(config: WhisperGateConfig, channelId?: string, channelPolicies?: Record) { +function getLastChar(input: string): string { + const t = stripTrailingMetadata(input).trim(); + if (!t.length) return ""; + // Use Array.from to handle multi-byte characters (emoji, surrogate pairs) + const chars = Array.from(t); + return chars[chars.length - 1] || ""; +} + +export function resolvePolicy(config: WhisperGateConfig, channelId?: string, channelPolicies?: Record) { const globalMode = config.listMode || "human-list"; const globalHuman = config.humanList || config.bypassUserIds || []; const globalAgent = config.agentList || []; @@ -73,6 +92,12 @@ export function evaluateDecision(params: { return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: false, reason: "non_discord" }; } + // DM bypass: if no conversation info was found in the prompt (no senderId AND no channelId), + // this is a DM session where untrusted metadata is not injected. Always allow through. + if (!params.senderId && !params.channelId) { + return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: "dm_no_metadata_bypass" }; + } + const policy = resolvePolicy(config, params.channelId, params.channelPolicies); const mode = policy.listMode; diff --git a/scripts/install-whispergate-openclaw.mjs b/scripts/install-whispergate-openclaw.mjs index 73d7ad9..be64876 100755 --- a/scripts/install-whispergate-openclaw.mjs +++ b/scripts/install-whispergate-openclaw.mjs @@ -13,7 +13,8 @@ const mode = modeArg === "--install" ? "install" : "uninstall"; const env = process.env; const OPENCLAW_CONFIG_PATH = env.OPENCLAW_CONFIG_PATH || path.join(os.homedir(), ".openclaw", "openclaw.json"); -const PLUGIN_PATH = env.PLUGIN_PATH || "/root/.openclaw/workspace-operator/WhisperGate/dist/whispergate"; +const __dirname = path.dirname(new URL(import.meta.url).pathname); +const PLUGIN_PATH = env.PLUGIN_PATH || path.resolve(__dirname, "..", "dist", "whispergate"); const NO_REPLY_PROVIDER_ID = env.NO_REPLY_PROVIDER_ID || "whisper-gateway"; const NO_REPLY_MODEL_ID = env.NO_REPLY_MODEL_ID || "no-reply"; const NO_REPLY_BASE_URL = env.NO_REPLY_BASE_URL || "http://127.0.0.1:8787/v1";