-
-
Notifications
You must be signed in to change notification settings - Fork 80.8k
Expand file tree
/
Copy pathnormalize-reply.ts
More file actions
142 lines (131 loc) · 4.56 KB
/
Copy pathnormalize-reply.ts
File metadata and controls
142 lines (131 loc) · 4.56 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
// Normalizes raw agent output into sendable reply text and metadata.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js";
import { hasReplyPayloadContent } from "../../interactive/payload.js";
import { stripHeartbeatToken } from "../heartbeat.js";
import { copyReplyPayloadMetadata } from "../reply-payload.js";
import {
HEARTBEAT_TOKEN,
isInternalFormattingArtifact,
isSilentReplyPayloadText,
isSilentReplyText,
SILENT_REPLY_TOKEN,
startsWithSilentToken,
stripLeadingSilentToken,
stripSilentToken,
} from "../tokens.js";
import type { ReplyPayload } from "../types.js";
import {
resolveResponsePrefixTemplate,
type ResponsePrefixContext,
} from "./response-prefix-template.js";
export type NormalizeReplySkipReason = "empty" | "silent" | "heartbeat";
export type NormalizeReplyOptions = {
responsePrefix?: string;
applyChannelTransforms?: boolean;
/** Context for template variable interpolation in responsePrefix */
responsePrefixContext?: ResponsePrefixContext;
onHeartbeatStrip?: () => void;
stripHeartbeat?: boolean;
silentToken?: string;
transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null;
onSkip?: (reason: NormalizeReplySkipReason) => void;
};
export function normalizeReplyPayload(
payload: ReplyPayload,
opts: NormalizeReplyOptions = {},
): ReplyPayload | null {
const applyChannelTransforms = opts.applyChannelTransforms ?? true;
const hasContent = (text: string | undefined) =>
hasReplyPayloadContent(
{
...payload,
text,
},
{
trimText: true,
},
);
const trimmed = normalizeOptionalString(payload.text) ?? "";
if (!hasContent(trimmed)) {
opts.onSkip?.("empty");
return null;
}
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
let text = payload.text ?? undefined;
if (text && isSilentReplyPayloadText(text, silentToken)) {
if (!hasContent("")) {
opts.onSkip?.("silent");
return null;
}
text = "";
}
// Strip NO_REPLY from mixed-content messages (e.g. "😄 NO_REPLY") so the
// token never leaks to end users. If stripping leaves nothing, treat it as
// silent just like the exact-match path above. (#30916, #30955)
if (text && !isSilentReplyText(text, silentToken)) {
const hasLeadingSilentToken = startsWithSilentToken(text, silentToken);
if (hasLeadingSilentToken) {
text = stripLeadingSilentToken(text, silentToken);
}
if (hasLeadingSilentToken || text.toLowerCase().includes(silentToken.toLowerCase())) {
text = stripSilentToken(text, silentToken);
if (!hasContent(text)) {
opts.onSkip?.("silent");
return null;
}
}
}
if (text && !trimmed) {
// Keep empty text when media exists so media-only replies still send.
text = "";
}
const shouldStripHeartbeat = opts.stripHeartbeat ?? true;
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip) {
opts.onHeartbeatStrip?.();
}
if (stripped.shouldSkip && !hasContent(stripped.text)) {
opts.onSkip?.("heartbeat");
return null;
}
text = stripped.text;
}
if (text && isInternalFormattingArtifact(text) && !hasContent("")) {
opts.onSkip?.("silent");
return null;
}
if (text) {
text = sanitizeUserFacingText(text, { errorContext: Boolean(payload.isError) });
}
if (!hasContent(text)) {
opts.onSkip?.("empty");
return null;
}
let enrichedPayload: ReplyPayload = copyReplyPayloadMetadata(payload, { ...payload, text });
if (applyChannelTransforms && opts.transformReplyPayload) {
const transformedPayload = opts.transformReplyPayload(enrichedPayload);
if (transformedPayload === null) {
return null;
}
enrichedPayload = transformedPayload
? copyReplyPayloadMetadata(enrichedPayload, transformedPayload)
: enrichedPayload;
text = enrichedPayload.text;
}
// Resolve template variables in responsePrefix if context is provided
const effectivePrefix = opts.responsePrefixContext
? resolveResponsePrefixTemplate(opts.responsePrefix, opts.responsePrefixContext)
: opts.responsePrefix;
if (
effectivePrefix &&
text &&
text.trim() !== HEARTBEAT_TOKEN &&
!text.startsWith(effectivePrefix)
) {
text = `${effectivePrefix} ${text}`;
}
enrichedPayload = copyReplyPayloadMetadata(enrichedPayload, { ...enrichedPayload, text });
return enrichedPayload;
}