diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 9df6ef2bc63..1e833c412d2 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -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 diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index e8e1e2aa163..bb220f957c2 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -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 { + 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 + )(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, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 343a338c4f8..1ba7cd7e3a7 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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; + before_dispatch: ( + event: PluginHookBeforeDispatchEvent, + ctx: PluginHookMessageContext, + ) => Promise | PluginHookBeforeDispatchResult | void; gateway_start: ( event: PluginHookGatewayStartEvent, ctx: PluginHookGatewayContext,