Skip to content

Commit 2899560

Browse files
authored
fix(reply): derive explicit control command turns
Derive explicit source-reply command turns from authorized control-command bodies when legacy command source metadata is missing. Preserve native/text structured command semantics, keep unauthorized native commands and structured normal command bodies on plugin-owned fallback paths, and pass bot username normalization through the derived detection. Co-authored-by: Alex Knight <aknight@atlassian.com>
1 parent 44c1cc8 commit 2899560

7 files changed

Lines changed: 420 additions & 16 deletions

src/auto-reply/command-turn-context.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from "vitest";
2+
import type { OpenClawConfig } from "../config/types.openclaw.js";
23
import {
34
createCommandTurnContext,
45
isAuthorizedTextSlashCommandTurn,
@@ -7,6 +8,9 @@ import {
78
resolveCommandTurnContext,
89
resolveCommandTurnTargetSessionKey,
910
} from "./command-turn-context.js";
11+
import { isExplicitCommandTurnContext } from "./command-turn-detection.js";
12+
13+
const emptyConfig = {} as const satisfies OpenClawConfig;
1014

1115
describe("resolveCommandTurnContext", () => {
1216
it("derives native command turns from legacy context fields", () => {
@@ -53,6 +57,88 @@ describe("resolveCommandTurnContext", () => {
5357
expect(isExplicitCommandTurn(commandTurn)).toBe(false);
5458
});
5559

60+
it("treats authorized control command bodies as explicit without legacy source tags", () => {
61+
expect(
62+
isExplicitCommandTurnContext(
63+
{
64+
CommandAuthorized: true,
65+
CommandBody: "/reset",
66+
},
67+
emptyConfig,
68+
),
69+
).toBe(true);
70+
expect(
71+
isExplicitCommandTurnContext(
72+
{
73+
CommandAuthorized: true,
74+
CommandBody: "hey can you /status please",
75+
},
76+
emptyConfig,
77+
),
78+
).toBe(false);
79+
});
80+
81+
it("keeps structured normal command turns non-explicit", () => {
82+
expect(
83+
isExplicitCommandTurnContext(
84+
{
85+
CommandTurn: {
86+
kind: "normal",
87+
source: "message",
88+
authorized: false,
89+
body: "/think high through this",
90+
},
91+
CommandAuthorized: true,
92+
Body: "through this",
93+
RawBody: "through this",
94+
CommandBody: "/think high through this",
95+
},
96+
emptyConfig,
97+
),
98+
).toBe(false);
99+
});
100+
101+
it("uses cleaned command bodies for command-shaped structured normal turns", () => {
102+
expect(
103+
isExplicitCommandTurnContext(
104+
{
105+
CommandTurn: {
106+
kind: "normal",
107+
source: "message",
108+
authorized: false,
109+
body: "/reset",
110+
},
111+
CommandAuthorized: true,
112+
Body: "/reset@openclaw",
113+
RawBody: "/reset@openclaw",
114+
CommandBody: "/reset",
115+
},
116+
emptyConfig,
117+
),
118+
).toBe(true);
119+
});
120+
121+
it("normalizes bot-mentioned command bodies for structured normal turns", () => {
122+
expect(
123+
isExplicitCommandTurnContext(
124+
{
125+
CommandTurn: {
126+
kind: "normal",
127+
source: "message",
128+
authorized: false,
129+
body: "/reset@openclaw",
130+
},
131+
CommandAuthorized: true,
132+
Body: "/reset@openclaw",
133+
RawBody: "/reset@openclaw",
134+
CommandBody: "/reset@openclaw",
135+
BotUsername: "openclaw",
136+
},
137+
emptyConfig,
138+
),
139+
).toBe(true);
140+
});
141+
56142
it("lets structured command turns override legacy command fields", () => {
57143
expect(
58144
resolveCommandTurnContext({

src/auto-reply/command-turn-context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export type CommandTurnContextInput = {
3939
BodyForCommands?: unknown;
4040
RawBody?: unknown;
4141
Body?: unknown;
42+
BotUsername?: unknown;
4243
};
4344

4445
function resolveCommandBody(input: CommandTurnContextInput): string | undefined {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { OpenClawConfig } from "../config/types.openclaw.js";
2+
import { normalizeOptionalString } from "../shared/string-coerce.js";
3+
import { isControlCommandMessage } from "./command-detection.js";
4+
import {
5+
isExplicitCommandTurn,
6+
resolveCommandTurnContext,
7+
type CommandTurnContextInput,
8+
} from "./command-turn-context.js";
9+
10+
function resolveCommandBody(input: CommandTurnContextInput): string | undefined {
11+
return (
12+
normalizeOptionalString(input.CommandBody) ??
13+
normalizeOptionalString(input.BodyForCommands) ??
14+
normalizeOptionalString(input.RawBody) ??
15+
normalizeOptionalString(input.Body)
16+
);
17+
}
18+
19+
function resolveVisibleMessageBody(input: CommandTurnContextInput): string | undefined {
20+
return normalizeOptionalString(input.RawBody) ?? normalizeOptionalString(input.Body);
21+
}
22+
23+
function resolveStructuredNormalFallbackBody(input: CommandTurnContextInput): string | undefined {
24+
const visibleBody = resolveVisibleMessageBody(input);
25+
if (!/^[!/]/.test(visibleBody ?? "")) {
26+
return undefined;
27+
}
28+
return resolveCommandBody(input) ?? visibleBody;
29+
}
30+
31+
function hasCommandSourceMetadata(input: CommandTurnContextInput): boolean {
32+
return (
33+
input.CommandSource === "native" ||
34+
input.CommandSource === "text" ||
35+
input.CommandSource === "message"
36+
);
37+
}
38+
39+
export function isExplicitCommandTurnContext(
40+
input: CommandTurnContextInput,
41+
cfg: OpenClawConfig,
42+
): boolean {
43+
if (isExplicitCommandTurn(resolveCommandTurnContext(input))) {
44+
return true;
45+
}
46+
if (input.CommandSource === "native" || input.CommandSource === "text") {
47+
return false;
48+
}
49+
const fallbackBody =
50+
input.CommandTurn !== undefined || hasCommandSourceMetadata(input)
51+
? resolveStructuredNormalFallbackBody(input)
52+
: resolveCommandBody(input);
53+
return (
54+
input.CommandAuthorized === true &&
55+
isControlCommandMessage(fallbackBody, cfg, {
56+
botUsername: normalizeOptionalString(input.BotUsername),
57+
})
58+
);
59+
}

0 commit comments

Comments
 (0)