Skip to content

Commit 5113fbf

Browse files
authored
fix(slack): truncate served arg-menu option labels on a surrogate boundary (#97923)
1 parent 99bce5f commit 5113fbf

2 files changed

Lines changed: 47 additions & 5 deletions

File tree

extensions/slack/src/monitor/slash.test.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -215,10 +215,15 @@ vi.mock("./slash-commands.runtime.js", () => {
215215
if (params.command?.key === "reportexternal") {
216216
return {
217217
arg: { name: "period", description: "period" },
218-
choices: Array.from({ length: 140 }, (_v, i) => ({
219-
value: `period-${i + 1}`,
220-
label: `Period ${i + 1}`,
221-
})),
218+
choices: [
219+
...Array.from({ length: 140 }, (_v, i) => ({
220+
value: `period-${i + 1}`,
221+
label: `Period ${i + 1}`,
222+
})),
223+
// Label whose emoji surrogate pair straddles the 75-char plain_text
224+
// limit, to cover surrogate-safe truncation in served options.
225+
{ value: "emoji-overflow", label: `${"a".repeat(74)}😀 emojioverflow` },
226+
],
222227
};
223228
}
224229
if (params.command?.key === "unsafeconfirm") {
@@ -872,6 +877,37 @@ describe("Slack native command argument menus", () => {
872877
expect(optionTexts.join("\n")).toContain("Period 12");
873878
});
874879

880+
it("truncates served option labels on a surrogate boundary", async () => {
881+
const { blockId } = await runCommandAndResolveActionsBlock(reportExternalHandler);
882+
expect(blockId).toContain("openclaw_cmdarg_ext:");
883+
884+
const ackOptions = vi.fn().mockResolvedValue(undefined);
885+
await argMenuOptionsHandler({
886+
ack: ackOptions,
887+
body: {
888+
user: { id: "U1" },
889+
value: "emojioverflow",
890+
actions: [{ block_id: blockId }],
891+
},
892+
});
893+
894+
const optionsPayload = firstCallPayload(ackOptions, "options ack") as {
895+
options?: Array<{ text?: { text?: string }; value?: string }>;
896+
};
897+
// The "emojioverflow" query matches only the long emoji label, so exactly one
898+
// option is served.
899+
const served = optionsPayload.options ?? [];
900+
expect(served).toHaveLength(1);
901+
const text = served[0]?.text?.text ?? "";
902+
// Plain_text option labels are capped at 75 chars and must not end on a lone
903+
// surrogate half, which Slack rejects. The label was long enough to truncate.
904+
expect(text.length).toBeGreaterThan(0);
905+
expect(text.length).toBeLessThanOrEqual(75);
906+
expect(
907+
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(text),
908+
).toBe(false);
909+
});
910+
875911
it("tracks accepted external_select option requests", async () => {
876912
const trackingHarness = createArgMenusHarness();
877913
const trackEvent = vi.fn();

extensions/slack/src/monitor/slash.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -931,7 +931,13 @@ export async function registerSlackMonitorSlashCommands(params: {
931931
.filter((choice) => !query || normalizeLowercaseStringOrEmpty(choice.label).includes(query))
932932
.slice(0, SLACK_COMMAND_ARG_SELECT_OPTIONS_MAX)
933933
.map((choice) => ({
934-
text: { type: "plain_text", text: choice.label.slice(0, 75) },
934+
// Surrogate-safe cap (matches the static-select path above) so an emoji
935+
// straddling the 75-char Slack plain_text limit is dropped whole rather
936+
// than serialized as a lone `\uD83D` half that Slack rejects.
937+
text: {
938+
type: "plain_text",
939+
text: truncateSlackText(choice.label, SLACK_COMMAND_ARG_SELECT_OPTION_TEXT_MAX),
940+
},
935941
value: choice.value,
936942
}));
937943
await ack({ options });

0 commit comments

Comments
 (0)