Skip to content

Commit 82c11de

Browse files
committed
fix(plugins): derive hook channel ids from targets
1 parent ab25a26 commit 82c11de

10 files changed

Lines changed: 161 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai
6969
- 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.
7070
- 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.
7171
- 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.
72+
- 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.
7273
- Gateway/config: log config health-state write failures instead of silently hiding config observe-recovery write errors. Thanks @sallyom.
7374
- 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.
7475

docs/plugins/hooks.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,11 @@ Cron-driven runs also expose `ctx.jobId` (the originating cron job id) so
212212
plugin hooks can scope metrics, side effects, or state to a specific scheduled
213213
job.
214214

215+
For channel-originated runs, `ctx.messageProvider` is the provider surface such
216+
as `discord` or `telegram`, while `ctx.channelId` is the conversation target
217+
identifier when OpenClaw can derive one from the session key or delivery
218+
metadata.
219+
215220
`agent_end` is an observation hook and runs fire-and-forget after the turn. The
216221
hook runner applies a 30 second timeout so a wedged plugin or embedding
217222
endpoint cannot leave the hook promise pending forever. A timeout is logged and

src/agents/cli-runner.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ReplyPayload } from "../auto-reply/reply-payload.js";
22
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
33
import { formatErrorMessage } from "../infra/errors.js";
4+
import { buildAgentHookContextChannelFields } from "../plugins/hook-agent-context.js";
45
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
56
import { loadCliSessionHistoryMessages } from "./cli-runner/session-history.js";
67
import type { PreparedCliRunContext, RunCliAgentParams } from "./cli-runner/types.js";
@@ -76,9 +77,8 @@ export async function runCliAgent(params: RunCliAgentParams): Promise<EmbeddedPi
7677
sessionKey: params.sessionKey,
7778
sessionId: params.sessionId,
7879
workspaceDir: params.workspaceDir,
79-
messageProvider: params.messageProvider,
8080
trigger: params.trigger,
81-
channelId: params.messageChannel ?? params.messageProvider,
81+
...buildAgentHookContextChannelFields(params),
8282
} as const;
8383
const hookResult = await hookRunner.runBeforeAgentReply(
8484
{ cleanedBody: params.prompt },
@@ -154,9 +154,8 @@ export async function runPreparedCliAgent(
154154
sessionKey: params.sessionKey,
155155
sessionId: params.sessionId,
156156
workspaceDir: params.workspaceDir,
157-
messageProvider: params.messageProvider,
158157
trigger: params.trigger,
159-
channelId: params.messageChannel ?? params.messageProvider,
158+
...buildAgentHookContextChannelFields(params),
160159
} as const;
161160

162161
const buildAgentEndMessages = (lastAssistant?: unknown): unknown[] => [

src/agents/cli-runner/prepare.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
CliBackendAuthEpochMode,
99
CliBackendPreparedExecution,
1010
} from "../../plugins/cli-backend.types.js";
11+
import { buildAgentHookContextChannelFields } from "../../plugins/hook-agent-context.js";
1112
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
1213
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
1314
import { resolveOpenClawAgentDir } from "../agent-paths.js";
@@ -349,9 +350,8 @@ export async function prepareCliRunContext(
349350
workspaceDir,
350351
modelProviderId: params.provider,
351352
modelId,
352-
messageProvider: params.messageProvider,
353353
trigger: params.trigger,
354-
channelId: params.messageChannel ?? params.messageProvider,
354+
...buildAgentHookContextChannelFields(params),
355355
},
356356
hookRunner,
357357
});

src/agents/pi-embedded-runner/run.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { emitAgentPlanEvent } from "../../infra/agent-events.js";
99
import { sleepWithAbort } from "../../infra/backoff.js";
1010
import { freezeDiagnosticTraceContext } from "../../infra/diagnostic-trace-context.js";
1111
import { formatErrorMessage } from "../../infra/errors.js";
12+
import { buildAgentHookContextChannelFields } from "../../plugins/hook-agent-context.js";
1213
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
1314
import { resolveProviderAuthProfileId } from "../../plugins/provider-runtime.js";
1415
import { enqueueCommandInLane } from "../../process/command-queue.js";
@@ -423,9 +424,8 @@ export async function runEmbeddedPiAgent(
423424
workspaceDir: resolvedWorkspace,
424425
modelProviderId: provider,
425426
modelId,
426-
messageProvider: params.messageProvider ?? undefined,
427427
trigger: params.trigger,
428-
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
428+
...buildAgentHookContextChannelFields(params),
429429
};
430430
if (params.trigger === "cron" && hookRunner?.hasHooks("before_agent_reply")) {
431431
const hookResult = await hookRunner.runBeforeAgentReply(

src/agents/pi-embedded-runner/run/attempt.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { resolveHeartbeatSummaryForAgent } from "../../../infra/heartbeat-summar
2323
import { getMachineDisplayName } from "../../../infra/machine-name.js";
2424
import { MAX_IMAGE_BYTES } from "../../../media/constants.js";
2525
import { listRegisteredPluginAgentPromptGuidance } from "../../../plugins/command-registry-state.js";
26+
import { buildAgentHookContextChannelFields } from "../../../plugins/hook-agent-context.js";
2627
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
2728
import {
2829
extractModelCompat,
@@ -2442,9 +2443,8 @@ export async function runEmbeddedAttempt(
24422443
workspaceDir: params.workspaceDir,
24432444
modelProviderId: params.model.provider,
24442445
modelId: params.model.id,
2445-
messageProvider: params.messageProvider ?? undefined,
24462446
trigger: params.trigger,
2447-
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
2447+
...buildAgentHookContextChannelFields(params),
24482448
};
24492449
const promptBuildMessages =
24502450
pruneProcessedHistoryImages(activeSession.messages) ?? activeSession.messages;
@@ -2761,9 +2761,8 @@ export async function runEmbeddedAttempt(
27612761
sessionKey: params.sessionKey,
27622762
sessionId: params.sessionId,
27632763
workspaceDir: params.workspaceDir,
2764-
messageProvider: params.messageProvider ?? undefined,
27652764
trigger: params.trigger,
2766-
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
2765+
...buildAgentHookContextChannelFields(params),
27672766
},
27682767
)
27692768
.catch((err) => {
@@ -3228,9 +3227,8 @@ export async function runEmbeddedAttempt(
32283227
sessionKey: params.sessionKey,
32293228
sessionId: params.sessionId,
32303229
workspaceDir: params.workspaceDir,
3231-
messageProvider: params.messageProvider ?? undefined,
32323230
trigger: params.trigger,
3233-
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
3231+
...buildAgentHookContextChannelFields(params),
32343232
},
32353233
)
32363234
.catch((err) => {
@@ -3336,9 +3334,8 @@ export async function runEmbeddedAttempt(
33363334
sessionKey: params.sessionKey,
33373335
sessionId: params.sessionId,
33383336
workspaceDir: params.workspaceDir,
3339-
messageProvider: params.messageProvider ?? undefined,
33403337
trigger: params.trigger,
3341-
channelId: params.messageChannel ?? params.messageProvider ?? undefined,
3338+
...buildAgentHookContextChannelFields(params),
33423339
},
33433340
)
33443341
.catch((err) => {

src/auto-reply/reply/get-reply.before-agent-reply.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ describe("getReplyFromConfig before_agent_reply wiring", () => {
9393
workspaceDir: "/tmp/workspace",
9494
messageProvider: "telegram",
9595
trigger: "user",
96-
channelId: "telegram",
96+
channelId: "-100123",
9797
}),
9898
);
9999
expect(mocks.handleInlineActions.mock.invocationCallOrder[0]).toBeLessThan(

src/auto-reply/reply/get-reply.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { resolveChannelModelOverride } from "../../channels/model-overrides.js";
1212
import { type OpenClawConfig, getRuntimeConfig } from "../../config/config.js";
1313
import { logVerbose } from "../../globals.js";
1414
import { formatErrorMessage } from "../../infra/errors.js";
15+
import { buildAgentHookContextChannelFields } from "../../plugins/hook-agent-context.js";
1516
import { defaultRuntime } from "../../runtime.js";
1617
import { normalizeOptionalString } from "../../shared/string-coerce.js";
1718
import { normalizeStringEntries } from "../../shared/string-normalization.js";
@@ -604,9 +605,13 @@ export async function getReplyFromConfig(
604605
sessionKey: agentSessionKey,
605606
sessionId,
606607
workspaceDir,
607-
messageProvider: hookMessageProvider,
608608
trigger: opts?.isHeartbeat ? "heartbeat" : "user",
609-
channelId: hookMessageProvider,
609+
...buildAgentHookContextChannelFields({
610+
sessionKey: agentSessionKey,
611+
messageProvider: hookMessageProvider,
612+
currentChannelId: sessionCtx.OriginatingTo ?? ctx.OriginatingTo ?? ctx.To,
613+
messageTo: sessionCtx.OriginatingTo ?? ctx.OriginatingTo ?? ctx.To,
614+
}),
610615
},
611616
);
612617
if (hookResult?.handled) {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
buildAgentHookContextChannelFields,
4+
resolveAgentHookChannelId,
5+
} from "./hook-agent-context.js";
6+
7+
describe("resolveAgentHookChannelId", () => {
8+
it("derives the conversation id from channel session keys", () => {
9+
expect(
10+
resolveAgentHookChannelId({
11+
sessionKey: "agent:main:discord:channel:1472750640760623226",
12+
messageChannel: "discord",
13+
messageProvider: "discord",
14+
currentChannelId: "channel:1472750640760623226",
15+
}),
16+
).toBe("1472750640760623226");
17+
});
18+
19+
it("uses target metadata when the session key is not a channel conversation", () => {
20+
expect(
21+
resolveAgentHookChannelId({
22+
sessionKey: "agent:main:main",
23+
messageProvider: "telegram",
24+
currentChannelId: "telegram:-1003841603622",
25+
}),
26+
).toBe("-1003841603622");
27+
});
28+
29+
it("uses prefixed message targets before falling back to the provider", () => {
30+
expect(
31+
resolveAgentHookChannelId({
32+
messageChannel: "channel:1472750640760623226",
33+
messageProvider: "discord",
34+
}),
35+
).toBe("1472750640760623226");
36+
});
37+
38+
it("falls back to legacy channel/provider values when no conversation id is available", () => {
39+
expect(
40+
resolveAgentHookChannelId({
41+
messageChannel: "discord",
42+
messageProvider: "discord",
43+
}),
44+
).toBe("discord");
45+
});
46+
});
47+
48+
describe("buildAgentHookContextChannelFields", () => {
49+
it("keeps provider and conversation id separate", () => {
50+
expect(
51+
buildAgentHookContextChannelFields({
52+
sessionKey: "agent:main:discord:channel:c1",
53+
messageChannel: "discord",
54+
messageProvider: "discord",
55+
}),
56+
).toEqual({
57+
messageProvider: "discord",
58+
channelId: "c1",
59+
});
60+
});
61+
});

src/plugins/hook-agent-context.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { parseRawSessionConversationRef } from "../sessions/session-key-utils.js";
2+
import { normalizeOptionalString } from "../shared/string-coerce.js";
3+
import type { PluginHookAgentContext } from "./hook-types.js";
4+
5+
const TARGET_PREFIXES = new Set(["channel", "chat", "direct", "dm", "group", "thread", "user"]);
6+
7+
function normalizeKey(value: string | undefined): string {
8+
return (value ?? "").trim().toLowerCase();
9+
}
10+
11+
function stripConversationPrefix(
12+
value: string | undefined,
13+
provider: string | undefined,
14+
): string | undefined {
15+
const text = normalizeOptionalString(value);
16+
if (!text) {
17+
return undefined;
18+
}
19+
20+
const separatorIndex = text.indexOf(":");
21+
if (separatorIndex === -1) {
22+
return text;
23+
}
24+
25+
const prefix = normalizeKey(text.slice(0, separatorIndex));
26+
const suffix = normalizeOptionalString(text.slice(separatorIndex + 1));
27+
if (!suffix) {
28+
return text;
29+
}
30+
if (TARGET_PREFIXES.has(prefix) || (provider && prefix === normalizeKey(provider))) {
31+
return suffix;
32+
}
33+
return text;
34+
}
35+
36+
export function resolveAgentHookChannelId(params: {
37+
sessionKey?: string | null;
38+
messageChannel?: string | null;
39+
messageProvider?: string | null;
40+
currentChannelId?: string | null;
41+
messageTo?: string | null;
42+
}): string | undefined {
43+
const provider = normalizeOptionalString(params.messageProvider);
44+
const parsed = parseRawSessionConversationRef(params.sessionKey);
45+
if (parsed?.rawId) {
46+
return parsed.rawId;
47+
}
48+
49+
const metadataChannel =
50+
stripConversationPrefix(params.currentChannelId ?? undefined, provider) ??
51+
stripConversationPrefix(params.messageTo ?? undefined, provider);
52+
if (metadataChannel && normalizeKey(metadataChannel) !== normalizeKey(provider)) {
53+
return metadataChannel;
54+
}
55+
56+
const messageChannel = stripConversationPrefix(params.messageChannel ?? undefined, provider);
57+
if (messageChannel && normalizeKey(messageChannel) !== normalizeKey(provider)) {
58+
return messageChannel;
59+
}
60+
return normalizeOptionalString(params.messageChannel) ?? provider;
61+
}
62+
63+
export function buildAgentHookContextChannelFields(params: {
64+
sessionKey?: string | null;
65+
messageChannel?: string | null;
66+
messageProvider?: string | null;
67+
currentChannelId?: string | null;
68+
messageTo?: string | null;
69+
}): Pick<PluginHookAgentContext, "channelId" | "messageProvider"> {
70+
return {
71+
messageProvider: normalizeOptionalString(params.messageProvider),
72+
channelId: resolveAgentHookChannelId(params),
73+
};
74+
}

0 commit comments

Comments
 (0)