mirror of https://github.com/openclaw/openclaw.git
293 lines
9.7 KiB
TypeScript
293 lines
9.7 KiB
TypeScript
import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js";
|
|
import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js";
|
|
import { normalizeChannelId } from "../../channels/plugins/index.js";
|
|
import { parseExplicitTargetForChannel } from "../../channels/plugins/target-parsing.js";
|
|
import type { ReplyToMode } from "../../config/types.js";
|
|
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
|
|
import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js";
|
|
import { normalizeOptionalAccountId } from "../../routing/account-id.js";
|
|
import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js";
|
|
import type { OriginatingChannelType } from "../templating.js";
|
|
import type { ReplyPayload } from "../types.js";
|
|
import { extractReplyToTag } from "./reply-tags.js";
|
|
import { createReplyToModeFilterForChannel } from "./reply-threading.js";
|
|
|
|
export function formatBtwTextForExternalDelivery(payload: ReplyPayload): string | undefined {
|
|
const text = payload.text?.trim();
|
|
if (!text) {
|
|
return payload.text;
|
|
}
|
|
const question = payload.btw?.question?.trim();
|
|
if (!question) {
|
|
return payload.text;
|
|
}
|
|
const formatted = `BTW\nQuestion: ${question}\n\n${text}`;
|
|
return text === formatted || text.startsWith("BTW\nQuestion:") ? text : formatted;
|
|
}
|
|
|
|
function resolveReplyThreadingForPayload(params: {
|
|
payload: ReplyPayload;
|
|
implicitReplyToId?: string;
|
|
currentMessageId?: string;
|
|
}): ReplyPayload {
|
|
const implicitReplyToId = params.implicitReplyToId?.trim() || undefined;
|
|
const currentMessageId = params.currentMessageId?.trim() || undefined;
|
|
|
|
// 1) Apply implicit reply threading first (replyToMode will strip later if needed).
|
|
let resolved: ReplyPayload =
|
|
params.payload.replyToId || params.payload.replyToCurrent === false || !implicitReplyToId
|
|
? params.payload
|
|
: { ...params.payload, replyToId: implicitReplyToId };
|
|
|
|
// 2) Parse explicit reply tags from text (if present) and clean them.
|
|
if (typeof resolved.text === "string" && resolved.text.includes("[[")) {
|
|
const { cleaned, replyToId, replyToCurrent, hasTag } = extractReplyToTag(
|
|
resolved.text,
|
|
currentMessageId,
|
|
);
|
|
resolved = {
|
|
...resolved,
|
|
text: cleaned ? cleaned : undefined,
|
|
replyToId: replyToId ?? resolved.replyToId,
|
|
replyToTag: hasTag || resolved.replyToTag,
|
|
replyToCurrent: replyToCurrent || resolved.replyToCurrent,
|
|
};
|
|
}
|
|
|
|
// 3) If replyToCurrent was set out-of-band (e.g. tags already stripped upstream),
|
|
// ensure replyToId is set to the current message id when available.
|
|
if (resolved.replyToCurrent && !resolved.replyToId && currentMessageId) {
|
|
resolved = {
|
|
...resolved,
|
|
replyToId: currentMessageId,
|
|
};
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
// Backward-compatible helper: apply explicit reply tags/directives to a single payload.
|
|
// This intentionally does not apply implicit threading.
|
|
export function applyReplyTagsToPayload(
|
|
payload: ReplyPayload,
|
|
currentMessageId?: string,
|
|
): ReplyPayload {
|
|
return resolveReplyThreadingForPayload({ payload, currentMessageId });
|
|
}
|
|
|
|
export function isRenderablePayload(payload: ReplyPayload): boolean {
|
|
return hasReplyContent({
|
|
text: payload.text,
|
|
mediaUrl: payload.mediaUrl,
|
|
mediaUrls: payload.mediaUrls,
|
|
interactive: payload.interactive,
|
|
hasChannelData: hasReplyChannelData(payload.channelData),
|
|
extraContent: payload.audioAsVoice,
|
|
});
|
|
}
|
|
|
|
export function shouldSuppressReasoningPayload(payload: ReplyPayload): boolean {
|
|
if (payload.isReasoning === true) {
|
|
return true;
|
|
}
|
|
const text = payload.text;
|
|
if (typeof text !== "string") {
|
|
return false;
|
|
}
|
|
if (text.trimStart().toLowerCase().startsWith("reasoning:")) {
|
|
return true;
|
|
}
|
|
const stripped = stripReasoningTagsFromText(text, { mode: "strict", trim: "both" });
|
|
return !stripped && stripped !== text;
|
|
}
|
|
|
|
export function applyReplyThreading(params: {
|
|
payloads: ReplyPayload[];
|
|
replyToMode: ReplyToMode;
|
|
replyToChannel?: OriginatingChannelType;
|
|
currentMessageId?: string;
|
|
}): ReplyPayload[] {
|
|
const { payloads, replyToMode, replyToChannel, currentMessageId } = params;
|
|
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
|
|
const implicitReplyToId = currentMessageId?.trim() || undefined;
|
|
return payloads
|
|
.map((payload) =>
|
|
resolveReplyThreadingForPayload({ payload, implicitReplyToId, currentMessageId }),
|
|
)
|
|
.filter(isRenderablePayload)
|
|
.map(applyReplyToMode);
|
|
}
|
|
|
|
export function filterMessagingToolDuplicates(params: {
|
|
payloads: ReplyPayload[];
|
|
sentTexts: string[];
|
|
}): ReplyPayload[] {
|
|
const { payloads, sentTexts } = params;
|
|
if (sentTexts.length === 0) {
|
|
return payloads;
|
|
}
|
|
return payloads.filter((payload) => !isMessagingToolDuplicate(payload.text ?? "", sentTexts));
|
|
}
|
|
|
|
export function filterMessagingToolMediaDuplicates(params: {
|
|
payloads: ReplyPayload[];
|
|
sentMediaUrls: string[];
|
|
}): ReplyPayload[] {
|
|
const normalizeMediaForDedupe = (value: string): string => {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return "";
|
|
}
|
|
if (!trimmed.toLowerCase().startsWith("file://")) {
|
|
return trimmed;
|
|
}
|
|
try {
|
|
const parsed = new URL(trimmed);
|
|
if (parsed.protocol === "file:") {
|
|
return decodeURIComponent(parsed.pathname || "");
|
|
}
|
|
} catch {
|
|
// Keep fallback below for non-URL-like inputs.
|
|
}
|
|
return trimmed.replace(/^file:\/\//i, "");
|
|
};
|
|
|
|
const { payloads, sentMediaUrls } = params;
|
|
if (sentMediaUrls.length === 0) {
|
|
return payloads;
|
|
}
|
|
const sentSet = new Set(sentMediaUrls.map(normalizeMediaForDedupe).filter(Boolean));
|
|
return payloads.map((payload) => {
|
|
const mediaUrl = payload.mediaUrl;
|
|
const mediaUrls = payload.mediaUrls;
|
|
const stripSingle = mediaUrl && sentSet.has(normalizeMediaForDedupe(mediaUrl));
|
|
const filteredUrls = mediaUrls?.filter((u) => !sentSet.has(normalizeMediaForDedupe(u)));
|
|
if (!stripSingle && (!mediaUrls || filteredUrls?.length === mediaUrls.length)) {
|
|
return payload; // No change
|
|
}
|
|
return {
|
|
...payload,
|
|
mediaUrl: stripSingle ? undefined : mediaUrl,
|
|
mediaUrls: filteredUrls?.length ? filteredUrls : undefined,
|
|
};
|
|
});
|
|
}
|
|
|
|
const PROVIDER_ALIAS_MAP: Record<string, string> = {
|
|
lark: "feishu",
|
|
};
|
|
|
|
function normalizeProviderForComparison(value?: string): string | undefined {
|
|
const trimmed = value?.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
const lowered = trimmed.toLowerCase();
|
|
const normalizedChannel = normalizeChannelId(trimmed);
|
|
if (normalizedChannel) {
|
|
return normalizedChannel;
|
|
}
|
|
return PROVIDER_ALIAS_MAP[lowered] ?? lowered;
|
|
}
|
|
|
|
function normalizeThreadIdForComparison(value?: string): string | undefined {
|
|
const trimmed = value?.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
if (/^-?\d+$/.test(trimmed)) {
|
|
return String(Number.parseInt(trimmed, 10));
|
|
}
|
|
return trimmed.toLowerCase();
|
|
}
|
|
|
|
function resolveTargetProviderForComparison(params: {
|
|
currentProvider: string;
|
|
targetProvider?: string;
|
|
}): string {
|
|
const targetProvider = normalizeProviderForComparison(params.targetProvider);
|
|
if (!targetProvider || targetProvider === "message") {
|
|
return params.currentProvider;
|
|
}
|
|
return targetProvider;
|
|
}
|
|
|
|
function targetsMatchForSuppression(params: {
|
|
provider: string;
|
|
originTarget: string;
|
|
targetKey: string;
|
|
targetThreadId?: string;
|
|
}): boolean {
|
|
if (params.provider !== "telegram") {
|
|
return params.targetKey === params.originTarget;
|
|
}
|
|
|
|
const origin = parseExplicitTargetForChannel("telegram", params.originTarget);
|
|
const target = parseExplicitTargetForChannel("telegram", params.targetKey);
|
|
if (!origin || !target) {
|
|
return params.targetKey === params.originTarget;
|
|
}
|
|
const explicitTargetThreadId = normalizeThreadIdForComparison(params.targetThreadId);
|
|
const targetThreadId =
|
|
explicitTargetThreadId ?? (target.threadId != null ? String(target.threadId) : undefined);
|
|
const originThreadId = origin.threadId != null ? String(origin.threadId) : undefined;
|
|
if (origin.to.trim().toLowerCase() !== target.to.trim().toLowerCase()) {
|
|
return false;
|
|
}
|
|
if (originThreadId && targetThreadId != null) {
|
|
return originThreadId === targetThreadId;
|
|
}
|
|
if (originThreadId && targetThreadId == null) {
|
|
return false;
|
|
}
|
|
if (!originThreadId && targetThreadId != null) {
|
|
return false;
|
|
}
|
|
// chatId already matched and neither side carries thread context.
|
|
return true;
|
|
}
|
|
|
|
export function shouldSuppressMessagingToolReplies(params: {
|
|
messageProvider?: string;
|
|
messagingToolSentTargets?: MessagingToolSend[];
|
|
originatingTo?: string;
|
|
accountId?: string;
|
|
}): boolean {
|
|
const provider = normalizeProviderForComparison(params.messageProvider);
|
|
if (!provider) {
|
|
return false;
|
|
}
|
|
const originTarget = normalizeTargetForProvider(provider, params.originatingTo);
|
|
if (!originTarget) {
|
|
return false;
|
|
}
|
|
const originAccount = normalizeOptionalAccountId(params.accountId);
|
|
const sentTargets = params.messagingToolSentTargets ?? [];
|
|
if (sentTargets.length === 0) {
|
|
return false;
|
|
}
|
|
return sentTargets.some((target) => {
|
|
const targetProvider = resolveTargetProviderForComparison({
|
|
currentProvider: provider,
|
|
targetProvider: target?.provider,
|
|
});
|
|
if (targetProvider !== provider) {
|
|
return false;
|
|
}
|
|
const targetKey = normalizeTargetForProvider(targetProvider, target.to);
|
|
if (!targetKey) {
|
|
return false;
|
|
}
|
|
const targetAccount = normalizeOptionalAccountId(target.accountId);
|
|
if (originAccount && targetAccount && originAccount !== targetAccount) {
|
|
return false;
|
|
}
|
|
return targetsMatchForSuppression({
|
|
provider,
|
|
originTarget,
|
|
targetKey,
|
|
targetThreadId: target.threadId,
|
|
});
|
|
});
|
|
}
|