Skip to content

Commit b24820b

Browse files
committed
fix(qqbot): truncate reminder job name on code-point boundary
generateJobName truncated reminder content with String.slice(0, 20) on a UTF-16 code-unit index, so an astral character (e.g. an emoji) landing on the boundary was cut into a lone surrogate, producing a malformed cron job name. Truncate with the shared truncateUtf16Safe helper so a surrogate pair is never split, keeping the existing 20-unit budget and ellipsis suffix. Adds a test asserting the truncated job name contains no lone surrogate.
1 parent c6f5725 commit b24820b

2 files changed

Lines changed: 41 additions & 6 deletions

File tree

extensions/qqbot/src/engine/tools/remind-logic.test.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,45 @@ describe("engine/tools/remind-logic", () => {
9797
expect(generateJobName("drink water")).toBe("Reminder: drink water");
9898
});
9999

100-
it("truncates long content", () => {
101-
const long = "a very long reminder content that exceeds twenty characters";
102-
const name = generateJobName(long);
103-
expect(name.length).toBeLessThan(40);
104-
expect(name).toContain("…");
100+
it("truncates long content to a 20 UTF-16-unit budget with an ellipsis", () => {
101+
expect(generateJobName("a very long reminder content")).toBe(
102+
"Reminder: a very long reminder…",
103+
);
104+
});
105+
106+
it("keeps an exactly-fitting all-emoji content unchanged", () => {
107+
// 10 emoji = 20 UTF-16 units, exactly at the budget, so no truncation.
108+
expect(generateJobName("😀".repeat(10))).toBe(`Reminder: ${"😀".repeat(10)}`);
109+
});
110+
111+
it("does not split surrogate pairs when truncating", () => {
112+
const hasLoneSurrogate = (value: string): boolean => {
113+
for (let index = 0; index < value.length; index++) {
114+
const code = value.charCodeAt(index);
115+
if (code >= 0xd800 && code <= 0xdbff) {
116+
const next = value.charCodeAt(index + 1);
117+
if (!(next >= 0xdc00 && next <= 0xdfff)) {
118+
return true;
119+
}
120+
index++;
121+
} else if (code >= 0xdc00 && code <= 0xdfff) {
122+
return true;
123+
}
124+
}
125+
return false;
126+
};
127+
128+
// 11 emoji = 22 UTF-16 units > 20; the 11th emoji straddles the cap and is
129+
// dropped whole rather than split into a lone surrogate.
130+
const allEmoji = generateJobName("😀".repeat(11));
131+
expect(allEmoji).toBe(`Reminder: ${"😀".repeat(10)}…`);
132+
expect(hasLoneSurrogate(allEmoji)).toBe(false);
133+
134+
// 19 ASCII + emoji: the emoji's high surrogate would land at unit 20, so the
135+
// whole pair is dropped to stay within the 20-unit budget.
136+
const name = generateJobName(`${"x".repeat(19)}😀tail`);
137+
expect(name).toBe(`Reminder: ${"x".repeat(19)}…`);
138+
expect(hasLoneSurrogate(name)).toBe(false);
105139
});
106140
});
107141

extensions/qqbot/src/engine/tools/remind-logic.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Qqbot plugin module implements remind logic behavior.
22
import { resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
3+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
34

45
/**
56
* QQBot reminder tool core logic.
@@ -171,7 +172,7 @@ export function isCronExpression(timeStr: string): boolean {
171172
*/
172173
export function generateJobName(content: string): string {
173174
const trimmed = content.trim();
174-
const short = trimmed.length > 20 ? `${trimmed.slice(0, 20)}…` : trimmed;
175+
const short = trimmed.length > 20 ? `${truncateUtf16Safe(trimmed, 20)}…` : trimmed;
175176
return `Reminder: ${short}`;
176177
}
177178

0 commit comments

Comments
 (0)