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:
208
plugin/index.ts
208
plugin/index.ts
@@ -52,6 +52,37 @@ function normalizeChannel(ctx: Record<string, unknown>): string {
|
||||
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 {
|
||||
const direct = [ctx.senderId, ctx.from, event.from];
|
||||
for (const v of direct) {
|
||||
@@ -97,14 +128,22 @@ function deriveDecisionInputFromPrompt(
|
||||
const conv = extractUntrustedConversationInfo(prompt) || {};
|
||||
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;
|
||||
if (!channelId) {
|
||||
channelId =
|
||||
(typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:")
|
||||
? conv.chat_id.slice("channel:".length)
|
||||
: typeof conv.channel_id === "string" && conv.channel_id) ||
|
||||
undefined;
|
||||
// Try chat_id field (format "channel:123456")
|
||||
if (typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:")) {
|
||||
channelId = conv.chat_id.slice("channel:".length);
|
||||
}
|
||||
// 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 =
|
||||
@@ -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[] {
|
||||
const root = (api.config as Record<string, unknown>) || {};
|
||||
@@ -209,12 +248,44 @@ function getAllBotAccountIds(api: OpenClawPluginApi): string[] {
|
||||
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.
|
||||
* 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 {
|
||||
const botAccounts = getAllBotAccountIds(api);
|
||||
const botAccounts = getChannelBotAccountIds(api, channelId);
|
||||
if (botAccounts.length > 0) {
|
||||
initTurnOrder(channelId, botAccounts);
|
||||
}
|
||||
@@ -538,7 +609,9 @@ export default {
|
||||
try {
|
||||
const c = (ctx || {}) 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;
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
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
|
||||
if (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
|
||||
const moderatorUserId = getModeratorUserId(livePre);
|
||||
@@ -560,6 +637,17 @@ export default {
|
||||
const humanList = livePre.humanList || livePre.bypassUserIds || [];
|
||||
const isHuman = humanList.includes(from);
|
||||
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);
|
||||
if (shouldDebugLog(livePre, preChannelId)) {
|
||||
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);
|
||||
// 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
|
||||
const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):");
|
||||
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);
|
||||
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
|
||||
if (rec) sessionDecision.delete(key);
|
||||
@@ -638,13 +740,6 @@ export default {
|
||||
}
|
||||
// Allowed to speak - record this session as allowed
|
||||
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 ?? {}))}`,
|
||||
);
|
||||
|
||||
// Try multiple sources for channelId and accountId
|
||||
const deliveryContext = (ctx as Record<string, unknown>)?.deliveryContext as Record<string, unknown> | undefined;
|
||||
// before_message_write ctx only has { agentId, sessionKey }.
|
||||
// Use session mappings populated during before_model_resolve for channelId/accountId.
|
||||
// Content comes from event.message (AgentMessage).
|
||||
let key = ctx.sessionKey;
|
||||
let channelId = ctx.channelId as string | undefined;
|
||||
let accountId = ctx.accountId as string | undefined;
|
||||
let content = (event.content as string) || "";
|
||||
let channelId: string | undefined;
|
||||
let accountId: string | undefined;
|
||||
|
||||
// Fallback: get channelId from deliveryContext.to
|
||||
if (!channelId && deliveryContext?.to) {
|
||||
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) {
|
||||
// Get from session mapping (set in before_model_resolve)
|
||||
if (key) {
|
||||
channelId = sessionChannelId.get(key);
|
||||
}
|
||||
if (!accountId && key) {
|
||||
accountId = sessionAccountId.get(key);
|
||||
}
|
||||
|
||||
// Fallback: get content from event.message.content
|
||||
if (!content && (event as Record<string, unknown>).message) {
|
||||
const msg = (event as Record<string, unknown>).message as Record<string, unknown>;
|
||||
content = String(msg.content ?? "");
|
||||
// Extract content from event.message (AgentMessage)
|
||||
let content = "";
|
||||
const msg = (event as Record<string, unknown>).message as Record<string, unknown> | undefined;
|
||||
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
|
||||
@@ -937,17 +1035,35 @@ export default {
|
||||
api.on("message_sent", async (event, ctx) => {
|
||||
try {
|
||||
const key = ctx.sessionKey;
|
||||
// Try ctx.channelId first, fallback to sessionChannelId mapping
|
||||
let channelId = ctx.channelId as string | undefined;
|
||||
const c = (ctx || {}) as Record<string, unknown>;
|
||||
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) {
|
||||
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) || "";
|
||||
|
||||
// Debug log
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user