Skip to content

Commit 863198f

Browse files
committed
fix(commands): tolerate empty plugin command replies
Fixes #74800.
1 parent 63ebe37 commit 863198f

5 files changed

Lines changed: 103 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
2929

3030
### Fixes
3131

32+
- Plugins/commands: normalize empty plugin command handler results and let Telegram native plugin commands send the empty-response fallback instead of throwing when a handler returns `undefined`. Fixes #74800. Thanks @vincentkoc.
3233
- Plugins/OpenRouter: advertise DeepSeek V4 thinking levels, including `xhigh` and `max`, through the runtime and lightweight provider policy surfaces so `/think` validation no longer rejects OpenRouter-routed DeepSeek V4 models. Fixes #74788. Thanks @vincentkoc.
3334
- Status/sessions: ignore malformed non-string persisted session provider/model metadata instead of throwing while rendering status summaries. Thanks @vincentkoc.
3435
- CLI/config: remove only the targeted array element for `openclaw config unset array[index]` instead of replaying the unset during config write and deleting the shifted next element. Fixes #76290. Thanks @SymbolStar and @vincentkoc.

extensions/telegram/src/bot-native-commands.session-meta.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,4 +1106,40 @@ describe("registerTelegramNativeCommands — session metadata", () => {
11061106
}),
11071107
);
11081108
});
1109+
1110+
it("sends an empty-response fallback when a plugin command returns undefined", async () => {
1111+
pluginRuntimeMocks.executePluginCommand.mockResolvedValue(undefined as never);
1112+
1113+
const { handler } = registerAndResolveCommandHandler({
1114+
commandName: "codex",
1115+
cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig,
1116+
useAccessGroups: false,
1117+
pluginCommandSpecs: [
1118+
{
1119+
name: "codex",
1120+
description: "Codex",
1121+
acceptsArgs: true,
1122+
},
1123+
] as TelegramPluginCommandSpecs,
1124+
});
1125+
pluginRuntimeMocks.matchPluginCommand.mockReturnValue({
1126+
command: {
1127+
name: "codex",
1128+
description: "Codex",
1129+
handler: vi.fn(),
1130+
pluginId: "openclaw-codex-app-server",
1131+
pluginName: "Codex",
1132+
requireAuth: true,
1133+
},
1134+
args: "status",
1135+
});
1136+
1137+
await handler(createTelegramPrivateCommandContext({ match: "status" }));
1138+
1139+
expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith(
1140+
expect.objectContaining({
1141+
replies: [{ text: "No response generated. Please try again." }],
1142+
}),
1143+
);
1144+
});
11091145
});

extensions/telegram/src/bot-native-commands.ts

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type {
2929
TelegramTopicConfig,
3030
} from "openclaw/plugin-sdk/config-types";
3131
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
32+
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
3233
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
3334
import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot";
3435
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
@@ -249,6 +250,16 @@ function resolveTelegramNativeReplyChannelData(
249250
return result.channelData?.telegram as TelegramNativeReplyChannelData | undefined;
250251
}
251252

253+
function normalizeTelegramNativeReplyPayload(
254+
result: TelegramNativeReplyPayload | null | undefined,
255+
): TelegramNativeReplyPayload {
256+
return result && typeof result === "object" ? result : {};
257+
}
258+
259+
function hasRenderableTelegramNativeReplyPayload(result: TelegramNativeReplyPayload): boolean {
260+
return resolveSendableOutboundReplyParts(result).hasContent;
261+
}
262+
252263
function isEditableTelegramProgressResult(result: TelegramNativeReplyPayload): boolean {
253264
const telegramData = resolveTelegramNativeReplyChannelData(result);
254265
return Boolean(
@@ -1276,23 +1287,25 @@ export const registerTelegramNativeCommands = ({
12761287
threadId: threadSpec.id,
12771288
});
12781289

1279-
const result = await nativeCommandRuntime.executePluginCommand({
1280-
command: match.command,
1281-
args: match.args,
1282-
senderId,
1283-
channel: "telegram",
1284-
isAuthorizedSender: commandAuthorized,
1285-
senderIsOwner,
1286-
sessionKey: route.sessionKey,
1287-
sessionId: sessionFileContext.sessionId,
1288-
sessionFile: sessionFileContext.sessionFile,
1289-
commandBody,
1290-
config: runtimeCfg,
1291-
from,
1292-
to,
1293-
accountId,
1294-
messageThreadId: threadSpec.id,
1295-
});
1290+
const result = normalizeTelegramNativeReplyPayload(
1291+
await nativeCommandRuntime.executePluginCommand({
1292+
command: match.command,
1293+
args: match.args,
1294+
senderId,
1295+
channel: "telegram",
1296+
isAuthorizedSender: commandAuthorized,
1297+
senderIsOwner,
1298+
sessionKey: route.sessionKey,
1299+
sessionId: sessionFileContext.sessionId,
1300+
sessionFile: sessionFileContext.sessionFile,
1301+
commandBody,
1302+
config: runtimeCfg,
1303+
from,
1304+
to,
1305+
accountId,
1306+
messageThreadId: threadSpec.id,
1307+
}),
1308+
);
12961309

12971310
if (
12981311
shouldSuppressLocalTelegramExecApprovalPrompt({
@@ -1310,14 +1323,19 @@ export const registerTelegramNativeCommands = ({
13101323
return;
13111324
}
13121325

1326+
const deliverableResult = hasRenderableTelegramNativeReplyPayload(result)
1327+
? result
1328+
: { text: EMPTY_RESPONSE_FALLBACK };
13131329
const progressResultText =
1314-
typeof result.text === "string" && result.text.trim().length > 0 ? result.text : null;
1315-
const telegramResultData = resolveTelegramNativeReplyChannelData(result);
1330+
typeof deliverableResult.text === "string" && deliverableResult.text.trim().length > 0
1331+
? deliverableResult.text
1332+
: null;
1333+
const telegramResultData = resolveTelegramNativeReplyChannelData(deliverableResult);
13161334
if (
13171335
progressMessageId != null &&
13181336
telegramDeps.editMessageTelegram &&
13191337
progressResultText &&
1320-
isEditableTelegramProgressResult(result)
1338+
isEditableTelegramProgressResult(deliverableResult)
13211339
) {
13221340
try {
13231341
await telegramDeps.editMessageTelegram(chatId, progressMessageId, progressResultText, {
@@ -1350,9 +1368,10 @@ export const registerTelegramNativeCommands = ({
13501368
runtime,
13511369
});
13521370
await deliverReplies({
1353-
replies: [result],
1371+
replies: [deliverableResult],
13541372
...deliveryBaseOptions,
1355-
silent: runtimeTelegramCfg.silentErrorReplies === true && result.isError === true,
1373+
silent:
1374+
runtimeTelegramCfg.silentErrorReplies === true && deliverableResult.isError === true,
13561375
});
13571376
});
13581377
}

src/plugins/commands.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,27 @@ describe("registerPluginCommand", () => {
901901
});
902902
});
903903

904+
it("normalizes undefined plugin command handler results to an empty reply payload", async () => {
905+
const handler = async () => undefined as never;
906+
907+
const result = await executePluginCommand({
908+
command: {
909+
name: "silentcheck",
910+
description: "Demo command",
911+
acceptsArgs: false,
912+
handler,
913+
pluginId: "demo-plugin",
914+
},
915+
channel: "telegram",
916+
senderId: "U123",
917+
isAuthorizedSender: true,
918+
commandBody: "/silentcheck",
919+
config: {} as never,
920+
});
921+
922+
expect(result).toEqual({});
923+
});
924+
904925
it("passes the effective default account to plugin command handlers when accountId is omitted", async () => {
905926
setActivePluginRegistry(
906927
createTestRegistry([

src/plugins/commands.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,10 @@ export async function executePluginCommand(params: {
339339
logVerbose(
340340
`Plugin command /${command.name} executed successfully for ${senderId || "unknown"}`,
341341
);
342+
if (!result || typeof result !== "object") {
343+
logVerbose(`Plugin command /${command.name} returned no reply payload`);
344+
return {};
345+
}
342346
return result;
343347
} catch (err) {
344348
const error = err as Error;

0 commit comments

Comments
 (0)