fix: channelId extraction, sender identification, and per-channel turn order

- Fix channelId extraction: ctx.channelId is platform name ('discord'), not
  the Discord channel snowflake. Now extracts from conversation_label field
  ('channel id:123456') and sessionKey fallback (':channel:123456').

- Fix extractDiscordChannelId: support 'discord:channel:xxx' format in
  addition to 'channel:xxx' for conversationId/event.to fields.

- Fix sender identification in message_received: event.from returns channel
  target, not sender ID. Now uses event.metadata.senderId for humanList
  matching so human messages correctly reset turn order.

- Fix per-channel turn order: was using all server-wide bot accounts from
  bindings, causing deadlock when turn landed on bots not in the channel.
  Now dynamically tracks which bot accounts are seen per channel via
  message_received and only includes those in turn order.

- Always save sessionChannelId/sessionAccountId mappings in before_model_resolve
  regardless of turn check result, so downstream hooks can use them.

- Add comprehensive debug logging to message_sent hook.
This commit is contained in:
zhi
2026-03-01 11:08:41 +00:00
parent a4bc9990db
commit d90083317b
2 changed files with 1267 additions and 46 deletions

1105
dist/whispergate/index.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -52,6 +52,37 @@ function normalizeChannel(ctx: Record<string, unknown>): string {
return ""; return "";
} }
/**
* Extract the actual Discord channel ID from a conversationId or "to" field.
* OpenClaw uses format "channel:<snowflake>" for Discord conversations.
* Also tries event.to and event.metadata.to as fallbacks.
*/
function extractDiscordChannelId(ctx: Record<string, unknown>, event?: Record<string, unknown>): string | undefined {
const candidates: unknown[] = [
ctx.conversationId,
event?.to,
(event?.metadata as Record<string, unknown>)?.to,
];
for (const c of candidates) {
if (typeof c === "string" && c.trim()) {
const s = c.trim();
// Handle "channel:123456" format
if (s.startsWith("channel:")) {
const id = s.slice("channel:".length);
if (/^\d+$/.test(id)) return id;
}
// Handle "discord:channel:123456" format
if (s.startsWith("discord:channel:")) {
const id = s.slice("discord:channel:".length);
if (/^\d+$/.test(id)) return id;
}
// If it's a raw snowflake (all digits), use directly
if (/^\d{15,}$/.test(s)) return s;
}
}
return undefined;
}
function normalizeSender(event: Record<string, unknown>, ctx: Record<string, unknown>): string | undefined { function normalizeSender(event: Record<string, unknown>, ctx: Record<string, unknown>): string | undefined {
const direct = [ctx.senderId, ctx.from, event.from]; const direct = [ctx.senderId, ctx.from, event.from];
for (const v of direct) { for (const v of direct) {
@@ -97,14 +128,22 @@ function deriveDecisionInputFromPrompt(
const conv = extractUntrustedConversationInfo(prompt) || {}; const conv = extractUntrustedConversationInfo(prompt) || {};
const channel = (messageProvider || "").toLowerCase(); const channel = (messageProvider || "").toLowerCase();
// Priority: ctx.channelId > conv.chat_id > conv.channel_id // Priority: ctx.channelId > conv.chat_id > conversation_label > conv.channel_id
let channelId = channelIdFromCtx; let channelId = channelIdFromCtx;
if (!channelId) { if (!channelId) {
channelId = // Try chat_id field (format "channel:123456")
(typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:") if (typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:")) {
? conv.chat_id.slice("channel:".length) channelId = conv.chat_id.slice("channel:".length);
: typeof conv.channel_id === "string" && conv.channel_id) || }
undefined; // Try conversation_label (format "Guild #name channel id:123456")
if (!channelId && typeof conv.conversation_label === "string") {
const labelMatch = conv.conversation_label.match(/channel id:(\d+)/);
if (labelMatch) channelId = labelMatch[1];
}
// Try channel_id field directly
if (!channelId && typeof conv.channel_id === "string" && conv.channel_id) {
channelId = conv.channel_id;
}
} }
const senderId = const senderId =
@@ -193,7 +232,7 @@ function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | und
} }
/** /**
* Get all Discord bot accountIds from config bindings (excluding humanList-bound agents). * Get all Discord bot accountIds from config bindings.
*/ */
function getAllBotAccountIds(api: OpenClawPluginApi): string[] { function getAllBotAccountIds(api: OpenClawPluginApi): string[] {
const root = (api.config as Record<string, unknown>) || {}; const root = (api.config as Record<string, unknown>) || {};
@@ -209,12 +248,44 @@ function getAllBotAccountIds(api: OpenClawPluginApi): string[] {
return ids; return ids;
} }
/**
* Track which bot accountIds have been seen in each channel via message_received.
* Key: channelId, Value: Set of accountIds seen.
*/
const channelSeenAccounts = new Map<string, Set<string>>();
/**
* Record a bot accountId seen in a channel.
* Returns true if this is a new account for this channel (turn order should be updated).
*/
function recordChannelAccount(channelId: string, accountId: string): boolean {
let seen = channelSeenAccounts.get(channelId);
if (!seen) {
seen = new Set();
channelSeenAccounts.set(channelId, seen);
}
if (seen.has(accountId)) return false;
seen.add(accountId);
return true;
}
/**
* Get the list of bot accountIds seen in a channel.
* Only returns accounts that are also in the global bindings (actual bots).
*/
function getChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): string[] {
const allBots = new Set(getAllBotAccountIds(api));
const seen = channelSeenAccounts.get(channelId);
if (!seen) return [];
return [...seen].filter(id => allBots.has(id));
}
/** /**
* Ensure turn order is initialized for a channel. * Ensure turn order is initialized for a channel.
* Uses all bot accounts from bindings as the turn order. * Uses only bot accounts that have been seen in this channel.
*/ */
function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): void { function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): void {
const botAccounts = getAllBotAccountIds(api); const botAccounts = getChannelBotAccountIds(api, channelId);
if (botAccounts.length > 0) { if (botAccounts.length > 0) {
initTurnOrder(channelId, botAccounts); initTurnOrder(channelId, botAccounts);
} }
@@ -538,7 +609,9 @@ export default {
try { try {
const c = (ctx || {}) as Record<string, unknown>; const c = (ctx || {}) as Record<string, unknown>;
const e = (event || {}) as Record<string, unknown>; const e = (event || {}) as Record<string, unknown>;
const preChannelId = typeof c.channelId === "string" ? c.channelId : undefined; // ctx.channelId is the platform name (e.g. "discord"), NOT the Discord channel snowflake.
// Extract the real Discord channel ID from conversationId or event.to.
const preChannelId = extractDiscordChannelId(c, e);
const livePre = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; const livePre = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
if (shouldDebugLog(livePre, preChannelId)) { if (shouldDebugLog(livePre, preChannelId)) {
api.logger.info(`whispergate: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`); api.logger.info(`whispergate: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`);
@@ -547,7 +620,11 @@ export default {
// Turn management on message received // Turn management on message received
if (preChannelId) { if (preChannelId) {
ensureTurnOrder(api, preChannelId); ensureTurnOrder(api, preChannelId);
const from = typeof (e as Record<string, unknown>).from === "string" ? (e as Record<string, unknown>).from as string : ""; // event.from is often the channel target (e.g. "discord:channel:xxx"), NOT the sender.
// The actual sender ID is in event.metadata.senderId.
const metadata = (e as Record<string, unknown>).metadata as Record<string, unknown> | undefined;
const from = (typeof metadata?.senderId === "string" && metadata.senderId)
|| (typeof (e as Record<string, unknown>).from === "string" ? (e as Record<string, unknown>).from as string : "");
// Ignore moderator bot messages — they don't affect turn state // Ignore moderator bot messages — they don't affect turn state
const moderatorUserId = getModeratorUserId(livePre); const moderatorUserId = getModeratorUserId(livePre);
@@ -560,6 +637,17 @@ export default {
const humanList = livePre.humanList || livePre.bypassUserIds || []; const humanList = livePre.humanList || livePre.bypassUserIds || [];
const isHuman = humanList.includes(from); const isHuman = humanList.includes(from);
const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined; const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined;
// Track which bot accounts are present in this channel
if (senderAccountId && senderAccountId !== "default") {
const isNew = recordChannelAccount(preChannelId, senderAccountId);
if (isNew) {
// Re-initialize turn order with updated channel membership
ensureTurnOrder(api, preChannelId);
api.logger.info(`whispergate: new account ${senderAccountId} seen in channel=${preChannelId}, turn order updated`);
}
}
onNewMessage(preChannelId, senderAccountId, isHuman); onNewMessage(preChannelId, senderAccountId, isHuman);
if (shouldDebugLog(livePre, preChannelId)) { if (shouldDebugLog(livePre, preChannelId)) {
api.logger.info(`whispergate: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`); api.logger.info(`whispergate: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`);
@@ -588,10 +676,24 @@ export default {
} }
const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId); const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId);
// Fallback: extract channelId from sessionKey (format "agent:<id>:discord:channel:<channelId>")
if (!derived.channelId && key) {
const skMatch = key.match(/:channel:(\d+)$/);
if (skMatch) derived.channelId = skMatch[1];
}
// Only proceed if: discord channel AND prompt contains untrusted metadata // Only proceed if: discord channel AND prompt contains untrusted metadata
const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):"); const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):");
if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return; if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return;
// Always save channelId and accountId mappings for use in later hooks
if (derived.channelId) {
sessionChannelId.set(key, derived.channelId);
}
const resolvedAccountId = resolveAccountId(api, ctx.agentId || "");
if (resolvedAccountId) {
sessionAccountId.set(key, resolvedAccountId);
}
let rec = sessionDecision.get(key); let rec = sessionDecision.get(key);
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) { if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
if (rec) sessionDecision.delete(key); if (rec) sessionDecision.delete(key);
@@ -638,13 +740,6 @@ export default {
} }
// Allowed to speak - record this session as allowed // Allowed to speak - record this session as allowed
sessionAllowed.set(key, true); sessionAllowed.set(key, true);
// Also save channelId and accountId for this session
if (derived.channelId) {
sessionChannelId.set(key, derived.channelId);
}
if (accountId) {
sessionAccountId.set(key, accountId);
}
} }
} }
@@ -822,36 +917,39 @@ export default {
`whispergate: DEBUG before_message_write eventKeys=${JSON.stringify(Object.keys(event ?? {}))} ctxKeys=${JSON.stringify(Object.keys(ctx ?? {}))}`, `whispergate: DEBUG before_message_write eventKeys=${JSON.stringify(Object.keys(event ?? {}))} ctxKeys=${JSON.stringify(Object.keys(ctx ?? {}))}`,
); );
// Try multiple sources for channelId and accountId // before_message_write ctx only has { agentId, sessionKey }.
const deliveryContext = (ctx as Record<string, unknown>)?.deliveryContext as Record<string, unknown> | undefined; // Use session mappings populated during before_model_resolve for channelId/accountId.
// Content comes from event.message (AgentMessage).
let key = ctx.sessionKey; let key = ctx.sessionKey;
let channelId = ctx.channelId as string | undefined; let channelId: string | undefined;
let accountId = ctx.accountId as string | undefined; let accountId: string | undefined;
let content = (event.content as string) || "";
// Fallback: get channelId from deliveryContext.to // Get from session mapping (set in before_model_resolve)
if (!channelId && deliveryContext?.to) { if (key) {
const toStr = String(deliveryContext.to);
channelId = toStr.startsWith("channel:") ? toStr.replace("channel:", "") : toStr;
}
// Fallback: get accountId from deliveryContext.accountId
if (!accountId && deliveryContext?.accountId) {
accountId = String(deliveryContext.accountId);
}
// Fallback: get from session mapping
if (!channelId && key) {
channelId = sessionChannelId.get(key); channelId = sessionChannelId.get(key);
}
if (!accountId && key) {
accountId = sessionAccountId.get(key); accountId = sessionAccountId.get(key);
} }
// Fallback: get content from event.message.content // Extract content from event.message (AgentMessage)
if (!content && (event as Record<string, unknown>).message) { let content = "";
const msg = (event as Record<string, unknown>).message as Record<string, unknown>; const msg = (event as Record<string, unknown>).message as Record<string, unknown> | undefined;
content = String(msg.content ?? ""); if (msg) {
// AgentMessage may have content as string or nested
if (typeof msg.content === "string") {
content = msg.content;
} else if (Array.isArray(msg.content)) {
// content might be an array of parts (Anthropic format)
for (const part of msg.content) {
if (typeof part === "string") content += part;
else if (part && typeof part === "object" && typeof (part as Record<string, unknown>).text === "string") {
content += (part as Record<string, unknown>).text;
}
}
}
}
// Fallback to event.content
if (!content) {
content = ((event as Record<string, unknown>).content as string) || "";
} }
// Always log for debugging - show all available info // Always log for debugging - show all available info
@@ -937,17 +1035,35 @@ export default {
api.on("message_sent", async (event, ctx) => { api.on("message_sent", async (event, ctx) => {
try { try {
const key = ctx.sessionKey; const key = ctx.sessionKey;
// Try ctx.channelId first, fallback to sessionChannelId mapping const c = (ctx || {}) as Record<string, unknown>;
let channelId = ctx.channelId as string | undefined; const e = (event || {}) as Record<string, unknown>;
// Always log raw context first for debugging
api.logger.info(
`whispergate: DEBUG message_sent RAW ctxKeys=${JSON.stringify(Object.keys(c))} eventKeys=${JSON.stringify(Object.keys(e))} ` +
`ctx.channelId=${String(c.channelId ?? "undefined")} ctx.conversationId=${String(c.conversationId ?? "undefined")} ` +
`ctx.accountId=${String(c.accountId ?? "undefined")} event.to=${String(e.to ?? "undefined")} ` +
`session=${key ?? "undefined"}`,
);
// ctx.channelId is the platform name (e.g. "discord"), NOT the Discord channel snowflake.
// Extract real Discord channel ID from conversationId or event.to.
let channelId = extractDiscordChannelId(c, e);
// Fallback: sessionKey mapping
if (!channelId && key) { if (!channelId && key) {
channelId = sessionChannelId.get(key); channelId = sessionChannelId.get(key);
} }
const accountId = ctx.accountId as string | undefined; // Fallback: parse from sessionKey
if (!channelId && key) {
const skMatch = key.match(/:channel:(\d+)$/);
if (skMatch) channelId = skMatch[1];
}
const accountId = (ctx.accountId as string | undefined) || (key ? sessionAccountId.get(key) : undefined);
const content = (event.content as string) || ""; const content = (event.content as string) || "";
// Debug log // Debug log
api.logger.info( api.logger.info(
`whispergate: DEBUG message_sent session=${key ?? "undefined"} channelId=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} content=${content.slice(0, 100)}`, `whispergate: DEBUG message_sent RESOLVED session=${key ?? "undefined"} channelId=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} content=${content.slice(0, 100)}`,
); );
if (!channelId || !accountId) return; if (!channelId || !accountId) return;