feat(hooks): add message:transcribed and message:preprocessed internal hooks

Adds two new internal hook events that fire after media/link processing:

- message:transcribed: fires when audio has been transcribed, providing
  the transcript text alongside the original body and media metadata.
  Useful for logging, analytics, or routing based on spoken content.

- message:preprocessed: fires for every message after all media + link
  understanding completes. Gives hooks access to the fully enriched body
  (transcripts, image descriptions, link summaries) before the agent sees it.

Both hooks are added in get-reply.ts, after applyMediaUnderstanding and
applyLinkUnderstanding. message:received and message:sent are already
in upstream (f07bb8e8) and are not duplicated here.

Typed contexts (MessageTranscribedHookContext, MessagePreprocessedHookContext)
and type guards (isMessageTranscribedEvent, isMessagePreprocessedEvent) added
to internal-hooks.ts alongside the existing received/sent types.

Test coverage in src/hooks/message-hooks.test.ts.
This commit is contained in:
Eric Lytle
2026-02-20 09:05:57 +00:00
committed by Peter Steinberger
parent 44183c6eb1
commit e0b8b80067
5 changed files with 564 additions and 2 deletions

View File

@@ -9,6 +9,8 @@ import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../../agents/workspace.js";
import { resolveChannelModelOverride } from "../../channels/model-overrides.js";
import { type OpenClawConfig, loadConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
import { applyLinkUnderstanding } from "../../link-understanding/apply.js";
import { applyMediaUnderstanding } from "../../media-understanding/apply.js";
import { defaultRuntime } from "../../runtime.js";
@@ -136,6 +138,73 @@ export async function getReplyFromConfig(
});
}
const channelId = (
finalized.OriginatingChannel ??
finalized.Surface ??
finalized.Provider ??
""
).toLowerCase();
// Trigger message:transcribed hook after media understanding completes
// Only fire if transcription actually occurred (skip in fast test mode or non-audio)
if (finalized.Transcript) {
void triggerInternalHook(
createInternalHookEvent("message", "transcribed", finalized.SessionKey ?? "", {
from: finalized.From,
to: finalized.To,
body: finalized.Body,
bodyForAgent: finalized.BodyForAgent,
transcript: finalized.Transcript,
timestamp: finalized.Timestamp,
channelId,
conversationId: finalized.OriginatingTo ?? finalized.To ?? finalized.From ?? undefined,
messageId: finalized.MessageSid,
senderId: finalized.SenderId,
senderName: finalized.SenderName,
senderUsername: finalized.SenderUsername,
provider: finalized.Provider,
surface: finalized.Surface,
mediaPath: finalized.MediaPath,
mediaType: finalized.MediaType,
cfg,
}),
).catch((err) => {
logVerbose(`get-reply: message:transcribed internal hook failed: ${String(err)}`);
});
}
// Trigger message:preprocessed hook after all media + link understanding.
// Fires for every message, giving hooks access to the fully enriched body
// (transcripts, image descriptions, link summaries) before the agent sees it.
void triggerInternalHook(
createInternalHookEvent("message", "preprocessed", finalized.SessionKey ?? "", {
from: finalized.From,
to: finalized.To,
body: finalized.Body,
bodyForAgent: finalized.BodyForAgent,
transcript: finalized.Transcript,
timestamp: finalized.Timestamp,
channelId,
conversationId: finalized.OriginatingTo ?? finalized.To ?? finalized.From ?? undefined,
messageId: finalized.MessageSid,
senderId: finalized.SenderId,
senderName: finalized.SenderName,
senderUsername: finalized.SenderUsername,
provider: finalized.Provider,
surface: finalized.Surface,
mediaPath: finalized.MediaPath,
mediaType: finalized.MediaType,
isGroup: Boolean(finalized.GroupSubject || finalized.GroupChannel),
groupId:
finalized.From?.includes(":group:") || finalized.From?.includes(":channel:")
? finalized.From
: undefined,
cfg,
}),
).catch((err) => {
logVerbose(`get-reply: message:preprocessed internal hook failed: ${String(err)}`);
});
const commandAuthorized = finalized.CommandAuthorized;
resolveCommandAuthorization({
ctx: finalized,