feat: turn-based speaking + slash commands + enhanced prompts
1. Rename tool: whispergateway_tools → whispergate_tools 2. Turn-based speaking mechanism: - New turn-manager.ts maintains per-channel turn state - ChannelPolicy新增turnOrder字段配置发言顺序 - before_model_resolve hook检查当前agent是否为发言人 - 非当前发言人直接切换到no-reply模型 - message_sent hook检测结束符或NO_REPLY时推进turn - message_received检测到human消息时重置turn 3. 注入提示词增强: - buildEndMarkerInstruction增加isGroupChat参数 - 群聊时追加规则:与自己无关时主动回复NO_REPLY 4. Slash command支持: - /whispergate status - 查看频道策略 - /whispergate turn-status - 查看轮流状态 - /whispergate turn-advance - 手动推进轮流 - /whispergate turn-reset - 重置轮流顺序
This commit is contained in:
191
plugin/index.ts
191
plugin/index.ts
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js";
|
||||
import { checkTurn, advanceTurn, resetTurn, setTurnPolicy, getTurnDebugInfo, type TurnPolicy } from "./turn-manager.js";
|
||||
|
||||
type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list";
|
||||
|
||||
@@ -24,9 +25,13 @@ type DebugConfig = {
|
||||
const sessionDecision = new Map<string, DecisionRecord>();
|
||||
const MAX_SESSION_DECISIONS = 2000;
|
||||
const DECISION_TTL_MS = 5 * 60 * 1000;
|
||||
function buildEndMarkerInstruction(endSymbols: string[]): string {
|
||||
function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean): string {
|
||||
const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚";
|
||||
return `你的这次发言必须以${symbols}作为结尾。除非你的回复是 gateway 关键词(如 NO_REPLY、HEARTBEAT_OK),这些关键词不要加${symbols}。`;
|
||||
let instruction = `你的这次发言必须以${symbols}作为结尾。除非你的回复是 gateway 关键词(如 NO_REPLY、HEARTBEAT_OK),这些关键词不要加${symbols}。`;
|
||||
if (isGroupChat) {
|
||||
instruction += `\n\n群聊发言规则:如果这条消息与你无关、不需要你回应、或你没有有价值的补充,请主动回复 NO_REPLY。不要为了说话而说话。`;
|
||||
}
|
||||
return instruction;
|
||||
}
|
||||
|
||||
const policyState: PolicyState = {
|
||||
@@ -153,12 +158,40 @@ function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: WhisperGateConf
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, ChannelPolicy>;
|
||||
policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {};
|
||||
syncTurnPolicies();
|
||||
} catch (err) {
|
||||
api.logger.warn(`whispergate: failed init policy file ${filePath}: ${String(err)}`);
|
||||
policyState.channelPolicies = {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve agentId → Discord accountId from config bindings */
|
||||
function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | undefined {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
|
||||
if (!Array.isArray(bindings)) return undefined;
|
||||
for (const b of bindings) {
|
||||
if (b.agentId === agentId) {
|
||||
const match = b.match as Record<string, unknown> | undefined;
|
||||
if (match?.channel === "discord" && typeof match.accountId === "string") {
|
||||
return match.accountId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Sync turn policies from channel policies into the turn manager */
|
||||
function syncTurnPolicies(): void {
|
||||
for (const [channelId, policy] of Object.entries(policyState.channelPolicies)) {
|
||||
if (policy.turnOrder?.length) {
|
||||
setTurnPolicy(channelId, { turnOrder: policy.turnOrder });
|
||||
} else {
|
||||
setTurnPolicy(channelId, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function persistPolicies(api: OpenClawPluginApi): void {
|
||||
const filePath = policyState.filePath;
|
||||
if (!filePath) throw new Error("policy file path not initialized");
|
||||
@@ -167,6 +200,7 @@ function persistPolicies(api: OpenClawPluginApi): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(tmp, before, "utf8");
|
||||
fs.renameSync(tmp, filePath);
|
||||
syncTurnPolicies();
|
||||
api.logger.info(`whispergate: policy file persisted: ${filePath}`);
|
||||
}
|
||||
|
||||
@@ -234,7 +268,7 @@ export default {
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "whispergateway_tools",
|
||||
name: "whispergate_tools",
|
||||
description: "WhisperGate unified tool: Discord admin actions + in-memory policy management.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
@@ -242,7 +276,7 @@ export default {
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
enum: ["channel-private-create", "channel-private-update", "member-list", "policy-get", "policy-set-channel", "policy-delete-channel"],
|
||||
enum: ["channel-private-create", "channel-private-update", "member-list", "policy-get", "policy-set-channel", "policy-delete-channel", "turn-status", "turn-advance", "turn-reset"],
|
||||
},
|
||||
guildId: { type: "string" },
|
||||
name: { type: "string" },
|
||||
@@ -269,6 +303,7 @@ export default {
|
||||
humanList: { type: "array", items: { type: "string" } },
|
||||
agentList: { type: "array", items: { type: "string" } },
|
||||
endSymbols: { type: "array", items: { type: "string" } },
|
||||
turnOrder: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: ["action"],
|
||||
},
|
||||
@@ -303,7 +338,7 @@ export default {
|
||||
const text = await r.text();
|
||||
if (!r.ok) {
|
||||
return {
|
||||
content: [{ type: "text", text: `whispergateway_tools discord failed (${r.status}): ${text}` }],
|
||||
content: [{ type: "text", text: `whispergate_tools discord failed (${r.status}): ${text}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
@@ -331,6 +366,7 @@ export default {
|
||||
humanList: Array.isArray(params.humanList) ? (params.humanList as string[]) : undefined,
|
||||
agentList: Array.isArray(params.agentList) ? (params.agentList as string[]) : undefined,
|
||||
endSymbols: Array.isArray(params.endSymbols) ? (params.endSymbols as string[]) : undefined,
|
||||
turnOrder: Array.isArray(params.turnOrder) ? (params.turnOrder as string[]) : undefined,
|
||||
};
|
||||
policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record<string, unknown>) as ChannelPolicy;
|
||||
persistPolicies(api);
|
||||
@@ -355,6 +391,26 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "turn-status") {
|
||||
const channelId = String(params.channelId || "").trim();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
return { content: [{ type: "text", text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) }] };
|
||||
}
|
||||
|
||||
if (action === "turn-advance") {
|
||||
const channelId = String(params.channelId || "").trim();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
const next = advanceTurn(channelId);
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, nextSpeaker: next, ...getTurnDebugInfo(channelId) }) }] };
|
||||
}
|
||||
|
||||
if (action === "turn-reset") {
|
||||
const channelId = String(params.channelId || "").trim();
|
||||
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||
resetTurn(channelId);
|
||||
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, ...getTurnDebugInfo(channelId) }) }] };
|
||||
}
|
||||
|
||||
return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true };
|
||||
},
|
||||
},
|
||||
@@ -370,6 +426,16 @@ export default {
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
api.logger.info(`whispergate: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`);
|
||||
}
|
||||
|
||||
// Reset turn when human sends a message in a turn-managed channel
|
||||
if (preChannelId) {
|
||||
const from = typeof (e as Record<string, unknown>).from === "string" ? (e as Record<string, unknown>).from as string : "";
|
||||
const humanList = livePre.humanList || livePre.bypassUserIds || [];
|
||||
if (humanList.includes(from)) {
|
||||
resetTurn(preChannelId);
|
||||
api.logger.info(`whispergate: turn reset by human message in channel=${preChannelId} from=${from}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`whispergate: message hook failed: ${String(err)}`);
|
||||
}
|
||||
@@ -422,6 +488,23 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// Turn-based check: if channel has turn order, only current speaker can respond
|
||||
if (!rec.decision.shouldUseNoReply && derived.channelId) {
|
||||
const accountId = resolveAccountId(api, ctx.agentId || "");
|
||||
if (accountId) {
|
||||
const turnCheck = checkTurn(derived.channelId, accountId);
|
||||
if (!turnCheck.isSpeaker) {
|
||||
api.logger.info(
|
||||
`whispergate: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`,
|
||||
);
|
||||
return {
|
||||
providerOverride: live.noReplyProvider,
|
||||
modelOverride: live.noReplyModel,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!rec.decision.shouldUseNoReply) {
|
||||
// 如果之前有 no-reply 执行过,现在不需要了,清除 override 恢复原模型
|
||||
if (rec.needsRestore) {
|
||||
@@ -512,10 +595,104 @@ export default {
|
||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||
const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider);
|
||||
const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies);
|
||||
const instruction = buildEndMarkerInstruction(policy.endSymbols);
|
||||
const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true";
|
||||
const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat);
|
||||
|
||||
api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason}`);
|
||||
api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`);
|
||||
return { prependContext: instruction };
|
||||
});
|
||||
|
||||
// Register slash commands for Discord
|
||||
api.registerCommand({
|
||||
name: "whispergate",
|
||||
description: "WhisperGate 频道策略管理",
|
||||
acceptsArgs: true,
|
||||
handler: async (cmdCtx) => {
|
||||
const args = cmdCtx.args || "";
|
||||
const parts = args.trim().split(/\s+/);
|
||||
const subCmd = parts[0] || "help";
|
||||
|
||||
if (subCmd === "help") {
|
||||
return { text: `WhisperGate 命令:\n` +
|
||||
`/whispergate status - 显示当前频道状态\n` +
|
||||
`/whispergate turn-status - 显示轮流发言状态\n` +
|
||||
`/whispergate turn-advance - 手动推进轮流\n` +
|
||||
`/whispergate turn-reset - 重置轮流顺序` };
|
||||
}
|
||||
|
||||
if (subCmd === "status") {
|
||||
return { text: JSON.stringify({ policies: policyState.channelPolicies }, null, 2) };
|
||||
}
|
||||
|
||||
if (subCmd === "turn-status") {
|
||||
const channelId = cmdCtx.channelId;
|
||||
if (!channelId) return { text: "无法获取频道ID", isError: true };
|
||||
return { text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) };
|
||||
}
|
||||
|
||||
if (subCmd === "turn-advance") {
|
||||
const channelId = cmdCtx.channelId;
|
||||
if (!channelId) return { text: "无法获取频道ID", isError: true };
|
||||
const next = advanceTurn(channelId);
|
||||
return { text: JSON.stringify({ ok: true, nextSpeaker: next }) };
|
||||
}
|
||||
|
||||
if (subCmd === "turn-reset") {
|
||||
const channelId = cmdCtx.channelId;
|
||||
if (!channelId) return { text: "无法获取频道ID", isError: true };
|
||||
resetTurn(channelId);
|
||||
return { text: JSON.stringify({ ok: true }) };
|
||||
}
|
||||
|
||||
return { text: `未知子命令: ${subCmd}`, isError: true };
|
||||
},
|
||||
});
|
||||
|
||||
// Turn advance: when an agent sends a message, check if it signals end of turn
|
||||
api.on("message_sent", async (event, ctx) => {
|
||||
try {
|
||||
const channelId = ctx.channelId;
|
||||
const accountId = ctx.accountId;
|
||||
const content = event.content || "";
|
||||
|
||||
if (!channelId || !accountId) return;
|
||||
|
||||
const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||
ensurePolicyStateLoaded(api, live);
|
||||
const policy = resolvePolicy(live, channelId, policyState.channelPolicies);
|
||||
|
||||
// Check if this message ends the turn:
|
||||
// 1. Content is empty (no-reply was used)
|
||||
// 2. Content ends with an end symbol
|
||||
// 3. Content is a gateway keyword like NO_REPLY
|
||||
const trimmed = content.trim();
|
||||
const isEmpty = trimmed.length === 0;
|
||||
const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed);
|
||||
const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : "";
|
||||
const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar);
|
||||
|
||||
if (isEmpty || isNoReply || hasEndSymbol) {
|
||||
const nextSpeaker = advanceTurn(channelId);
|
||||
if (nextSpeaker) {
|
||||
api.logger.info(
|
||||
`whispergate: turn advanced in channel=${channelId} from=${accountId} to=${nextSpeaker} ` +
|
||||
`trigger=${isEmpty ? "empty" : isNoReply ? "no_reply" : "end_symbol"}`,
|
||||
);
|
||||
|
||||
// Wake the next speaker by sending a nudge message to the channel
|
||||
// The next agent will pick it up as a new message_received
|
||||
// We use a zero-width space message to avoid visible noise
|
||||
// Actually, we need the next agent's session to receive a trigger.
|
||||
// The simplest approach: the turn manager just advances state.
|
||||
// The next message in the channel (from any source) will allow
|
||||
// the next speaker to respond. If the current speaker said NO_REPLY
|
||||
// (empty content), the original message is still pending for the
|
||||
// next speaker.
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`whispergate: message_sent hook failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user