mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:40:49 +00:00
feat: add rich Slack progress drafts
This commit is contained in:
@@ -129,6 +129,10 @@ export const slackChannelConfigUiHints = {
|
||||
label: "Slack Progress Max Lines",
|
||||
help: "Maximum number of compact progress lines to keep below the draft label (default: 8).",
|
||||
},
|
||||
"streaming.progress.render": {
|
||||
label: "Slack Progress Renderer",
|
||||
help: 'Progress draft renderer: "text" uses one portable text body; "rich" renders structured Slack Block Kit fields with the same text fallback.',
|
||||
},
|
||||
"streaming.progress.toolProgress": {
|
||||
label: "Slack Progress Tool Lines",
|
||||
help: "Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery.",
|
||||
|
||||
@@ -59,6 +59,28 @@ describe("createSlackDraftStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sends and edits rich draft blocks with text fallback", async () => {
|
||||
const { stream, send, edit } = createDraftStreamHarness();
|
||||
const blocks = [{ type: "divider" }] as const;
|
||||
|
||||
stream.update({ text: "fallback", blocks: [...blocks] });
|
||||
await stream.flush();
|
||||
stream.update({ text: "updated fallback", blocks: [...blocks] });
|
||||
await stream.flush();
|
||||
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"fallback",
|
||||
expect.objectContaining({ blocks: [...blocks] }),
|
||||
);
|
||||
expect(edit).toHaveBeenCalledWith(
|
||||
"C123",
|
||||
"111.222",
|
||||
"updated fallback",
|
||||
expect.objectContaining({ blocks: [...blocks] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not send duplicate text", async () => {
|
||||
const { stream, send, edit } = createDraftStreamHarness();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Block, KnownBlock } from "@slack/web-api";
|
||||
import { createDraftStreamLoop } from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
@@ -8,7 +9,7 @@ import { sendMessageSlack } from "./send.js";
|
||||
const DEFAULT_THROTTLE_MS = 1000;
|
||||
|
||||
type SlackDraftStream = {
|
||||
update: (text: string) => void;
|
||||
update: (update: SlackDraftStreamUpdate) => void;
|
||||
flush: () => Promise<void>;
|
||||
clear: () => Promise<void>;
|
||||
discardPending: () => Promise<void>;
|
||||
@@ -19,6 +20,13 @@ type SlackDraftStream = {
|
||||
channelId: () => string | undefined;
|
||||
};
|
||||
|
||||
export type SlackDraftStreamUpdate =
|
||||
| string
|
||||
| {
|
||||
text: string;
|
||||
blocks?: (Block | KnownBlock)[];
|
||||
};
|
||||
|
||||
export function createSlackDraftStream(params: {
|
||||
target: string;
|
||||
cfg: OpenClawConfig;
|
||||
@@ -42,9 +50,13 @@ export function createSlackDraftStream(params: {
|
||||
|
||||
let streamMessageId: string | undefined;
|
||||
let streamChannelId: string | undefined;
|
||||
let lastSentText = "";
|
||||
let lastSentKey = "";
|
||||
let pendingUpdate: SlackDraftStreamUpdate | undefined;
|
||||
let stopped = false;
|
||||
|
||||
const normalizeUpdate = (update: SlackDraftStreamUpdate) =>
|
||||
typeof update === "string" ? { text: update } : update;
|
||||
|
||||
const sendOrEditStreamMessage = async (text: string) => {
|
||||
if (stopped) {
|
||||
return;
|
||||
@@ -58,16 +70,20 @@ export function createSlackDraftStream(params: {
|
||||
params.warn?.(`slack stream preview stopped (text length ${trimmed.length} > ${maxChars})`);
|
||||
return;
|
||||
}
|
||||
if (trimmed === lastSentText) {
|
||||
const update = normalizeUpdate(pendingUpdate ?? text);
|
||||
const blocks = update.text === text ? update.blocks : undefined;
|
||||
const sentKey = `${trimmed}\n${blocks ? JSON.stringify(blocks) : ""}`;
|
||||
if (sentKey === lastSentKey) {
|
||||
return;
|
||||
}
|
||||
lastSentText = trimmed;
|
||||
lastSentKey = sentKey;
|
||||
try {
|
||||
if (streamChannelId && streamMessageId) {
|
||||
await edit(streamChannelId, streamMessageId, trimmed, {
|
||||
cfg: params.cfg,
|
||||
token: params.token,
|
||||
accountId: params.accountId,
|
||||
...(blocks ? { blocks } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -76,6 +92,7 @@ export function createSlackDraftStream(params: {
|
||||
token: params.token,
|
||||
accountId: params.accountId,
|
||||
threadTs: params.resolveThreadTs?.(),
|
||||
...(blocks ? { blocks } : {}),
|
||||
});
|
||||
streamChannelId = sent.channelId || streamChannelId;
|
||||
streamMessageId = sent.messageId || streamMessageId;
|
||||
@@ -112,7 +129,8 @@ export function createSlackDraftStream(params: {
|
||||
const messageId = streamMessageId;
|
||||
streamChannelId = undefined;
|
||||
streamMessageId = undefined;
|
||||
lastSentText = "";
|
||||
lastSentKey = "";
|
||||
pendingUpdate = undefined;
|
||||
if (!channelId || !messageId) {
|
||||
return;
|
||||
}
|
||||
@@ -129,14 +147,19 @@ export function createSlackDraftStream(params: {
|
||||
const forceNewMessage = () => {
|
||||
streamMessageId = undefined;
|
||||
streamChannelId = undefined;
|
||||
lastSentText = "";
|
||||
lastSentKey = "";
|
||||
pendingUpdate = undefined;
|
||||
loop.resetPending();
|
||||
};
|
||||
|
||||
params.log?.(`slack stream preview ready (maxChars=${maxChars}, throttleMs=${throttleMs})`);
|
||||
|
||||
return {
|
||||
update: loop.update,
|
||||
update: (update: SlackDraftStreamUpdate) => {
|
||||
const normalized = normalizeUpdate(update);
|
||||
pendingUpdate = update;
|
||||
loop.update(normalized.text);
|
||||
},
|
||||
flush: loop.flush,
|
||||
clear,
|
||||
discardPending,
|
||||
|
||||
@@ -13,15 +13,18 @@ import {
|
||||
resolveChannelSourceReplyDeliveryMode,
|
||||
} from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
import {
|
||||
buildChannelProgressDraftLine,
|
||||
createChannelProgressDraftGate,
|
||||
formatChannelProgressDraftLine,
|
||||
formatChannelProgressDraftText,
|
||||
isChannelProgressDraftWorkToolName,
|
||||
resolveChannelProgressDraftMaxLines,
|
||||
resolveChannelProgressDraftLabel,
|
||||
resolveChannelProgressDraftRender,
|
||||
resolveChannelStreamingBlockEnabled,
|
||||
resolveChannelStreamingNativeTransport,
|
||||
resolveChannelStreamingPreviewToolProgress,
|
||||
resolveChannelStreamingSuppressDefaultToolProgressMessages,
|
||||
type ChannelProgressDraftLine,
|
||||
} from "openclaw/plugin-sdk/channel-streaming";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
@@ -44,6 +47,7 @@ import {
|
||||
isSlackInteractiveRepliesEnabled,
|
||||
} from "../../interactive-replies.js";
|
||||
import { SLACK_TEXT_LIMIT } from "../../limits.js";
|
||||
import { buildSlackProgressDraftBlocks } from "../../progress-blocks.js";
|
||||
import { recordSlackThreadParticipation } from "../../sent-thread-cache.js";
|
||||
import { applyAppendOnlyStreamUpdate, resolveSlackStreamingConfig } from "../../stream-mode.js";
|
||||
import type { SlackStreamSession } from "../../streaming.js";
|
||||
@@ -882,11 +886,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
previewStreamingEnabled,
|
||||
});
|
||||
let previewToolProgressSuppressed = false;
|
||||
let previewToolProgressLines: string[] = [];
|
||||
let previewToolProgressLines: ChannelProgressDraftLine[] = [];
|
||||
let appendRenderedText = "";
|
||||
let appendSourceText = "";
|
||||
let statusUpdateCount = 0;
|
||||
const progressSeed = `${account.accountId}:${message.channel}`;
|
||||
const useRichProgressDraft =
|
||||
streamMode === "status_final" && resolveChannelProgressDraftRender(account.config) === "rich";
|
||||
|
||||
const renderProgressDraft = () => {
|
||||
if (!draftStream || streamMode !== "status_final") {
|
||||
@@ -896,35 +902,62 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
entry: account.config,
|
||||
lines: previewToolProgressLines,
|
||||
seed: progressSeed,
|
||||
formatLine: escapeSlackMrkdwn,
|
||||
});
|
||||
if (!previewText) {
|
||||
return;
|
||||
}
|
||||
draftStream.update(previewText);
|
||||
draftStream.update(
|
||||
useRichProgressDraft
|
||||
? {
|
||||
text: previewText,
|
||||
blocks: buildSlackProgressDraftBlocks({
|
||||
label: resolveChannelProgressDraftLabel({
|
||||
entry: account.config,
|
||||
seed: progressSeed,
|
||||
}),
|
||||
lines: previewToolProgressLines,
|
||||
}),
|
||||
}
|
||||
: previewText,
|
||||
);
|
||||
hasStreamedMessage = true;
|
||||
};
|
||||
const progressDraftGate = createChannelProgressDraftGate({
|
||||
onStart: renderProgressDraft,
|
||||
});
|
||||
|
||||
const pushPreviewToolProgress = async (line?: string, options?: { toolName?: string }) => {
|
||||
const pushPreviewToolProgress = async (
|
||||
line?: ChannelProgressDraftLine,
|
||||
options?: { toolName?: string },
|
||||
) => {
|
||||
if (!draftStream) {
|
||||
return;
|
||||
}
|
||||
if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
|
||||
return;
|
||||
}
|
||||
const normalized = line?.replace(/\s+/g, " ").trim();
|
||||
const normalized = line?.text.replace(/\s+/g, " ").trim();
|
||||
if (!line || !normalized) {
|
||||
if (streamMode !== "status_final") {
|
||||
return;
|
||||
}
|
||||
const alreadyStarted = progressDraftGate.hasStarted;
|
||||
await progressDraftGate.noteWork();
|
||||
if (alreadyStarted && progressDraftGate.hasStarted) {
|
||||
renderProgressDraft();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (streamMode !== "status_final") {
|
||||
if (!previewToolProgressEnabled || previewToolProgressSuppressed || !normalized) {
|
||||
if (!previewToolProgressEnabled || previewToolProgressSuppressed) {
|
||||
return;
|
||||
}
|
||||
const escaped = escapeSlackMrkdwn(normalized);
|
||||
const previous = previewToolProgressLines.at(-1);
|
||||
if (previous === escaped) {
|
||||
if (previous?.text === normalized) {
|
||||
return;
|
||||
}
|
||||
previewToolProgressLines = [...previewToolProgressLines, escaped].slice(
|
||||
previewToolProgressLines = [...previewToolProgressLines, line].slice(
|
||||
-resolveChannelProgressDraftMaxLines(account.config),
|
||||
);
|
||||
draftStream.update(
|
||||
@@ -932,16 +965,16 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
entry: account.config,
|
||||
lines: previewToolProgressLines,
|
||||
seed: progressSeed,
|
||||
formatLine: escapeSlackMrkdwn,
|
||||
}),
|
||||
);
|
||||
hasStreamedMessage = true;
|
||||
return;
|
||||
}
|
||||
if (previewToolProgressEnabled && !previewToolProgressSuppressed && normalized) {
|
||||
const escaped = escapeSlackMrkdwn(normalized);
|
||||
if (previewToolProgressEnabled && !previewToolProgressSuppressed) {
|
||||
const previous = previewToolProgressLines.at(-1);
|
||||
if (previous !== escaped) {
|
||||
previewToolProgressLines = [...previewToolProgressLines, escaped].slice(
|
||||
if (previous?.text !== normalized) {
|
||||
previewToolProgressLines = [...previewToolProgressLines, line].slice(
|
||||
-resolveChannelProgressDraftMaxLines(account.config),
|
||||
);
|
||||
}
|
||||
@@ -985,16 +1018,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
if (statusUpdateCount > 1 && statusUpdateCount % 4 !== 0) {
|
||||
return;
|
||||
}
|
||||
const previewText = formatChannelProgressDraftText({
|
||||
entry: account.config,
|
||||
lines: previewToolProgressLines,
|
||||
seed: progressSeed,
|
||||
});
|
||||
if (!previewText) {
|
||||
return;
|
||||
}
|
||||
draftStream?.update(previewText);
|
||||
hasStreamedMessage = true;
|
||||
renderProgressDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1084,7 +1108,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
await statusReactions.setTool(payload.name);
|
||||
}
|
||||
await pushPreviewToolProgress(
|
||||
formatChannelProgressDraftLine(
|
||||
buildChannelProgressDraftLine(
|
||||
{
|
||||
event: "tool",
|
||||
name: payload.name,
|
||||
@@ -1098,7 +1122,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
},
|
||||
onItemEvent: async (payload) => {
|
||||
await pushPreviewToolProgress(
|
||||
formatChannelProgressDraftLine({
|
||||
buildChannelProgressDraftLine({
|
||||
event: "item",
|
||||
itemKind: payload.kind,
|
||||
title: payload.title,
|
||||
@@ -1116,7 +1140,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
return;
|
||||
}
|
||||
await pushPreviewToolProgress(
|
||||
formatChannelProgressDraftLine({
|
||||
buildChannelProgressDraftLine({
|
||||
event: "plan",
|
||||
phase: payload.phase,
|
||||
title: payload.title,
|
||||
@@ -1130,7 +1154,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
return;
|
||||
}
|
||||
await pushPreviewToolProgress(
|
||||
formatChannelProgressDraftLine({
|
||||
buildChannelProgressDraftLine({
|
||||
event: "approval",
|
||||
phase: payload.phase,
|
||||
title: payload.title,
|
||||
@@ -1145,7 +1169,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
return;
|
||||
}
|
||||
await pushPreviewToolProgress(
|
||||
formatChannelProgressDraftLine({
|
||||
buildChannelProgressDraftLine({
|
||||
event: "command-output",
|
||||
phase: payload.phase,
|
||||
title: payload.title,
|
||||
@@ -1160,7 +1184,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
return;
|
||||
}
|
||||
await pushPreviewToolProgress(
|
||||
formatChannelProgressDraftLine({
|
||||
buildChannelProgressDraftLine({
|
||||
event: "patch",
|
||||
phase: payload.phase,
|
||||
title: payload.title,
|
||||
|
||||
91
extensions/slack/src/progress-blocks.test.ts
Normal file
91
extensions/slack/src/progress-blocks.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSlackProgressDraftBlocks } from "./progress-blocks.js";
|
||||
|
||||
function progressLine(index: number) {
|
||||
return {
|
||||
kind: "tool" as const,
|
||||
icon: "🛠️",
|
||||
label: `Exec ${index}`,
|
||||
detail: `run ${index}`,
|
||||
text: `🛠️ Exec ${index}: run ${index}`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildSlackProgressDraftBlocks", () => {
|
||||
it("renders structured progress lines as compact Block Kit fields", () => {
|
||||
expect(
|
||||
buildSlackProgressDraftBlocks({
|
||||
label: "Shelling...",
|
||||
lines: [
|
||||
{
|
||||
kind: "tool",
|
||||
icon: "🛠️",
|
||||
label: "Exec",
|
||||
detail: "run tests",
|
||||
text: "🛠️ Exec: run tests",
|
||||
toolName: "exec",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "section",
|
||||
text: { type: "mrkdwn", text: "*Shelling...*" },
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
fields: [
|
||||
{ type: "mrkdwn", text: "🛠️ *Exec*" },
|
||||
{ type: "mrkdwn", text: "run tests" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("compacts long rich details independently from the text fallback", () => {
|
||||
const blocks = buildSlackProgressDraftBlocks({
|
||||
lines: [
|
||||
{
|
||||
kind: "tool",
|
||||
icon: "🛠️",
|
||||
label: "Exec",
|
||||
detail: "run tests in /Users/example/Projects/openclaw/packages/very/deep/path/example",
|
||||
text: "🛠️ Exec: run tests in /Users/example/Projects/openclaw/packages/very/deep/path/example",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(blocks?.[0]).toEqual({
|
||||
type: "section",
|
||||
fields: [
|
||||
{ type: "mrkdwn", text: "🛠️ *Exec*" },
|
||||
{ type: "mrkdwn", text: "run tests in /Users/ex…es/very/deep/path/example" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("caps rich progress blocks to Slack's maximum while leaving caller text fallback independent", () => {
|
||||
const blocksWithLabel = buildSlackProgressDraftBlocks({
|
||||
label: "Shelling...",
|
||||
lines: Array.from({ length: 60 }, (_value, index) => progressLine(index)),
|
||||
});
|
||||
expect(blocksWithLabel).toHaveLength(50);
|
||||
expect(blocksWithLabel?.[0]).toMatchObject({
|
||||
type: "section",
|
||||
text: { text: "*Shelling...*" },
|
||||
});
|
||||
expect(blocksWithLabel?.at(-1)).toMatchObject({
|
||||
type: "section",
|
||||
fields: [{ text: "🛠️ *Exec 48*" }, { text: "run 48" }],
|
||||
});
|
||||
|
||||
const blocksWithoutLabel = buildSlackProgressDraftBlocks({
|
||||
lines: Array.from({ length: 60 }, (_value, index) => progressLine(index)),
|
||||
});
|
||||
expect(blocksWithoutLabel).toHaveLength(50);
|
||||
expect(blocksWithoutLabel?.at(-1)).toMatchObject({
|
||||
type: "section",
|
||||
fields: [{ text: "🛠️ *Exec 49*" }, { text: "run 49" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
65
extensions/slack/src/progress-blocks.ts
Normal file
65
extensions/slack/src/progress-blocks.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { Block, KnownBlock } from "@slack/web-api";
|
||||
import type { ChannelProgressDraftLine } from "openclaw/plugin-sdk/channel-streaming";
|
||||
import { SLACK_MAX_BLOCKS } from "./blocks-input.js";
|
||||
import { escapeSlackMrkdwn } from "./monitor/mrkdwn.js";
|
||||
import { truncateSlackText } from "./truncate.js";
|
||||
|
||||
const SLACK_PROGRESS_FIELD_MAX = 1800;
|
||||
const SLACK_PROGRESS_DETAIL_MAX_CHARS = 48;
|
||||
|
||||
function field(text: string) {
|
||||
return {
|
||||
type: "mrkdwn" as const,
|
||||
text: truncateSlackText(text, SLACK_PROGRESS_FIELD_MAX),
|
||||
};
|
||||
}
|
||||
|
||||
function lineTitle(line: ChannelProgressDraftLine): string {
|
||||
return `${line.icon ?? "•"} *${escapeSlackMrkdwn(line.label)}*`;
|
||||
}
|
||||
|
||||
function compactDetail(value: string): string {
|
||||
const normalized = value.replace(/\s+/g, " ").trim();
|
||||
const chars = Array.from(normalized);
|
||||
if (chars.length <= SLACK_PROGRESS_DETAIL_MAX_CHARS) {
|
||||
return normalized;
|
||||
}
|
||||
const keepStart = Math.ceil((SLACK_PROGRESS_DETAIL_MAX_CHARS - 1) * 0.45);
|
||||
const keepEnd = SLACK_PROGRESS_DETAIL_MAX_CHARS - keepStart - 1;
|
||||
return `${chars.slice(0, keepStart).join("").trimEnd()}…${chars
|
||||
.slice(-keepEnd)
|
||||
.join("")
|
||||
.trimStart()}`;
|
||||
}
|
||||
|
||||
function lineDetail(line: ChannelProgressDraftLine): string {
|
||||
const parts = [
|
||||
line.detail,
|
||||
line.status && !line.detail?.includes(line.status) ? line.status : undefined,
|
||||
]
|
||||
.map((part) => part?.trim())
|
||||
.filter((part): part is string => Boolean(part));
|
||||
return parts.length ? escapeSlackMrkdwn(compactDetail(parts.join(" · "))) : " ";
|
||||
}
|
||||
|
||||
export function buildSlackProgressDraftBlocks(params: {
|
||||
label?: string;
|
||||
lines: readonly ChannelProgressDraftLine[];
|
||||
}): (Block | KnownBlock)[] | undefined {
|
||||
const blocks: (Block | KnownBlock)[] = [];
|
||||
const label = params.label?.trim();
|
||||
if (label) {
|
||||
blocks.push({
|
||||
type: "section",
|
||||
text: field(`*${escapeSlackMrkdwn(label)}*`),
|
||||
});
|
||||
}
|
||||
const availableLineBlocks = Math.max(0, SLACK_MAX_BLOCKS - blocks.length);
|
||||
for (const line of params.lines.slice(0, availableLineBlocks)) {
|
||||
blocks.push({
|
||||
type: "section",
|
||||
fields: [field(lineTitle(line)), field(lineDetail(line))],
|
||||
});
|
||||
}
|
||||
return blocks.length ? blocks : undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user