refactor(plugin): extract channel/decision parsing modules and unify channelId resolution
This commit is contained in:
73
plugin/channel-resolver.ts
Normal file
73
plugin/channel-resolver.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export function extractDiscordChannelId(ctx: Record<string, unknown>, event?: Record<string, unknown>): string | undefined {
|
||||
const candidates: unknown[] = [
|
||||
ctx.conversationId,
|
||||
ctx.OriginatingTo,
|
||||
event?.to,
|
||||
(event?.metadata as Record<string, unknown>)?.to,
|
||||
];
|
||||
|
||||
for (const c of candidates) {
|
||||
if (typeof c !== "string" || !c.trim()) continue;
|
||||
const s = c.trim();
|
||||
|
||||
if (s.startsWith("channel:")) {
|
||||
const id = s.slice("channel:".length);
|
||||
if (/^\d+$/.test(id)) return id;
|
||||
}
|
||||
|
||||
if (s.startsWith("discord:channel:")) {
|
||||
const id = s.slice("discord:channel:".length);
|
||||
if (/^\d+$/.test(id)) return id;
|
||||
}
|
||||
|
||||
if (/^\d{15,}$/.test(s)) return s;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractDiscordChannelIdFromSessionKey(sessionKey?: string): string | undefined {
|
||||
if (!sessionKey) return undefined;
|
||||
|
||||
const canonical = sessionKey.match(/discord:(?:channel|group):(\d+)/);
|
||||
if (canonical?.[1]) return canonical[1];
|
||||
|
||||
const suffix = sessionKey.match(/:channel:(\d+)$/);
|
||||
if (suffix?.[1]) return suffix[1];
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractUntrustedConversationInfo(text: string): Record<string, unknown> | undefined {
|
||||
const marker = "Conversation info (untrusted metadata):";
|
||||
const idx = text.indexOf(marker);
|
||||
if (idx < 0) return undefined;
|
||||
const tail = text.slice(idx + marker.length);
|
||||
const m = tail.match(/```json\s*([\s\S]*?)\s*```/i);
|
||||
if (!m) return undefined;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(m[1]);
|
||||
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractDiscordChannelIdFromConversationMetadata(conv: Record<string, unknown>): string | undefined {
|
||||
if (typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:")) {
|
||||
const id = conv.chat_id.slice("channel:".length);
|
||||
if (/^\d+$/.test(id)) return id;
|
||||
}
|
||||
|
||||
if (typeof conv.conversation_label === "string") {
|
||||
const labelMatch = conv.conversation_label.match(/channel id:(\d+)/);
|
||||
if (labelMatch?.[1]) return labelMatch[1];
|
||||
}
|
||||
|
||||
if (typeof conv.channel_id === "string" && /^\d+$/.test(conv.channel_id)) {
|
||||
return conv.channel_id;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
37
plugin/decision-input.ts
Normal file
37
plugin/decision-input.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
extractDiscordChannelId,
|
||||
extractDiscordChannelIdFromConversationMetadata,
|
||||
extractDiscordChannelIdFromSessionKey,
|
||||
extractUntrustedConversationInfo,
|
||||
} from "./channel-resolver.js";
|
||||
|
||||
export type DerivedDecisionInput = {
|
||||
channel: string;
|
||||
channelId?: string;
|
||||
senderId?: string;
|
||||
content: string;
|
||||
conv: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function deriveDecisionInputFromPrompt(params: {
|
||||
prompt: string;
|
||||
messageProvider?: string;
|
||||
sessionKey?: string;
|
||||
ctx?: Record<string, unknown>;
|
||||
event?: Record<string, unknown>;
|
||||
}): DerivedDecisionInput {
|
||||
const { prompt, messageProvider, sessionKey, ctx, event } = params;
|
||||
const conv = extractUntrustedConversationInfo(prompt) || {};
|
||||
const channel = (messageProvider || "").toLowerCase();
|
||||
|
||||
let channelId = extractDiscordChannelId(ctx || {}, event);
|
||||
if (!channelId) channelId = extractDiscordChannelIdFromSessionKey(sessionKey);
|
||||
if (!channelId) channelId = extractDiscordChannelIdFromConversationMetadata(conv);
|
||||
|
||||
const senderId =
|
||||
(typeof conv.sender_id === "string" && conv.sender_id) ||
|
||||
(typeof conv.sender === "string" && conv.sender) ||
|
||||
undefined;
|
||||
|
||||
return { channel, channelId, senderId, content: prompt, conv };
|
||||
}
|
||||
144
plugin/index.ts
144
plugin/index.ts
@@ -5,6 +5,8 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type DirigentConfig } from "./rules.js";
|
||||
import { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo, setMentionOverride, hasMentionOverride, setWaitingForHuman, isWaitingForHuman } from "./turn-manager.js";
|
||||
import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js";
|
||||
import { extractDiscordChannelId, extractDiscordChannelIdFromSessionKey } from "./channel-resolver.js";
|
||||
import { deriveDecisionInputFromPrompt } from "./decision-input.js";
|
||||
|
||||
// ── No-Reply API child process lifecycle ──────────────────────────────
|
||||
let noReplyProcess: ChildProcess | null = null;
|
||||
@@ -97,116 +99,6 @@ const policyState: PolicyState = {
|
||||
channelPolicies: {},
|
||||
};
|
||||
|
||||
function normalizeChannel(ctx: Record<string, unknown>): string {
|
||||
const candidates = [ctx.commandSource, ctx.messageProvider, ctx.channelId, ctx.channel];
|
||||
for (const c of candidates) {
|
||||
if (typeof c === "string" && c.trim()) return c.trim().toLowerCase();
|
||||
}
|
||||
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) {
|
||||
if (typeof v === "string" && v.trim()) return v.trim();
|
||||
}
|
||||
|
||||
const meta = (event.metadata || ctx.metadata) as Record<string, unknown> | undefined;
|
||||
if (!meta) return undefined;
|
||||
const metaCandidates = [meta.senderId, meta.sender_id, meta.userId, meta.user_id];
|
||||
for (const v of metaCandidates) {
|
||||
if (typeof v === "string" && v.trim()) return v.trim();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractUntrustedConversationInfo(text: string): Record<string, unknown> | undefined {
|
||||
const marker = "Conversation info (untrusted metadata):";
|
||||
const idx = text.indexOf(marker);
|
||||
if (idx < 0) return undefined;
|
||||
const tail = text.slice(idx + marker.length);
|
||||
const m = tail.match(/```json\s*([\s\S]*?)\s*```/i);
|
||||
if (!m) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(m[1]);
|
||||
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveDecisionInputFromPrompt(
|
||||
prompt: string,
|
||||
messageProvider?: string,
|
||||
channelIdFromCtx?: string,
|
||||
): {
|
||||
channel: string;
|
||||
channelId?: string;
|
||||
senderId?: string;
|
||||
content: string;
|
||||
conv: Record<string, unknown>;
|
||||
} {
|
||||
const conv = extractUntrustedConversationInfo(prompt) || {};
|
||||
const channel = (messageProvider || "").toLowerCase();
|
||||
|
||||
// Priority: ctx.channelId > conv.chat_id > conversation_label > conv.channel_id
|
||||
let channelId = channelIdFromCtx;
|
||||
if (!channelId) {
|
||||
// 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 =
|
||||
(typeof conv.sender_id === "string" && conv.sender_id) ||
|
||||
(typeof conv.sender === "string" && conv.sender) ||
|
||||
undefined;
|
||||
|
||||
return { channel, channelId, senderId, content: prompt, conv };
|
||||
}
|
||||
|
||||
function pruneDecisionMap(now = Date.now()) {
|
||||
for (const [k, v] of sessionDecision.entries()) {
|
||||
if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k);
|
||||
@@ -916,12 +808,13 @@ 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];
|
||||
}
|
||||
const derived = deriveDecisionInputFromPrompt({
|
||||
prompt,
|
||||
messageProvider: ctx.messageProvider,
|
||||
sessionKey: key,
|
||||
ctx: ctx as Record<string, unknown>,
|
||||
event: event as Record<string, unknown>,
|
||||
});
|
||||
// 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;
|
||||
@@ -1034,7 +927,13 @@ export default {
|
||||
if (rec) sessionDecision.delete(key);
|
||||
|
||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||
const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId);
|
||||
const derived = deriveDecisionInputFromPrompt({
|
||||
prompt,
|
||||
messageProvider: ctx.messageProvider,
|
||||
sessionKey: key,
|
||||
ctx: ctx as Record<string, unknown>,
|
||||
event: event as Record<string, unknown>,
|
||||
});
|
||||
|
||||
const decision = evaluateDecision({
|
||||
config: live,
|
||||
@@ -1080,7 +979,13 @@ export default {
|
||||
|
||||
// Resolve end symbols from config/policy for dynamic instruction
|
||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||
const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId);
|
||||
const derived = deriveDecisionInputFromPrompt({
|
||||
prompt,
|
||||
messageProvider: ctx.messageProvider,
|
||||
sessionKey: key,
|
||||
ctx: ctx as Record<string, unknown>,
|
||||
event: event as Record<string, unknown>,
|
||||
});
|
||||
const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies);
|
||||
const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true";
|
||||
const schedulingId = live.schedulingIdentifier || "➡️";
|
||||
@@ -1319,8 +1224,7 @@ export default {
|
||||
channelId = sessionChannelId.get(key);
|
||||
}
|
||||
if (!channelId && key) {
|
||||
const skMatch = key.match(/:channel:(\d+)$/);
|
||||
if (skMatch) channelId = skMatch[1];
|
||||
channelId = extractDiscordChannelIdFromSessionKey(key);
|
||||
}
|
||||
const accountId = (ctx.accountId as string | undefined) || (key ? sessionAccountId.get(key) : undefined);
|
||||
const content = (event.content as string) || "";
|
||||
|
||||
Reference in New Issue
Block a user