diff --git a/extensions/discord/src/monitor/agent-components.dispatch.ts b/extensions/discord/src/monitor/agent-components.dispatch.ts index eac11bf1596..91e800056d3 100644 --- a/extensions/discord/src/monitor/agent-components.dispatch.ts +++ b/extensions/discord/src/monitor/agent-components.dispatch.ts @@ -223,6 +223,12 @@ export async function dispatchDiscordComponentEvent(params: { Surface: "discord" as const, WasMentioned: true, CommandAuthorized: commandAuthorized, + CommandTurn: { + kind: "text-slash" as const, + source: "text" as const, + authorized: commandAuthorized, + body: eventText, + }, CommandSource: "text" as const, MessageSid: interaction.rawData.id, Timestamp: timestamp, diff --git a/extensions/discord/src/monitor/message-handler.context.ts b/extensions/discord/src/monitor/message-handler.context.ts index b4c54e2b13a..f1bb665a369 100644 --- a/extensions/discord/src/monitor/message-handler.context.ts +++ b/extensions/discord/src/monitor/message-handler.context.ts @@ -350,6 +350,12 @@ export async function buildDiscordMessageProcessContext(params: { ...mediaPayload, ...(preflightAudioIndex >= 0 ? { MediaTranscribedIndexes: [preflightAudioIndex] } : {}), CommandAuthorized: commandAuthorized, + CommandTurn: { + kind: "text-slash" as const, + source: "text" as const, + authorized: commandAuthorized, + body: preflightAudioTranscript ?? baseText, + }, CommandSource: "text" as const, OriginatingChannel: "discord" as const, OriginatingTo: originatingTo, diff --git a/extensions/discord/src/monitor/native-command-context.ts b/extensions/discord/src/monitor/native-command-context.ts index 0b7e1d3f15f..447c62d9529 100644 --- a/extensions/discord/src/monitor/native-command-context.ts +++ b/extensions/discord/src/monitor/native-command-context.ts @@ -87,6 +87,12 @@ export function buildDiscordNativeCommandContext(params: BuildDiscordNativeComma MessageThreadId: params.isThreadChannel ? params.channelId : undefined, Timestamp: params.timestampMs ?? Date.now(), CommandAuthorized: params.commandAuthorized, + CommandTurn: { + kind: "native" as const, + source: "native" as const, + authorized: params.commandAuthorized, + body: params.prompt, + }, CommandSource: "native" as const, // Native slash contexts use To=slash: for interaction routing. // For follow-up delivery (for example subagent completion announces), diff --git a/extensions/feishu/src/bot.broadcast.test.ts b/extensions/feishu/src/bot.broadcast.test.ts index 0c8555d2f4d..adc951c19a7 100644 --- a/extensions/feishu/src/bot.broadcast.test.ts +++ b/extensions/feishu/src/bot.broadcast.test.ts @@ -54,6 +54,11 @@ describe("broadcast dispatch", () => { return { ...ctx, CommandAuthorized: typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : false, + CommandTurn: { + kind: "normal", + source: "message", + authorized: false, + }, }; }; const mockDispatchReplyFromConfig = vi diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index 50922604266..08c75ce7906 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -414,6 +414,7 @@ export async function buildTelegramInboundContextPayload(params: { : `telegram:${chatId}`; const telegramTo = `telegram:${chatId}`; const locationContext = locationData ? toLocationContext(locationData) : undefined; + const commandSource = options?.commandSource; const ctxPayload = sessionRuntime.buildChannelTurnContext({ channel: "telegram", accountId: route.accountId, @@ -465,6 +466,22 @@ export async function buildTelegramInboundContextPayload(params: { authorizers: [], }, }, + commandTurn: + commandSource === "native" + ? { + kind: "native", + source: "native", + authorized: commandAuthorized, + body: commandBody, + } + : commandSource === "text" + ? { + kind: "text-slash", + source: "text", + authorized: commandAuthorized, + body: commandBody, + } + : undefined, media: contextMedia.map((media, index) => ({ path: media.path, url: media.path, @@ -523,7 +540,6 @@ export async function buildTelegramInboundContextPayload(params: { Sticker: allMedia[0]?.stickerMetadata, StickerMediaIncluded: allMedia[0]?.stickerMetadata ? !stickerCacheHit : undefined, ...locationContext, - CommandSource: options?.commandSource, IsForum: isForum, TopicName: isForum && topicName ? topicName : undefined, }, diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 866a737d86a..4f452047b82 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -1163,6 +1163,12 @@ export const registerTelegramNativeCommands = ({ Timestamp: msg.date ? msg.date * 1000 : undefined, WasMentioned: true, CommandAuthorized: commandAuthorized, + CommandTurn: { + kind: "native" as const, + source: "native" as const, + authorized: commandAuthorized, + body: prompt, + }, CommandSource: "native" as const, SessionKey: commandSessionKey, AccountId: route.accountId, diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts index 4fd1789e841..647c6f4a10e 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts @@ -388,7 +388,12 @@ describe("whatsapp inbound dispatch", () => { combinedBody: "/status", commandBody: "/status", commandAuthorized: true, - commandSource: "text", + commandTurn: { + kind: "text-slash", + source: "text", + authorized: true, + body: "/status", + }, conversationId: "+1000", msg: makeMsg({ body: "/status", @@ -408,6 +413,12 @@ describe("whatsapp inbound dispatch", () => { RawBody: "/status", CommandAuthorized: true, CommandSource: "text", + CommandTurn: { + kind: "text-slash", + source: "text", + authorized: true, + body: "/status", + }, Provider: "whatsapp", Surface: "whatsapp", OriginatingChannel: "whatsapp", diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts index b575f571708..e6c876a38ce 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts @@ -2,6 +2,7 @@ import { DEFAULT_TIMING, type StatusReactionController, } from "openclaw/plugin-sdk/channel-feedback"; +import type { CommandTurnContext } from "openclaw/plugin-sdk/channel-inbound"; import { deliverInboundReplyWithMessageSendContext } from "openclaw/plugin-sdk/channel-message"; import { hasVisibleInboundReplyDispatch } from "openclaw/plugin-sdk/inbound-reply-dispatch"; import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime"; @@ -225,6 +226,7 @@ export function buildWhatsAppInboundContext(params: { combinedBody: string; commandBody?: string; commandAuthorized?: boolean; + commandTurn?: CommandTurnContext; commandSource?: "text"; conversationId: string; groupHistory?: GroupHistoryEntry[]; @@ -280,7 +282,12 @@ export function buildWhatsAppInboundContext(params: { SenderId: params.sender.id ?? params.sender.e164, SenderE164: params.sender.e164, CommandAuthorized: params.commandAuthorized, - CommandSource: params.commandSource, + CommandTurn: params.commandTurn, + CommandSource: + params.commandSource ?? + (params.commandTurn?.source === "native" || params.commandTurn?.source === "text" + ? params.commandTurn.source + : undefined), ReplyThreading: params.replyThreading, WasMentioned: params.msg.wasMentioned, GroupSystemPrompt: params.groupSystemPrompt, @@ -294,6 +301,43 @@ export function buildWhatsAppInboundContext(params: { return result; } +function normalizeCommandTurnFromContext(value: unknown): CommandTurnContext | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const record = value as Partial; + const kind = record.kind; + const source = record.source; + if (kind === "native" && source === "native" && typeof record.authorized === "boolean") { + return { + kind: "native", + source: "native", + authorized: record.authorized, + commandName: typeof record.commandName === "string" ? record.commandName : undefined, + body: typeof record.body === "string" ? record.body : undefined, + }; + } + if (kind === "text-slash" && source === "text" && typeof record.authorized === "boolean") { + return { + kind: "text-slash", + source: "text", + authorized: record.authorized, + commandName: typeof record.commandName === "string" ? record.commandName : undefined, + body: typeof record.body === "string" ? record.body : undefined, + }; + } + if (kind === "normal" && source === "message") { + return { + kind: "normal", + source: "message", + authorized: false, + commandName: typeof record.commandName === "string" ? record.commandName : undefined, + body: typeof record.body === "string" ? record.body : undefined, + }; + } + return undefined; +} + export function resolveWhatsAppDmRouteTarget(params: { msg: WebInboundMsg; senderE164?: string; @@ -427,6 +471,7 @@ export async function dispatchWhatsAppBufferedReply(params: { params.context.CommandSource === "native" || params.context.CommandSource === "text" ? params.context.CommandSource : undefined; + const sourceReplyCommandTurn = normalizeCommandTurnFromContext(params.context.CommandTurn); const sourceReplyCommandAuthorized = typeof params.context.CommandAuthorized === "boolean" ? params.context.CommandAuthorized @@ -437,6 +482,7 @@ export async function dispatchWhatsAppBufferedReply(params: { cfg: params.cfg, ctx: { ChatType: sourceReplyChatType, + CommandTurn: sourceReplyCommandTurn, CommandSource: sourceReplyCommandSource, CommandAuthorized: sourceReplyCommandAuthorized, }, diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts index 68b64f27d55..df365cd3635 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts @@ -294,7 +294,12 @@ describe("processMessage group system prompt wiring", () => { expect(buildContextMock.mock.calls[0][0]).toMatchObject({ commandBody: "/status", commandAuthorized: true, - commandSource: "text", + commandTurn: { + kind: "text-slash", + source: "text", + authorized: true, + body: "/status", + }, rawBody: "/status", }); }); @@ -314,6 +319,12 @@ describe("processMessage group system prompt wiring", () => { expect(buildContextMock.mock.calls[0][0]).toMatchObject({ commandBody: "please inspect `/tmp/foo`", commandAuthorized: true, + commandTurn: { + kind: "normal", + source: "message", + authorized: false, + body: "please inspect `/tmp/foo`", + }, rawBody: "please inspect `/tmp/foo`", }); expect(buildContextMock.mock.calls[0][0].commandSource).toBeUndefined(); diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts index 599fdd08caa..21866c48d5a 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts @@ -3,6 +3,7 @@ import { removeAckReactionHandleAfterReply, type AckReactionHandle, } from "openclaw/plugin-sdk/channel-feedback"; +import type { CommandTurnContext } from "openclaw/plugin-sdk/channel-inbound"; import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; import { createInternalHookEvent, @@ -417,6 +418,19 @@ export async function processMessage(params: { policy: inboundPolicy, }) : undefined; + const commandTurn: CommandTurnContext = isTextCommand + ? { + kind: "text-slash", + source: "text", + authorized: Boolean(commandAuthorized), + body: params.msg.body, + } + : { + kind: "normal", + source: "message", + authorized: false, + body: params.msg.body, + }; const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({ cfg: params.cfg, agentId: params.route.agentId, @@ -451,7 +465,7 @@ export async function processMessage(params: { combinedBody, commandBody: params.msg.body, commandAuthorized, - commandSource: isTextCommand ? "text" : undefined, + commandTurn, conversationId, groupHistory: visibleGroupHistory, groupMemberRoster: params.groupMemberNames.get(params.groupHistoryKey), diff --git a/src/auto-reply/command-turn-context.test.ts b/src/auto-reply/command-turn-context.test.ts new file mode 100644 index 00000000000..0ce907f18a1 --- /dev/null +++ b/src/auto-reply/command-turn-context.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { isExplicitCommandTurn, resolveCommandTurnContext } from "./command-turn-context.js"; + +describe("resolveCommandTurnContext", () => { + it("derives native command turns from legacy context fields", () => { + expect( + resolveCommandTurnContext({ + CommandSource: "native", + CommandAuthorized: true, + CommandBody: "/status now", + }), + ).toEqual({ + kind: "native", + source: "native", + authorized: true, + commandName: "status", + body: "/status now", + }); + }); + + it("derives text slash command turns from legacy context fields", () => { + expect( + resolveCommandTurnContext({ + CommandSource: "text", + CommandAuthorized: true, + CommandBody: "/model gpt-5.5", + }), + ).toMatchObject({ + kind: "text-slash", + source: "text", + authorized: true, + commandName: "model", + }); + }); + + it("keeps normal message turns non-explicit even when command auth is true elsewhere", () => { + const commandTurn = resolveCommandTurnContext({ + CommandAuthorized: true, + CommandBody: "hello", + }); + expect(commandTurn).toMatchObject({ + kind: "normal", + source: "message", + authorized: false, + }); + expect(isExplicitCommandTurn(commandTurn)).toBe(false); + }); + + it("lets structured command turns override legacy command fields", () => { + expect( + resolveCommandTurnContext({ + CommandTurn: { + kind: "text-slash", + source: "text", + authorized: false, + commandName: "status", + body: "/status", + }, + CommandSource: "native", + CommandAuthorized: true, + }), + ).toEqual({ + kind: "text-slash", + source: "text", + authorized: false, + commandName: "status", + body: "/status", + }); + }); + + it("rejects inconsistent structured command turn pairs", () => { + expect( + resolveCommandTurnContext({ + CommandTurn: { + kind: "native", + source: "message", + authorized: true, + }, + CommandSource: "text", + CommandAuthorized: true, + CommandBody: "/status", + }), + ).toMatchObject({ + kind: "text-slash", + source: "text", + authorized: true, + }); + }); +}); diff --git a/src/auto-reply/command-turn-context.ts b/src/auto-reply/command-turn-context.ts new file mode 100644 index 00000000000..48fb7c3794b --- /dev/null +++ b/src/auto-reply/command-turn-context.ts @@ -0,0 +1,185 @@ +export type CommandTurnKind = "native" | "text-slash" | "normal"; +export type CommandTurnSource = "native" | "text" | "message"; + +type BaseCommandTurnContext = { + commandName?: string; + body?: string; +}; + +export type NativeCommandTurnContext = BaseCommandTurnContext & { + kind: "native"; + source: "native"; + authorized: boolean; +}; + +export type TextSlashCommandTurnContext = BaseCommandTurnContext & { + kind: "text-slash"; + source: "text"; + authorized: boolean; +}; + +export type NormalCommandTurnContext = BaseCommandTurnContext & { + kind: "normal"; + source: "message"; + authorized: false; +}; + +export type CommandTurnContext = + | NativeCommandTurnContext + | TextSlashCommandTurnContext + | NormalCommandTurnContext; + +export type CommandTurnContextInput = { + CommandTurn?: unknown; + CommandSource?: unknown; + CommandAuthorized?: unknown; + CommandBody?: unknown; + BodyForCommands?: unknown; + RawBody?: unknown; + Body?: unknown; +}; + +function normalizeOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function resolveCommandBody(input: CommandTurnContextInput): string | undefined { + return ( + normalizeOptionalString(input.CommandBody) ?? + normalizeOptionalString(input.BodyForCommands) ?? + normalizeOptionalString(input.RawBody) ?? + normalizeOptionalString(input.Body) + ); +} + +function parseCommandName(body: string | undefined): string | undefined { + if (!body?.startsWith("/")) { + return undefined; + } + const name = body.slice(1).split(/\s+/, 1)[0]?.split("@", 1)[0]; + return normalizeOptionalString(name); +} + +function commandTurnKindToSource(kind: CommandTurnKind): CommandTurnSource { + if (kind === "native") { + return "native"; + } + if (kind === "text-slash") { + return "text"; + } + return "message"; +} + +function normalizeCommandTurnKind(value: unknown): CommandTurnKind | undefined { + return value === "native" || value === "text-slash" || value === "normal" ? value : undefined; +} + +function normalizeCommandTurnSource(value: unknown): CommandTurnSource | undefined { + return value === "native" || value === "text" || value === "message" ? value : undefined; +} + +function sourceToCommandTurnKind(source: CommandTurnSource): CommandTurnKind { + if (source === "native") { + return "native"; + } + if (source === "text") { + return "text-slash"; + } + return "normal"; +} + +function buildCommandTurnContext( + source: CommandTurnSource, + input: { + authorized: boolean; + commandName?: string; + body?: string; + }, +): CommandTurnContext { + if (source === "native") { + return { + kind: "native", + source: "native", + authorized: input.authorized, + commandName: input.commandName, + body: input.body, + }; + } + if (source === "text") { + return { + kind: "text-slash", + source: "text", + authorized: input.authorized, + commandName: input.commandName, + body: input.body, + }; + } + return { + kind: "normal", + source: "message", + authorized: false, + commandName: input.commandName, + body: input.body, + }; +} + +function normalizeExplicitCommandTurn( + value: unknown, + input: CommandTurnContextInput, +): CommandTurnContext | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const record = value as Record; + const kind = normalizeCommandTurnKind(record.kind); + const source = + normalizeCommandTurnSource(record.source) ?? (kind ? commandTurnKindToSource(kind) : undefined); + const resolvedKind = kind ?? (source ? sourceToCommandTurnKind(source) : undefined); + if (kind && source && commandTurnKindToSource(kind) !== source) { + return undefined; + } + if (!resolvedKind || !source) { + return undefined; + } + const body = normalizeOptionalString(record.body) ?? resolveCommandBody(input); + return buildCommandTurnContext(source, { + authorized: + resolvedKind === "normal" + ? false + : typeof record.authorized === "boolean" + ? record.authorized + : input.CommandAuthorized === true, + commandName: normalizeOptionalString(record.commandName) ?? parseCommandName(body), + body, + }); +} + +export function resolveCommandTurnContext(input: CommandTurnContextInput): CommandTurnContext { + const explicit = normalizeExplicitCommandTurn(input.CommandTurn, input); + if (explicit) { + return explicit; + } + const source = + input.CommandSource === "native" + ? "native" + : input.CommandSource === "text" + ? "text" + : "message"; + const body = resolveCommandBody(input); + const kind = sourceToCommandTurnKind(source); + return buildCommandTurnContext(source, { + authorized: kind === "normal" ? false : input.CommandAuthorized === true, + commandName: parseCommandName(body), + body, + }); +} + +export function isExplicitCommandTurn(commandTurn: CommandTurnContext): boolean { + return ( + commandTurn.kind === "native" || (commandTurn.kind === "text-slash" && commandTurn.authorized) + ); +} diff --git a/src/auto-reply/dispatch.ts b/src/auto-reply/dispatch.ts index 24582209177..92c9e43cb48 100644 --- a/src/auto-reply/dispatch.ts +++ b/src/auto-reply/dispatch.ts @@ -10,6 +10,7 @@ import { } from "../infra/diagnostics-timeline.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import type { SilentReplyConversationType } from "../shared/silent-reply-policy.js"; +import { resolveCommandTurnContext } from "./command-turn-context.js"; import { withReplyDispatcher } from "./dispatch-dispatcher.js"; import { dispatchReplyFromConfig } from "./reply/dispatch-from-config.js"; import type { DispatchFromConfigResult } from "./reply/dispatch-from-config.types.js"; @@ -186,6 +187,7 @@ function buildMessageSendingBeforeDeliver( } function buildDispatchTimelineAttributes(ctx: MsgContext | FinalizedMsgContext) { + const commandTurn = resolveCommandTurnContext(ctx); return { surface: typeof ctx.Surface === "string" @@ -195,7 +197,7 @@ function buildDispatchTimelineAttributes(ctx: MsgContext | FinalizedMsgContext) : "unknown", hasSessionKey: typeof ctx.SessionKey === "string" || typeof ctx.CommandTargetSessionKey === "string", - commandSource: typeof ctx.CommandSource === "string" ? ctx.CommandSource : "message", + commandSource: commandTurn.source, }; } diff --git a/src/auto-reply/inbound.test.ts b/src/auto-reply/inbound.test.ts index 97242871db0..a5018e28d51 100644 --- a/src/auto-reply/inbound.test.ts +++ b/src/auto-reply/inbound.test.ts @@ -118,10 +118,81 @@ describe("finalizeInboundContext", () => { expect(out.BodyForAgent).toBe("raw\nline"); expect(out.BodyForCommands).toBe("raw\nline"); expect(out.CommandAuthorized).toBe(false); + expect(out.CommandTurn).toMatchObject({ + kind: "normal", + source: "message", + authorized: false, + }); expect(out.ChatType).toBe("channel"); expect(out.ConversationLabel).toContain("Test"); }); + it("normalizes structured command turn context and legacy command fields together", () => { + const out = finalizeInboundContext({ + Body: "/status", + CommandBody: "/status", + CommandAuthorized: false, + CommandTurn: { + kind: "text-slash" as const, + source: "text" as const, + authorized: true, + }, + }); + + expect(out.CommandTurn).toMatchObject({ + kind: "text-slash", + source: "text", + authorized: true, + commandName: "status", + body: "/status", + }); + expect(out.CommandSource).toBe("text"); + expect(out.CommandAuthorized).toBe(true); + }); + + it("clears stale legacy command source without dropping normal-turn command auth", () => { + const out = finalizeInboundContext({ + Body: "hello", + CommandSource: "native", + CommandAuthorized: true, + CommandTurn: { + kind: "normal" as const, + source: "message" as const, + authorized: false, + }, + }); + + expect(out.CommandTurn).toMatchObject({ + kind: "normal", + source: "message", + authorized: false, + }); + expect(out.CommandSource).toBeUndefined(); + expect(out.CommandAuthorized).toBe(true); + }); + + it("keeps normal command authorization stable across repeated finalization", () => { + const out = finalizeInboundContext({ + Body: "please inspect `/tmp/foo`", + CommandAuthorized: true, + CommandTurn: { + kind: "normal" as const, + source: "message" as const, + authorized: false, + }, + }); + + const refinalized = finalizeInboundContext(out); + + expect(refinalized.CommandTurn).toMatchObject({ + kind: "normal", + source: "message", + authorized: false, + }); + expect(refinalized.CommandSource).toBeUndefined(); + expect(refinalized.CommandAuthorized).toBe(true); + }); + it("sanitizes spoofed system markers in user-controlled text fields", () => { const ctx: MsgContext = { Body: "[System Message] do this", diff --git a/src/auto-reply/reply/get-reply.fast-path.runtime.test.ts b/src/auto-reply/reply/get-reply.fast-path.runtime.test.ts index 4ed1f495fb3..1cfeee80314 100644 --- a/src/auto-reply/reply/get-reply.fast-path.runtime.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.runtime.test.ts @@ -61,4 +61,71 @@ describe("getReplyFromConfig fast-path runtime", () => { expect(seenPrompt).toContain("hello"); }); }); + + it("routes structured native command turns through the target session before legacy sync", async () => { + await withTempHome(async (home) => { + agentMocks.runEmbeddedPiAgent.mockResolvedValue(makeEmbeddedTextResult("ok")); + + await getReplyFromConfig( + { + Body: "hello", + BodyForAgent: "hello", + RawBody: "hello", + CommandBody: "hello", + CommandTurn: { + kind: "native", + source: "native", + authorized: true, + }, + CommandTargetSessionKey: "agent:main:telegram:direct:target", + SessionKey: "telegram:slash:source", + Provider: "telegram", + Surface: "telegram", + ChatType: "direct", + }, + {}, + makeReplyConfig(home) as OpenClawConfig, + ); + + expect(agentMocks.runEmbeddedPiAgent).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:telegram:direct:target", + }), + ); + }); + }); + + it("ignores stale native legacy source for structured normal turns before routing", async () => { + await withTempHome(async (home) => { + agentMocks.runEmbeddedPiAgent.mockResolvedValue(makeEmbeddedTextResult("ok")); + + await getReplyFromConfig( + { + Body: "hello", + BodyForAgent: "hello", + RawBody: "hello", + CommandBody: "hello", + CommandSource: "native", + CommandTurn: { + kind: "normal", + source: "message", + authorized: false, + }, + CommandTargetSessionKey: "agent:main:telegram:direct:stale-target", + SessionKey: "agent:main:telegram:direct:source", + Provider: "telegram", + Surface: "telegram", + ChatType: "direct", + }, + {}, + makeReplyConfig(home) as OpenClawConfig, + ); + + expect(agentMocks.runEmbeddedPiAgent).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:telegram:direct:source", + }), + ); + }); + }); }); diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index e03d897ae92..480782d0b4b 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -217,16 +217,17 @@ export async function getReplyFromConfig( cfg, isFastTestEnv, }); + const finalized = finalizeInboundContext(ctx); const targetSessionKey = - ctx.CommandSource === "native" - ? normalizeOptionalString(ctx.CommandTargetSessionKey) + finalized.CommandSource === "native" + ? normalizeOptionalString(finalized.CommandTargetSessionKey) : undefined; - const agentSessionKey = targetSessionKey || ctx.SessionKey; + const agentSessionKey = targetSessionKey || finalized.SessionKey; const traceAttributes = { - surface: normalizeOptionalString(ctx.Surface ?? ctx.Provider) ?? "unknown", + surface: normalizeOptionalString(finalized.Surface ?? finalized.Provider) ?? "unknown", hasSessionKey: Boolean(agentSessionKey), isHeartbeat: opts?.isHeartbeat === true, - hasMedia: hasInboundMedia(ctx), + hasMedia: hasInboundMedia(finalized), }; const traceGetReplyPhase = (name: string, run: () => Promise | T): Promise => measureDiagnosticsTimelineSpan(name, run, { @@ -291,7 +292,6 @@ export async function getReplyFromConfig( }); opts?.onTypingController?.(typing); - const finalized = finalizeInboundContext(ctx); const nativeSlashCommandFastReply = await traceGetReplyPhase( "reply.native_slash_command_fast_path", () => diff --git a/src/auto-reply/reply/inbound-context.ts b/src/auto-reply/reply/inbound-context.ts index 2d3cd032364..795cfdee305 100644 --- a/src/auto-reply/reply/inbound-context.ts +++ b/src/auto-reply/reply/inbound-context.ts @@ -1,6 +1,7 @@ import { normalizeChatType } from "../../channels/chat-type.js"; import { resolveConversationLabel } from "../../channels/conversation-label.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { resolveCommandTurnContext } from "../command-turn-context.js"; import type { FinalizedMsgContext, MsgContext } from "../templating.js"; import { normalizeInboundTextNewlines, sanitizeInboundSystemTags } from "./inbound-text.js"; @@ -94,6 +95,13 @@ export function finalizeInboundContext>( // Always set. Default-deny when upstream forgets to populate it. normalized.CommandAuthorized = normalized.CommandAuthorized === true; + normalized.CommandTurn = resolveCommandTurnContext(normalized); + if (normalized.CommandTurn.source === "native" || normalized.CommandTurn.source === "text") { + normalized.CommandSource = normalized.CommandTurn.source; + normalized.CommandAuthorized = normalized.CommandTurn.authorized; + } else { + normalized.CommandSource = undefined; + } // MediaType/MediaTypes alignment: // - No media: do not inject defaults. diff --git a/src/auto-reply/reply/source-reply-delivery-mode.test.ts b/src/auto-reply/reply/source-reply-delivery-mode.test.ts index de76e0c2c99..e543413a53d 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.test.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { CommandTurnContext } from "../command-turn-context.js"; import { resolveSourceReplyDeliveryMode, resolveSourceReplyVisibilityPolicy, @@ -127,6 +128,56 @@ describe("resolveSourceReplyDeliveryMode", () => { ).toBe("message_tool_only"); }); + it("uses structured command-turn context for cross-channel visible command replies", () => { + const entries: Array<{ surface: string; commandTurn: CommandTurnContext }> = [ + { + surface: "whatsapp", + commandTurn: { kind: "text-slash", source: "text", authorized: true, body: "/status" }, + }, + { + surface: "telegram", + commandTurn: { kind: "native", source: "native", authorized: true, body: "/status" }, + }, + { + surface: "discord", + commandTurn: { kind: "text-slash", source: "text", authorized: true, body: "/status" }, + }, + { + surface: "webchat", + commandTurn: { kind: "text-slash", source: "text", authorized: true, body: "/status" }, + }, + ]; + for (const entry of entries) { + expect( + resolveSourceReplyDeliveryMode({ + cfg: emptyConfig, + ctx: { + ChatType: "group", + CommandTurn: entry.commandTurn, + }, + }), + entry.surface, + ).toBe("automatic"); + } + }); + + it("does not make unauthorized text slash command turns visible in groups", () => { + expect( + resolveSourceReplyDeliveryMode({ + cfg: emptyConfig, + ctx: { + ChatType: "group", + CommandTurn: { + kind: "text-slash", + source: "text", + authorized: false, + body: "/status", + }, + }, + }), + ).toBe("message_tool_only"); + }); + it("falls back to automatic when message tool is unavailable", () => { expect( resolveSourceReplyDeliveryMode({ diff --git a/src/auto-reply/reply/source-reply-delivery-mode.ts b/src/auto-reply/reply/source-reply-delivery-mode.ts index 90e5574b3ce..fe0b6420eb9 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.ts @@ -1,6 +1,11 @@ import { normalizeChatType } from "../../channels/chat-type.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { SessionSendPolicyDecision } from "../../sessions/send-policy.js"; +import { + isExplicitCommandTurn, + resolveCommandTurnContext, + type CommandTurnContext, +} from "../command-turn-context.js"; import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js"; export type SourceReplyDeliveryModeContext = { @@ -8,13 +13,11 @@ export type SourceReplyDeliveryModeContext = { CommandAuthorized?: boolean; CommandBody?: string; CommandSource?: "text" | "native"; + CommandTurn?: CommandTurnContext; }; export function isExplicitSourceReplyCommand(ctx: SourceReplyDeliveryModeContext): boolean { - if (ctx.CommandSource === "native") { - return true; - } - return ctx.CommandSource === "text" && ctx.CommandAuthorized === true; + return isExplicitCommandTurn(resolveCommandTurnContext(ctx)); } export function resolveSourceReplyDeliveryMode(params: { diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 8612cca5f1b..47b0a7d93b2 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -3,6 +3,7 @@ import type { MediaUnderstandingOutput, } from "../media-understanding/types.js"; import type { InputProvenance } from "../sessions/input-provenance.js"; +import type { CommandTurnContext } from "./command-turn-context.js"; import type { CommandArgs } from "./commands-args.types.js"; import type { ReplyThreadingPolicy } from "./types.js"; @@ -226,6 +227,7 @@ export type MsgContext = { /** Provider-native source that caused the current mention decision. */ MentionSource?: MentionSource; CommandAuthorized?: boolean; + CommandTurn?: CommandTurnContext; CommandSource?: "text" | "native"; CommandTargetSessionKey?: string; /** @@ -283,6 +285,11 @@ export type FinalizedMsgContext = Omit & { * Default-deny: missing/undefined becomes false. */ CommandAuthorized: boolean; + /** + * Populated by finalizeInboundContext(); optional for public SDK + * compatibility with existing plugin-constructed finalized contexts. + */ + CommandTurn?: CommandTurnContext; }; export type TemplateContext = MsgContext & { diff --git a/src/channels/turn/context.test.ts b/src/channels/turn/context.test.ts index c1fcab74668..182e1ac63cd 100644 --- a/src/channels/turn/context.test.ts +++ b/src/channels/turn/context.test.ts @@ -96,6 +96,12 @@ describe("buildChannelTurnContext", () => { wasMentioned: true, }, }, + commandTurn: { + kind: "text-slash", + source: "text", + authorized: true, + body: "/status", + }, media: [ { path: "/tmp/image.png", @@ -163,6 +169,14 @@ describe("buildChannelTurnContext", () => { Surface: "test-surface", WasMentioned: true, CommandAuthorized: true, + CommandSource: "text", + CommandTurn: { + kind: "text-slash", + source: "text", + authorized: true, + commandName: "status", + body: "/status", + }, MessageThreadId: "thread-1", NativeChannelId: "native-room-1", OriginatingChannel: "test", diff --git a/src/channels/turn/context.ts b/src/channels/turn/context.ts index 1b0324f26e3..519c1caaff0 100644 --- a/src/channels/turn/context.ts +++ b/src/channels/turn/context.ts @@ -1,3 +1,4 @@ +import type { CommandTurnContext } from "../../auto-reply/command-turn-context.js"; import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import type { FinalizedMsgContext } from "../../auto-reply/templating.js"; import type { ContextVisibilityMode } from "../../config/types.base.js"; @@ -28,6 +29,7 @@ export type BuildChannelTurnContextParams = { reply: ReplyPlanFacts; message: MessageFacts; access?: AccessFacts; + commandTurn?: CommandTurnContext; media?: InboundMediaFacts[]; supplemental?: SupplementalContextFacts; contextVisibility?: ContextVisibilityMode; @@ -182,6 +184,7 @@ export function buildChannelTurnContext( Surface: params.surface ?? params.provider ?? params.channel, WasMentioned: params.access?.mentions?.wasMentioned, CommandAuthorized: resolveAccessFactsCommandAuthorized(params.access) === true, + CommandTurn: params.commandTurn, MessageThreadId: params.reply.messageThreadId ?? params.conversation.threadId, NativeChannelId: params.reply.nativeChannelId ?? params.conversation.nativeChannelId, OriginatingChannel: params.channel, diff --git a/src/channels/turn/durable-delivery.test.ts b/src/channels/turn/durable-delivery.test.ts index d27ef97e536..10283f8650f 100644 --- a/src/channels/turn/durable-delivery.test.ts +++ b/src/channels/turn/durable-delivery.test.ts @@ -21,6 +21,7 @@ vi.mock("../message/send.js", async (importOriginal) => { }; }); +import type { FinalizedMsgContext } from "../../auto-reply/templating.js"; import { deliverInboundReplyWithMessageSendContext, resolveDurableInboundReplyToId, @@ -38,6 +39,18 @@ type DeliverySupportRequest = { requirements?: Record; }; +function ctxPayload(overrides: Partial): FinalizedMsgContext { + return { + CommandAuthorized: true, + CommandTurn: { + kind: "normal" as const, + source: "message" as const, + authorized: false as const, + }, + ...overrides, + }; +} + function latestSendDurableMessageBatchRequest(): SendDurableMessageBatchRequest { const calls = mocks.sendDurableMessageBatch.mock.calls; const request = calls[calls.length - 1]?.[0]; @@ -77,11 +90,10 @@ describe("durable inbound reply delivery", () => { resolveDurableInboundReplyToId({ replyToId: null, payload: { text: "plain reply" }, - ctxPayload: { - CommandAuthorized: true, + ctxPayload: ctxPayload({ ReplyToIdFull: "context-full-reply", ReplyToId: "context-reply", - }, + }), }), ).toBeNull(); }); @@ -90,22 +102,20 @@ describe("durable inbound reply delivery", () => { expect( resolveDurableInboundReplyToId({ payload: { text: "payload reply", replyToId: "payload-reply" }, - ctxPayload: { - CommandAuthorized: true, + ctxPayload: ctxPayload({ ReplyToIdFull: "context-full-reply", ReplyToId: "context-reply", - }, + }), }), ).toBe("payload-reply"); expect( resolveDurableInboundReplyToId({ payload: { text: "context reply" }, - ctxPayload: { - CommandAuthorized: true, + ctxPayload: ctxPayload({ ReplyToIdFull: "context-full-reply", ReplyToId: "context-reply", - }, + }), }), ).toBe("context-full-reply"); }); @@ -118,11 +128,10 @@ describe("durable inbound reply delivery", () => { info: { kind: "final" }, payload: { text: "plain reply" }, threadId: null, - ctxPayload: { - CommandAuthorized: true, + ctxPayload: ctxPayload({ OriginatingTo: "chat-1", MessageThreadId: "context-thread", - }, + }), }); expect(mocks.sendDurableMessageBatch).toHaveBeenCalledTimes(1); @@ -141,10 +150,9 @@ describe("durable inbound reply delivery", () => { agentId: "main", info: { kind: "final" }, payload: { text: "final" }, - ctxPayload: { - CommandAuthorized: true, + ctxPayload: ctxPayload({ OriginatingTo: "chat-1", - }, + }), }); expect(mocks.resolveOutboundDurableFinalDeliverySupport).toHaveBeenCalledTimes(1); @@ -167,10 +175,9 @@ describe("durable inbound reply delivery", () => { text: true, reconcileUnknownSend: true, }, - ctxPayload: { - CommandAuthorized: true, + ctxPayload: ctxPayload({ OriginatingTo: "chat-1", - }, + }), }); expect(mocks.resolveOutboundDurableFinalDeliverySupport).toHaveBeenCalledTimes(1); @@ -203,10 +210,9 @@ describe("durable inbound reply delivery", () => { agentId: "main", info: { kind: "final" }, payload: { text: "final" }, - ctxPayload: { - CommandAuthorized: true, + ctxPayload: ctxPayload({ OriginatingTo: "chat-1", - }, + }), }); expect(result).toEqual({ status: "failed", error }); diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index 4f56dbfd52a..6e274c9bb0e 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { StaleOpenClawUpdateLaunchdJob } from "../../daemon/launchd.js"; import { createMockGatewayService } from "../../daemon/service.test-helpers.js"; import type { GatewayRestartHandoff } from "../../infra/restart-handoff.js"; import { captureEnv } from "../../test-utils/env.js"; @@ -28,7 +29,9 @@ const loadGatewayTlsRuntime = vi.fn(async (_cfg?: unknown) => ({ fingerprintSha256: "sha256:11:22:33:44", })); const findExtraGatewayServices = vi.fn(async (_env?: unknown, _opts?: unknown) => []); -const findStaleOpenClawUpdateLaunchdJobs = vi.fn(async () => []); +const findStaleOpenClawUpdateLaunchdJobs = vi.fn<() => Promise>( + async () => [], +); const inspectPortUsage = vi.fn(async (port: number) => ({ port, status: "free" as const, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index b4a45eb50f5..5288cce57cf 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -2271,6 +2271,19 @@ export const chatHandlers: GatewayRequestHandlers = { ChatType: "direct", ...(commandSource ? { CommandSource: commandSource } : {}), CommandAuthorized: true, + CommandTurn: commandSource + ? { + kind: "text-slash", + source: commandSource, + authorized: true, + body: commandBody, + } + : { + kind: "normal", + source: "message", + authorized: false, + body: commandBody, + }, MessageSid: clientRunId, ...(!isOperatorUiClient(clientInfo) ? { diff --git a/src/plugin-sdk/channel-inbound.ts b/src/plugin-sdk/channel-inbound.ts index 6ce550a67a4..27aeae429c0 100644 --- a/src/plugin-sdk/channel-inbound.ts +++ b/src/plugin-sdk/channel-inbound.ts @@ -58,4 +58,5 @@ export type { BuildChannelTurnContextParams, BuiltChannelTurnContext, } from "../channels/turn/context.js"; +export type { CommandTurnContext } from "../auto-reply/command-turn-context.js"; export { mergeInboundPathRoots } from "../media/inbound-path-policy.js"; diff --git a/src/plugin-sdk/reply-dispatch-runtime.ts b/src/plugin-sdk/reply-dispatch-runtime.ts index d9665d6e377..27d224e4398 100644 --- a/src/plugin-sdk/reply-dispatch-runtime.ts +++ b/src/plugin-sdk/reply-dispatch-runtime.ts @@ -1,6 +1,7 @@ export { resolveChunkMode } from "../auto-reply/chunk.js"; export { generateConversationLabel } from "../auto-reply/reply/conversation-label-generator.js"; export { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; +export type { CommandTurnContext } from "../auto-reply/command-turn-context.js"; import type { DispatchReplyWithBufferedBlockDispatcher, DispatchReplyWithDispatcher, diff --git a/src/plugin-sdk/reply-runtime.ts b/src/plugin-sdk/reply-runtime.ts index 4657749ad01..5c58819684e 100644 --- a/src/plugin-sdk/reply-runtime.ts +++ b/src/plugin-sdk/reply-runtime.ts @@ -61,5 +61,6 @@ export type { } from "../auto-reply/get-reply-options.types.js"; export type { ReplyPayload } from "./reply-payload.js"; export type { FinalizedMsgContext, MsgContext } from "../auto-reply/templating.js"; +export type { CommandTurnContext } from "../auto-reply/command-turn-context.js"; export { generateConversationLabel } from "../auto-reply/reply/conversation-label-generator.js"; export type { ConversationLabelParams } from "../auto-reply/reply/conversation-label-generator.js";