Files
openclaw/src/auto-reply/templating.ts
Vincent Koc 6c39616ecd Fix Control UI duplicate iMessage replies for internal webchat turns (#36151)
* Auto-reply: avoid routing external replies from internal webchat turns

* Auto-reply tests: cover internal webchat non-routing with external origin metadata

* Changelog: add Control UI iMessage duplicate-reply fix note

* Auto-reply context: track explicit deliver routes

* Gateway chat: mark explicit external deliver routes in context

* Auto-reply: preserve explicit deliver routes for internal webchat turns

* Auto-reply tests: cover explicit deliver routes from internal webchat turns

* Gateway chat tests: assert explicit deliver route context tagging
2026-03-06 00:47:57 -05:00

238 lines
7.9 KiB
TypeScript

import type { ChannelId } from "../channels/plugins/types.js";
import type {
MediaUnderstandingDecision,
MediaUnderstandingOutput,
} from "../media-understanding/types.js";
import type { StickerMetadata } from "../telegram/bot/types.js";
import type { InternalMessageChannel } from "../utils/message-channel.js";
import type { CommandArgs } from "./commands-registry.types.js";
/** Valid message channels for routing. */
export type OriginatingChannelType = ChannelId | InternalMessageChannel;
export type MsgContext = {
Body?: string;
/**
* Agent prompt body (may include envelope/history/context). Prefer this for prompt shaping.
* Should use real newlines (`\n`), not escaped `\\n`.
*/
BodyForAgent?: string;
/**
* Recent chat history for context (untrusted user content). Prefer passing this
* as structured context blocks in the user prompt rather than rendering plaintext envelopes.
*/
InboundHistory?: Array<{
sender: string;
body: string;
timestamp?: number;
}>;
/**
* Raw message body without structural context (history, sender labels).
* Legacy alias for CommandBody. Falls back to Body if not set.
*/
RawBody?: string;
/**
* Prefer for command detection; RawBody is treated as legacy alias.
*/
CommandBody?: string;
/**
* Command parsing body. Prefer this over CommandBody/RawBody when set.
* Should be the "clean" text (no history/sender context).
*/
BodyForCommands?: string;
CommandArgs?: CommandArgs;
From?: string;
To?: string;
SessionKey?: string;
/** Provider account id (multi-account). */
AccountId?: string;
ParentSessionKey?: string;
MessageSid?: string;
/** Provider-specific full message id when MessageSid is a shortened alias. */
MessageSidFull?: string;
MessageSids?: string[];
MessageSidFirst?: string;
MessageSidLast?: string;
ReplyToId?: string;
/**
* Root message id for thread reconstruction (used by Feishu for root_id).
* When a message is part of a thread, this is the id of the first message.
*/
RootMessageId?: string;
/** Provider-specific full reply-to id when ReplyToId is a shortened alias. */
ReplyToIdFull?: string;
ReplyToBody?: string;
ReplyToSender?: string;
ReplyToIsQuote?: boolean;
/** Forward origin from the reply target (when reply_to_message is a forwarded message). */
ReplyToForwardedFrom?: string;
ReplyToForwardedFromType?: string;
ReplyToForwardedFromId?: string;
ReplyToForwardedFromUsername?: string;
ReplyToForwardedFromTitle?: string;
ReplyToForwardedDate?: number;
ForwardedFrom?: string;
ForwardedFromType?: string;
ForwardedFromId?: string;
ForwardedFromUsername?: string;
ForwardedFromTitle?: string;
ForwardedFromSignature?: string;
ForwardedFromChatType?: string;
ForwardedFromMessageId?: number;
ForwardedDate?: number;
ThreadStarterBody?: string;
/** Full thread history when starting a new thread session. */
ThreadHistoryBody?: string;
IsFirstThreadTurn?: boolean;
ThreadLabel?: string;
MediaPath?: string;
MediaUrl?: string;
MediaType?: string;
MediaDir?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
/** Telegram sticker metadata (emoji, set name, file IDs, cached description). */
Sticker?: StickerMetadata;
/** True when current-turn sticker media is present in MediaPaths (false for cached-description path). */
StickerMediaIncluded?: boolean;
OutputDir?: string;
OutputBase?: string;
/** Remote host for SCP when media lives on a different machine (e.g., openclaw@192.168.64.3). */
MediaRemoteHost?: string;
Transcript?: string;
MediaUnderstanding?: MediaUnderstandingOutput[];
MediaUnderstandingDecisions?: MediaUnderstandingDecision[];
LinkUnderstanding?: string[];
Prompt?: string;
MaxChars?: number;
ChatType?: string;
/** Human label for envelope headers (conversation label, not sender). */
ConversationLabel?: string;
GroupSubject?: string;
/** Human label for channel-like group conversations (e.g. #general, #support). */
GroupChannel?: string;
GroupSpace?: string;
GroupMembers?: string;
GroupSystemPrompt?: string;
/** Untrusted metadata that must not be treated as system instructions. */
UntrustedContext?: string[];
/** Explicit owner allowlist overrides (trusted, configuration-derived). */
OwnerAllowFrom?: Array<string | number>;
SenderName?: string;
SenderId?: string;
SenderUsername?: string;
SenderTag?: string;
SenderE164?: string;
Timestamp?: number;
/** Provider label (e.g. whatsapp, telegram). */
Provider?: string;
/** Provider surface label (e.g. discord, slack). Prefer this over `Provider` when available. */
Surface?: string;
WasMentioned?: boolean;
CommandAuthorized?: boolean;
CommandSource?: "text" | "native";
CommandTargetSessionKey?: string;
/**
* Internal flag: command handling prepared trailing prompt text for ACP dispatch.
* Used for `/new <prompt>` and `/reset <prompt>` on ACP-bound sessions.
*/
AcpDispatchTailAfterReset?: boolean;
/** Gateway client scopes when the message originates from the gateway. */
GatewayClientScopes?: string[];
/** Thread identifier (Telegram topic id or Matrix thread event id). */
MessageThreadId?: string | number;
/** Platform-native channel/conversation id (e.g. Slack DM channel "D…" id). */
NativeChannelId?: string;
/** Telegram forum supergroup marker. */
IsForum?: boolean;
/** Warning: DM has topics enabled but this message is not in a topic. */
TopicRequiredButMissing?: boolean;
/**
* Originating channel for reply routing.
* When set, replies should be routed back to this provider
* instead of using lastChannel from the session.
*/
OriginatingChannel?: OriginatingChannelType;
/**
* Originating destination for reply routing.
* The chat/channel/user ID where the reply should be sent.
*/
OriginatingTo?: string;
/**
* True when the current turn intentionally requested external delivery to
* OriginatingChannel/OriginatingTo, rather than inheriting stale session route metadata.
*/
ExplicitDeliverRoute?: boolean;
/**
* Provider-specific parent conversation id for threaded contexts.
* For Discord threads, this is the parent channel id.
*/
ThreadParentId?: string;
/**
* Messages from hooks to be included in the response.
* Used for hook confirmation messages like "Session context saved to memory".
*/
HookMessages?: string[];
};
export type FinalizedMsgContext = Omit<MsgContext, "CommandAuthorized"> & {
/**
* Always set by finalizeInboundContext().
* Default-deny: missing/undefined becomes false.
*/
CommandAuthorized: boolean;
};
export type TemplateContext = MsgContext & {
BodyStripped?: string;
SessionId?: string;
IsNewSession?: string;
};
function formatTemplateValue(value: unknown): string {
if (value == null) {
return "";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
if (typeof value === "symbol" || typeof value === "function") {
return value.toString();
}
if (Array.isArray(value)) {
return value
.flatMap((entry) => {
if (entry == null) {
return [];
}
if (typeof entry === "string") {
return [entry];
}
if (typeof entry === "number" || typeof entry === "boolean" || typeof entry === "bigint") {
return [String(entry)];
}
return [];
})
.join(",");
}
if (typeof value === "object") {
return "";
}
return "";
}
// Simple {{Placeholder}} interpolation using inbound message context.
export function applyTemplate(str: string | undefined, ctx: TemplateContext) {
if (!str) {
return "";
}
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
const value = ctx[key as keyof TemplateContext];
return formatTemplateValue(value);
});
}