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:
1105
dist/whispergate/index.ts
vendored
Normal file
1105
dist/whispergate/index.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
208
plugin/index.ts
208
plugin/index.ts
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user