Merge 5dc2c6e0f50216980cde6bf15ee1e527ffc84667 into 5e417b44e1540f528d2ae63e3e20229a902d1db2

This commit is contained in:
M1a0 2026-03-21 02:37:09 +00:00 committed by GitHub
commit e94e15401d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 106 additions and 0 deletions

View File

@ -213,6 +213,31 @@ export async function dispatchReplyFromConfig(params: {
wasMentioned: typeof ctx.WasMentioned === "boolean" ? ctx.WasMentioned : undefined,
});
// Run before_dispatch plugin hooks (blocking — can abort dispatch before LLM).
// This lets security plugins (e.g. authentication gates) prevent LLM invocation
// entirely for unauthenticated sessions, saving tokens and enforcing access.
if (hookRunner?.hasHooks("before_dispatch") && sessionKey) {
const beforeDispatchResult = await hookRunner.runBeforeDispatch(
{
sessionKey,
channelId: channel,
senderId: ctx.SenderId,
conversationId: hookContext.conversationId,
isGroup,
content: hookContext.content,
messageId: messageIdForHook,
},
toPluginMessageContext(hookContext),
);
if (beforeDispatchResult?.block) {
const queuedFinal = beforeDispatchResult.replyText
? dispatcher.sendFinalReply({ text: beforeDispatchResult.replyText })
: false;
recordProcessed("skipped", { reason: "before_dispatch_blocked" });
return { queuedFinal, counts: dispatcher.getQueuedCounts() };
}
}
// Check if we should route replies to originating channel instead of dispatcher.
// Only route when the originating channel is DIFFERENT from the current surface.
// This handles cross-provider routing (e.g., message from Telegram being processed

View File

@ -53,6 +53,8 @@ import type {
PluginHookToolResultPersistResult,
PluginHookBeforeMessageWriteEvent,
PluginHookBeforeMessageWriteResult,
PluginHookBeforeDispatchEvent,
PluginHookBeforeDispatchResult,
} from "./types.js";
// Re-export types for consumers
@ -87,6 +89,8 @@ export type {
PluginHookToolResultPersistResult,
PluginHookBeforeMessageWriteEvent,
PluginHookBeforeMessageWriteResult,
PluginHookBeforeDispatchEvent,
PluginHookBeforeDispatchResult,
PluginHookSessionContext,
PluginHookSessionStartEvent,
PluginHookSessionEndEvent,
@ -622,6 +626,55 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
return runVoidHook("message_sent", event, ctx);
}
// =========================================================================
// Dispatch Hooks
// =========================================================================
/**
* Run before_dispatch hook.
* Allows plugins to block the entire message dispatch before LLM invocation
* and optionally send a reply text directly to the user.
* Runs sequentially first handler that returns { block: true } wins.
*
* **Error semantics:** Like all hooks, errors are caught and logged when
* `catchErrors` is true (the default). This means a throwing handler
* results in permit-by-default (fail-open). Security-critical plugins
* should handle errors internally to implement fail-closed behavior.
*/
async function runBeforeDispatch(
event: PluginHookBeforeDispatchEvent,
ctx: PluginHookMessageContext,
): Promise<PluginHookBeforeDispatchResult | undefined> {
const hooks = getHooksForName(registry, "before_dispatch");
if (hooks.length === 0) {
return undefined;
}
logger?.debug?.(`[hooks] running before_dispatch (${hooks.length} handlers, sequential)`);
for (const hook of hooks) {
try {
const result = await (
hook.handler as (
event: PluginHookBeforeDispatchEvent,
ctx: PluginHookMessageContext,
) =>
| Promise<PluginHookBeforeDispatchResult | void>
| PluginHookBeforeDispatchResult
| void
)(event, ctx);
if (result?.block) {
return result;
}
} catch (err) {
handleHookError({ hookName: "before_dispatch", pluginId: hook.pluginId, error: err });
}
}
return undefined;
}
// =========================================================================
// Tool Hooks
// =========================================================================
@ -936,6 +989,8 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
runMessageReceived,
runMessageSending,
runMessageSent,
// Dispatch hooks
runBeforeDispatch,
// Tool hooks
runBeforeToolCall,
runAfterToolCall,

View File

@ -1391,6 +1391,7 @@ export type PluginHookName =
| "subagent_delivery_target"
| "subagent_spawned"
| "subagent_ended"
| "before_dispatch"
| "gateway_start"
| "gateway_stop";
@ -1418,6 +1419,7 @@ export const PLUGIN_HOOK_NAMES = [
"subagent_delivery_target",
"subagent_spawned",
"subagent_ended",
"before_dispatch",
"gateway_start",
"gateway_stop",
] as const satisfies readonly PluginHookName[];
@ -1750,6 +1752,26 @@ export type PluginHookBeforeMessageWriteResult = {
message?: AgentMessage; // Optional: modified message to write instead
};
// before_dispatch hook — fired in dispatchReplyFromConfig before LLM invocation.
// Allows plugins to block the entire dispatch (preventing LLM processing) and
// optionally send a direct reply to the user.
export type PluginHookBeforeDispatchEvent = {
sessionKey: string;
channelId: string;
senderId?: string;
conversationId?: string;
isGroup: boolean;
content: string;
messageId?: string;
};
export type PluginHookBeforeDispatchResult = {
/** If true, the dispatch is aborted — no LLM invocation occurs. */
block?: boolean;
/** If block is true, this text is sent as a direct reply to the user. */
replyText?: string;
};
// Session context
export type PluginHookSessionContext = {
agentId?: string;
@ -1959,6 +1981,10 @@ export type PluginHookHandlerMap = {
event: PluginHookSubagentEndedEvent,
ctx: PluginHookSubagentContext,
) => Promise<void> | void;
before_dispatch: (
event: PluginHookBeforeDispatchEvent,
ctx: PluginHookMessageContext,
) => Promise<PluginHookBeforeDispatchResult | void> | PluginHookBeforeDispatchResult | void;
gateway_start: (
event: PluginHookGatewayStartEvent,
ctx: PluginHookGatewayContext,