fix(plugins): derive hook channel ids from targets

This commit is contained in:
Peter Steinberger
2026-05-02 04:05:41 +01:00
parent ab25a26c24
commit 82c11deaa2
10 changed files with 161 additions and 19 deletions

View File

@@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai
- Discord: add configurable gateway READY timeouts for startup and runtime reconnects, so staggered multi-account setups can avoid false restart loops. Fixes #72273. Thanks @sergionsantos.
- Discord: preserve native slash-command description localizations through command reconcile, so localized Discord descriptions no longer get overwritten by English defaults. Fixes #56580. Thanks @mhseo93.
- Discord: add configured outbound mention aliases so known `@Name` references can be rewritten to real Discord user mentions instead of relying only on the transient directory cache. Fixes #67587. Thanks @McoreD.
- Plugins/hooks: derive hook `ctx.channelId` from the conversation target instead of the provider name, so Discord and other channel plugins can keep per-channel state isolated. Fixes #59881. Thanks @bradfreels.
- Gateway/config: log config health-state write failures instead of silently hiding config observe-recovery write errors. Thanks @sallyom.
- Diagnostics: reset stuck-session timers on reply, tool, status, block, and ACP progress events, and back off repeated `session.stuck` diagnostics while a session remains unchanged. Supersedes #72010. Thanks @rubencu.

View File

@@ -212,6 +212,11 @@ Cron-driven runs also expose `ctx.jobId` (the originating cron job id) so
plugin hooks can scope metrics, side effects, or state to a specific scheduled
job.
For channel-originated runs, `ctx.messageProvider` is the provider surface such
as `discord` or `telegram`, while `ctx.channelId` is the conversation target
identifier when OpenClaw can derive one from the session key or delivery
metadata.
`agent_end` is an observation hook and runs fire-and-forget after the turn. The
hook runner applies a 30 second timeout so a wedged plugin or embedding
endpoint cannot leave the hook promise pending forever. A timeout is logged and

View File

@@ -1,6 +1,7 @@
import type { ReplyPayload } from "../auto-reply/reply-payload.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { formatErrorMessage } from "../infra/errors.js";
import { buildAgentHookContextChannelFields } from "../plugins/hook-agent-context.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { loadCliSessionHistoryMessages } from "./cli-runner/session-history.js";
import type { PreparedCliRunContext, RunCliAgentParams } from "./cli-runner/types.js";
@@ -76,9 +77,8 @@ export async function runCliAgent(params: RunCliAgentParams): Promise<EmbeddedPi
sessionKey: params.sessionKey,
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
messageProvider: params.messageProvider,
trigger: params.trigger,
channelId: params.messageChannel ?? params.messageProvider,
...buildAgentHookContextChannelFields(params),
} as const;
const hookResult = await hookRunner.runBeforeAgentReply(
{ cleanedBody: params.prompt },
@@ -154,9 +154,8 @@ export async function runPreparedCliAgent(
sessionKey: params.sessionKey,
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
messageProvider: params.messageProvider,
trigger: params.trigger,
channelId: params.messageChannel ?? params.messageProvider,
...buildAgentHookContextChannelFields(params),
} as const;
const buildAgentEndMessages = (lastAssistant?: unknown): unknown[] => [

View File

@@ -8,6 +8,7 @@ import type {
CliBackendAuthEpochMode,
CliBackendPreparedExecution,
} from "../../plugins/cli-backend.types.js";
import { buildAgentHookContextChannelFields } from "../../plugins/hook-agent-context.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
import { resolveOpenClawAgentDir } from "../agent-paths.js";
@@ -349,9 +350,8 @@ export async function prepareCliRunContext(
workspaceDir,
modelProviderId: params.provider,
modelId,
messageProvider: params.messageProvider,
trigger: params.trigger,
channelId: params.messageChannel ?? params.messageProvider,
...buildAgentHookContextChannelFields(params),
},
hookRunner,
});

View File

@@ -9,6 +9,7 @@ import { emitAgentPlanEvent } from "../../infra/agent-events.js";
import { sleepWithAbort } from "../../infra/backoff.js";
import { freezeDiagnosticTraceContext } from "../../infra/diagnostic-trace-context.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { buildAgentHookContextChannelFields } from "../../plugins/hook-agent-context.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { resolveProviderAuthProfileId } from "../../plugins/provider-runtime.js";
import { enqueueCommandInLane } from "../../process/command-queue.js";
@@ -423,9 +424,8 @@ export async function runEmbeddedPiAgent(
workspaceDir: resolvedWorkspace,
modelProviderId: provider,
modelId,
messageProvider: params.messageProvider ?? undefined,
trigger: params.trigger,
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
...buildAgentHookContextChannelFields(params),
};
if (params.trigger === "cron" && hookRunner?.hasHooks("before_agent_reply")) {
const hookResult = await hookRunner.runBeforeAgentReply(

View File

@@ -23,6 +23,7 @@ import { resolveHeartbeatSummaryForAgent } from "../../../infra/heartbeat-summar
import { getMachineDisplayName } from "../../../infra/machine-name.js";
import { MAX_IMAGE_BYTES } from "../../../media/constants.js";
import { listRegisteredPluginAgentPromptGuidance } from "../../../plugins/command-registry-state.js";
import { buildAgentHookContextChannelFields } from "../../../plugins/hook-agent-context.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import {
extractModelCompat,
@@ -2442,9 +2443,8 @@ export async function runEmbeddedAttempt(
workspaceDir: params.workspaceDir,
modelProviderId: params.model.provider,
modelId: params.model.id,
messageProvider: params.messageProvider ?? undefined,
trigger: params.trigger,
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
...buildAgentHookContextChannelFields(params),
};
const promptBuildMessages =
pruneProcessedHistoryImages(activeSession.messages) ?? activeSession.messages;
@@ -2761,9 +2761,8 @@ export async function runEmbeddedAttempt(
sessionKey: params.sessionKey,
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
messageProvider: params.messageProvider ?? undefined,
trigger: params.trigger,
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
...buildAgentHookContextChannelFields(params),
},
)
.catch((err) => {
@@ -3228,9 +3227,8 @@ export async function runEmbeddedAttempt(
sessionKey: params.sessionKey,
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
messageProvider: params.messageProvider ?? undefined,
trigger: params.trigger,
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
...buildAgentHookContextChannelFields(params),
},
)
.catch((err) => {
@@ -3336,9 +3334,8 @@ export async function runEmbeddedAttempt(
sessionKey: params.sessionKey,
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
messageProvider: params.messageProvider ?? undefined,
trigger: params.trigger,
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
...buildAgentHookContextChannelFields(params),
},
)
.catch((err) => {

View File

@@ -93,7 +93,7 @@ describe("getReplyFromConfig before_agent_reply wiring", () => {
workspaceDir: "/tmp/workspace",
messageProvider: "telegram",
trigger: "user",
channelId: "telegram",
channelId: "-100123",
}),
);
expect(mocks.handleInlineActions.mock.invocationCallOrder[0]).toBeLessThan(

View File

@@ -12,6 +12,7 @@ import { resolveChannelModelOverride } from "../../channels/model-overrides.js";
import { type OpenClawConfig, getRuntimeConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { buildAgentHookContextChannelFields } from "../../plugins/hook-agent-context.js";
import { defaultRuntime } from "../../runtime.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { normalizeStringEntries } from "../../shared/string-normalization.js";
@@ -604,9 +605,13 @@ export async function getReplyFromConfig(
sessionKey: agentSessionKey,
sessionId,
workspaceDir,
messageProvider: hookMessageProvider,
trigger: opts?.isHeartbeat ? "heartbeat" : "user",
channelId: hookMessageProvider,
...buildAgentHookContextChannelFields({
sessionKey: agentSessionKey,
messageProvider: hookMessageProvider,
currentChannelId: sessionCtx.OriginatingTo ?? ctx.OriginatingTo ?? ctx.To,
messageTo: sessionCtx.OriginatingTo ?? ctx.OriginatingTo ?? ctx.To,
}),
},
);
if (hookResult?.handled) {

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
buildAgentHookContextChannelFields,
resolveAgentHookChannelId,
} from "./hook-agent-context.js";
describe("resolveAgentHookChannelId", () => {
it("derives the conversation id from channel session keys", () => {
expect(
resolveAgentHookChannelId({
sessionKey: "agent:main:discord:channel:1472750640760623226",
messageChannel: "discord",
messageProvider: "discord",
currentChannelId: "channel:1472750640760623226",
}),
).toBe("1472750640760623226");
});
it("uses target metadata when the session key is not a channel conversation", () => {
expect(
resolveAgentHookChannelId({
sessionKey: "agent:main:main",
messageProvider: "telegram",
currentChannelId: "telegram:-1003841603622",
}),
).toBe("-1003841603622");
});
it("uses prefixed message targets before falling back to the provider", () => {
expect(
resolveAgentHookChannelId({
messageChannel: "channel:1472750640760623226",
messageProvider: "discord",
}),
).toBe("1472750640760623226");
});
it("falls back to legacy channel/provider values when no conversation id is available", () => {
expect(
resolveAgentHookChannelId({
messageChannel: "discord",
messageProvider: "discord",
}),
).toBe("discord");
});
});
describe("buildAgentHookContextChannelFields", () => {
it("keeps provider and conversation id separate", () => {
expect(
buildAgentHookContextChannelFields({
sessionKey: "agent:main:discord:channel:c1",
messageChannel: "discord",
messageProvider: "discord",
}),
).toEqual({
messageProvider: "discord",
channelId: "c1",
});
});
});

View File

@@ -0,0 +1,74 @@
import { parseRawSessionConversationRef } from "../sessions/session-key-utils.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import type { PluginHookAgentContext } from "./hook-types.js";
const TARGET_PREFIXES = new Set(["channel", "chat", "direct", "dm", "group", "thread", "user"]);
function normalizeKey(value: string | undefined): string {
return (value ?? "").trim().toLowerCase();
}
function stripConversationPrefix(
value: string | undefined,
provider: string | undefined,
): string | undefined {
const text = normalizeOptionalString(value);
if (!text) {
return undefined;
}
const separatorIndex = text.indexOf(":");
if (separatorIndex === -1) {
return text;
}
const prefix = normalizeKey(text.slice(0, separatorIndex));
const suffix = normalizeOptionalString(text.slice(separatorIndex + 1));
if (!suffix) {
return text;
}
if (TARGET_PREFIXES.has(prefix) || (provider && prefix === normalizeKey(provider))) {
return suffix;
}
return text;
}
export function resolveAgentHookChannelId(params: {
sessionKey?: string | null;
messageChannel?: string | null;
messageProvider?: string | null;
currentChannelId?: string | null;
messageTo?: string | null;
}): string | undefined {
const provider = normalizeOptionalString(params.messageProvider);
const parsed = parseRawSessionConversationRef(params.sessionKey);
if (parsed?.rawId) {
return parsed.rawId;
}
const metadataChannel =
stripConversationPrefix(params.currentChannelId ?? undefined, provider) ??
stripConversationPrefix(params.messageTo ?? undefined, provider);
if (metadataChannel && normalizeKey(metadataChannel) !== normalizeKey(provider)) {
return metadataChannel;
}
const messageChannel = stripConversationPrefix(params.messageChannel ?? undefined, provider);
if (messageChannel && normalizeKey(messageChannel) !== normalizeKey(provider)) {
return messageChannel;
}
return normalizeOptionalString(params.messageChannel) ?? provider;
}
export function buildAgentHookContextChannelFields(params: {
sessionKey?: string | null;
messageChannel?: string | null;
messageProvider?: string | null;
currentChannelId?: string | null;
messageTo?: string | null;
}): Pick<PluginHookAgentContext, "channelId" | "messageProvider"> {
return {
messageProvider: normalizeOptionalString(params.messageProvider),
channelId: resolveAgentHookChannelId(params),
};
}