diff --git a/CHANGELOG.md b/CHANGELOG.md index fdf7ed821af..517e83c09ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md index 5cc106798c0..bf1e9607f26 100644 --- a/docs/plugins/hooks.md +++ b/docs/plugins/hooks.md @@ -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 diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index e07171affea..99a496b2f11 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -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 [ diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index ee6c2bb7718..592d4b9e2c9 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -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, }); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index b9e71545504..3cd5a7a800b 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -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( diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index e8fa493b713..dae609bc3dc 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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) => { diff --git a/src/auto-reply/reply/get-reply.before-agent-reply.test.ts b/src/auto-reply/reply/get-reply.before-agent-reply.test.ts index e786bab89ec..c5e0820f8a1 100644 --- a/src/auto-reply/reply/get-reply.before-agent-reply.test.ts +++ b/src/auto-reply/reply/get-reply.before-agent-reply.test.ts @@ -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( diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 532b152013e..b9fe70b45c8 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -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) { diff --git a/src/plugins/hook-agent-context.test.ts b/src/plugins/hook-agent-context.test.ts new file mode 100644 index 00000000000..e6e188623b2 --- /dev/null +++ b/src/plugins/hook-agent-context.test.ts @@ -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", + }); + }); +}); diff --git a/src/plugins/hook-agent-context.ts b/src/plugins/hook-agent-context.ts new file mode 100644 index 00000000000..625308a3a18 --- /dev/null +++ b/src/plugins/hook-agent-context.ts @@ -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 { + return { + messageProvider: normalizeOptionalString(params.messageProvider), + channelId: resolveAgentHookChannelId(params), + }; +}