fix: polish Slack thread starter context (#68594)

This commit is contained in:
Peter Steinberger
2026-04-18 18:35:08 +01:00
parent 5cc4426f88
commit 6b525023d4
17 changed files with 109 additions and 52 deletions

View File

@@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai
- Gateway/restart: keep stale-gateway cleanup from terminating the current process's parent or ancestors, so plugin sidecars like WeChat no longer kill the active gateway and trigger an infinite supervisor restart loop. Fixes #68451. (#68517) Thanks @openperf.
- Gateway/auth: reject gateway auth credentials that match published example placeholders at startup and secret reload, and keep cloud install snippets from publishing copy-paste gateway/keyring secrets. (#68404) Thanks @coygeek.
- CLI/update: preserve macOS restart helper launchctl failures in the update restart log without letting log setup block the restart path. (#68492) Thanks @hclsys.
- Slack/threads: keep file-only root messages as starter context so first thread replies can still hydrate starter media. (#68594) Thanks @martingarramon.
## 2026.4.15

View File

@@ -6,6 +6,7 @@ import type {
} from "openclaw/plugin-sdk/config-runtime";
import type { SessionScope } from "openclaw/plugin-sdk/config-runtime";
import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/config-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { createDedupeCache } from "openclaw/plugin-sdk/infra-runtime";
import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
@@ -282,7 +283,9 @@ export function createSlackMonitorContext(params: {
status: p.status,
});
} catch (err) {
logVerbose(`slack status update failed for channel ${p.channelId}: ${String(err)}`);
logVerbose(
`slack status update failed for channel ${p.channelId}: ${formatErrorMessage(err)}`,
);
}
};

View File

@@ -1,5 +1,6 @@
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveSlackAllowListMatch } from "./allow-list.js";
import type { SlackMonitorContext } from "./context.js";
import { upsertChannelPairingRequest } from "./conversation.runtime.js";
@@ -57,7 +58,7 @@ export async function authorizeSlackDirectMessage(params: {
);
},
onReplyError: (err) => {
params.log(`slack pairing reply failed for ${params.senderId}: ${String(err)}`);
params.log(`slack pairing reply failed for ${params.senderId}: ${formatErrorMessage(err)}`);
},
});
return false;

View File

@@ -1,6 +1,7 @@
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-writes";
import { loadConfig, writeConfigFile } from "openclaw/plugin-sdk/config-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
import { danger, warn } from "openclaw/plugin-sdk/runtime-env";
import { migrateSlackChannelConfig } from "../../channel-migration.js";
@@ -61,7 +62,9 @@ export function registerSlackChannelEvents(params: {
const channelName = payload.channel?.name;
enqueueChannelSystemEvent({ kind: "created", channelId, channelName });
} catch (err) {
ctx.runtime.error?.(danger(`slack channel created handler failed: ${String(err)}`));
ctx.runtime.error?.(
danger(`slack channel created handler failed: ${formatErrorMessage(err)}`),
);
}
},
);
@@ -80,7 +83,9 @@ export function registerSlackChannelEvents(params: {
const channelName = payload.channel?.name_normalized ?? payload.channel?.name;
enqueueChannelSystemEvent({ kind: "renamed", channelId, channelName });
} catch (err) {
ctx.runtime.error?.(danger(`slack channel rename handler failed: ${String(err)}`));
ctx.runtime.error?.(
danger(`slack channel rename handler failed: ${formatErrorMessage(err)}`),
);
}
},
);
@@ -155,7 +160,9 @@ export function registerSlackChannelEvents(params: {
);
}
} catch (err) {
ctx.runtime.error?.(danger(`slack channel_id_changed handler failed: ${String(err)}`));
ctx.runtime.error?.(
danger(`slack channel_id_changed handler failed: ${formatErrorMessage(err)}`),
);
}
},
);

View File

@@ -1,4 +1,5 @@
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { SlackMonitorContext } from "../context.js";
@@ -42,7 +43,9 @@ export function registerSlackMemberEvents(params: {
contextKey: `slack:member:${params.verb}:${channelId ?? "unknown"}:${payload.user ?? "unknown"}`,
});
} catch (err) {
ctx.runtime.error?.(danger(`slack ${params.verb} handler failed: ${String(err)}`));
ctx.runtime.error?.(
danger(`slack ${params.verb} handler failed: ${formatErrorMessage(err)}`),
);
}
};

View File

@@ -1,4 +1,5 @@
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { SlackAppMentionEvent, SlackMessageEvent } from "../../types.js";
@@ -43,7 +44,7 @@ export function registerSlackMessageEvents(params: {
await handleSlackMessage(message, { source: "message" });
} catch (err) {
ctx.runtime.error?.(danger(`slack handler failed: ${String(err)}`));
ctx.runtime.error?.(danger(`slack handler failed: ${formatErrorMessage(err)}`));
}
};
@@ -77,7 +78,7 @@ export function registerSlackMessageEvents(params: {
wasMentioned: true,
});
} catch (err) {
ctx.runtime.error?.(danger(`slack mention handler failed: ${String(err)}`));
ctx.runtime.error?.(danger(`slack mention handler failed: ${formatErrorMessage(err)}`));
}
});
}

View File

@@ -1,4 +1,5 @@
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { SlackMonitorContext } from "../context.js";
@@ -45,7 +46,7 @@ async function handleSlackPinEvent(params: {
},
);
} catch (err) {
ctx.runtime.error?.(danger(`slack ${errorLabel} handler failed: ${String(err)}`));
ctx.runtime.error?.(danger(`slack ${errorLabel} handler failed: ${formatErrorMessage(err)}`));
}
}

View File

@@ -1,4 +1,5 @@
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { SlackMonitorContext } from "../context.js";
@@ -46,7 +47,7 @@ export function registerSlackReactionEvents(params: {
contextKey: `slack:reaction:${action}:${item.channel}:${item.ts}:${event.user}:${emojiLabel}`,
});
} catch (err) {
ctx.runtime.error?.(danger(`slack reaction handler failed: ${String(err)}`));
ctx.runtime.error?.(danger(`slack reaction handler failed: ${formatErrorMessage(err)}`));
}
};

View File

@@ -905,7 +905,7 @@ describe("resolveSlackThreadStarter", () => {
expect(vi.mocked(logVerbose)).not.toHaveBeenCalled();
});
it("returns null when the starter message has no text and no files", async () => {
it("returns null when the starter message has no text or files", async () => {
const replies = vi.fn().mockResolvedValueOnce({ messages: [{ text: " ", user: "U1" }] });
const client = {
conversations: { replies },
@@ -921,6 +921,37 @@ describe("resolveSlackThreadStarter", () => {
expect(vi.mocked(logVerbose)).not.toHaveBeenCalled();
});
it("returns a placeholder starter when the root message only has files", async () => {
const replies = vi.fn().mockResolvedValueOnce({
messages: [
{
text: " ",
user: "U1",
ts: "1.000",
files: [{ name: "root.png", mimetype: "image/png" }],
},
],
});
const client = {
conversations: { replies },
} as unknown as Parameters<typeof resolveSlackThreadStarter>[0]["client"];
const result = await resolveSlackThreadStarter({
channelId: "C1",
threadTs: "1.000",
client,
});
expect(result).toEqual({
text: "[attached: root.png]",
userId: "U1",
botId: undefined,
ts: "1.000",
files: [{ name: "root.png", mimetype: "image/png" }],
});
expect(vi.mocked(logVerbose)).not.toHaveBeenCalled();
});
it("returns null and surfaces the error via logVerbose when Slack API throws", async () => {
const replies = vi.fn().mockRejectedValueOnce(new Error("not_in_channel"));
const client = {
@@ -942,7 +973,7 @@ describe("resolveSlackThreadStarter", () => {
expect(vi.mocked(logVerbose)).toHaveBeenCalledWith(expect.stringContaining("ts=9.999"));
});
it("surfaces non-Error thrown values as String(err) via logVerbose", async () => {
it("surfaces non-Error thrown values via logVerbose", async () => {
const replies = vi.fn().mockRejectedValueOnce("rate_limited");
const client = {
conversations: { replies },

View File

@@ -1,4 +1,5 @@
import type { WebClient as SlackWebClient } from "@slack/web-api";
import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeHostname } from "openclaw/plugin-sdk/host-runtime";
import { fetchWithRuntimeDispatcher } from "openclaw/plugin-sdk/infra-runtime";
@@ -385,18 +386,11 @@ function evictThreadStarterCache(): void {
THREAD_STARTER_CACHE.delete(cacheKey);
}
}
if (THREAD_STARTER_CACHE.size <= THREAD_STARTER_CACHE_MAX) {
return;
}
const excess = THREAD_STARTER_CACHE.size - THREAD_STARTER_CACHE_MAX;
let removed = 0;
for (const cacheKey of THREAD_STARTER_CACHE.keys()) {
THREAD_STARTER_CACHE.delete(cacheKey);
removed += 1;
if (removed >= excess) {
break;
}
}
pruneMapToMaxSize(THREAD_STARTER_CACHE, THREAD_STARTER_CACHE_MAX);
}
function formatSlackFilePlaceholder(files: SlackFile[] | undefined): string {
return `[attached: ${files?.map((file) => file.name ?? "file").join(", ") ?? "file"}]`;
}
export async function resolveSlackThreadStarter(params: {
@@ -430,15 +424,16 @@ export async function resolveSlackThreadStarter(params: {
};
const message = response?.messages?.[0];
const text = (message?.text ?? "").trim();
if (!message || !text) {
const files = message?.files?.length ? message.files : undefined;
if (!message || (!text && !files)) {
return null;
}
const starter: SlackThreadStarter = {
text,
text: text || formatSlackFilePlaceholder(files),
userId: message.user,
botId: message.bot_id,
ts: message.ts,
files: message.files,
files,
};
if (THREAD_STARTER_CACHE.has(cacheKey)) {
THREAD_STARTER_CACHE.delete(cacheKey);
@@ -536,9 +531,7 @@ export async function resolveSlackThreadHistory(params: {
return retained.map((msg) => ({
// For file-only messages, create a placeholder showing attached filenames
text: msg.text?.trim()
? msg.text
: `[attached: ${msg.files?.map((f) => f.name ?? "file").join(", ")}]`,
text: msg.text?.trim() ? msg.text : formatSlackFilePlaceholder(msg.files),
userId: msg.user,
botId: msg.bot_id,
ts: msg.ts,

View File

@@ -2,6 +2,7 @@ import {
createChannelInboundDebouncer,
shouldDebounceTextInbound,
} from "openclaw/plugin-sdk/channel-inbound";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { ResolvedSlackAccount } from "../accounts.js";
import type { SlackMessageEvent } from "../types.js";
import { stripSlackMentionsForCommandDetection } from "./commands.js";
@@ -189,7 +190,7 @@ export function createSlackMessageHandler(params: {
}
},
onError: (err) => {
ctx.runtime.error?.(`slack inbound debounce flush failed: ${String(err)}`);
ctx.runtime.error?.(`slack inbound debounce flush failed: ${formatErrorMessage(err)}`);
},
});
const threadTsResolver = createSlackThreadTsResolver({ client: ctx.app.client });

View File

@@ -12,6 +12,7 @@ import {
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingNativeTransport,
} from "openclaw/plugin-sdk/channel-streaming";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/outbound-runtime";
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
@@ -199,7 +200,7 @@ export async function resolveSlackStreamRecipientTeamId(params: {
return teamId;
}
} catch (err) {
logVerbose(`slack-stream: users.info team lookup failed (${String(err)})`);
logVerbose(`slack-stream: users.info team lookup failed (${formatErrorMessage(err)})`);
}
}
return params.fallbackTeamId;
@@ -273,7 +274,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
token: ctx.botToken,
client: ctx.app.client,
}).catch((err) => {
if (String(err).includes("already_reacted")) {
if (formatErrorMessage(err).includes("already_reacted")) {
return;
}
throw err;
@@ -284,7 +285,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
token: ctx.botToken,
client: ctx.app.client,
}).catch((err) => {
if (String(err).includes("no_reaction")) {
if (formatErrorMessage(err).includes("no_reaction")) {
return;
}
throw err;
@@ -556,7 +557,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
});
} catch (err) {
runtime.error?.(
danger(`slack-stream: streaming API call failed: ${String(err)}, falling back`),
danger(`slack-stream: streaming API call failed: ${formatErrorMessage(err)}, falling back`),
);
streamFailed = true;
await deliverNormally({
@@ -613,7 +614,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
return;
} catch (err) {
logVerbose(
`slack: preview final edit failed; falling back to standard send (${String(err)})`,
`slack: preview final edit failed; falling back to standard send (${formatErrorMessage(err)})`,
);
}
} else if (previewStreamingEnabled && streamMode === "status_final" && hasStreamedMessage) {
@@ -629,7 +630,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
});
}
} catch (err) {
logVerbose(`slack: status_final completion update failed (${String(err)})`);
logVerbose(`slack: status_final completion update failed (${formatErrorMessage(err)})`);
}
} else if (reply.hasMedia) {
await draftStream?.clear();
@@ -639,7 +640,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
await deliverNormally({ payload, kind: info.kind });
},
onError: (err, info) => {
runtime.error?.(danger(`slack ${info.kind} reply failed: ${String(err)}`));
runtime.error?.(danger(`slack ${info.kind} reply failed: ${formatErrorMessage(err)}`));
replyPipeline.typingCallbacks?.onIdle?.();
},
});
@@ -771,7 +772,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
try {
await stopSlackStream({ session: finalStream });
} catch (err) {
runtime.error?.(danger(`slack-stream: failed to stop stream: ${String(err)}`));
runtime.error?.(danger(`slack-stream: failed to stop stream: ${formatErrorMessage(err)}`));
}
}

View File

@@ -15,6 +15,7 @@ import {
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
import { hasControlCommand } from "openclaw/plugin-sdk/command-auth";
import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-auth";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
import {
buildPendingHistoryContextFromMap,
@@ -596,7 +597,9 @@ export async function prepareSlackMessage(params: {
}).then(
() => true,
(err) => {
logVerbose(`slack react failed for channel ${message.channel}: ${String(err)}`);
logVerbose(
`slack react failed for channel ${message.channel}: ${formatErrorMessage(err)}`,
);
return false;
},
)
@@ -804,7 +807,7 @@ export async function prepareSlackMessage(params: {
onRecordError: (err) => {
ctx.logger.warn(
{
error: String(err),
error: formatErrorMessage(err),
storePath,
sessionKey,
},

View File

@@ -512,7 +512,9 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
summarizeMapping("slack channels", mapping, unresolved, runtime);
}
} catch (err) {
runtime.log?.(`slack channel resolve failed; using config entries. ${String(err)}`);
runtime.log?.(
`slack channel resolve failed; using config entries. ${formatUnknownError(err)}`,
);
}
}
@@ -533,7 +535,9 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
ctx.allowFrom = normalizeAllowList(allowFrom);
summarizeMapping("slack users", mapping, unresolved, runtime);
} catch (err) {
runtime.log?.(`slack user resolve failed; using config entries. ${String(err)}`);
runtime.log?.(
`slack user resolve failed; using config entries. ${formatUnknownError(err)}`,
);
}
}
@@ -565,7 +569,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
summarizeMapping("slack channel users", mapping, unresolved, runtime);
} catch (err) {
runtime.log?.(
`slack channel user resolve failed; using config entries. ${String(err)}`,
`slack channel user resolve failed; using config entries. ${formatUnknownError(err)}`,
);
}
}

View File

@@ -10,6 +10,7 @@ import {
resolveNativeCommandsEnabled,
resolveNativeSkillsEnabled,
} from "openclaw/plugin-sdk/config-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { chunkItems, normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
@@ -600,7 +601,9 @@ export async function registerSlackMonitorSlashCommands(params: {
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onError: (err) =>
runtime.error?.(danger(`slack slash: failed updating session meta: ${String(err)}`)),
runtime.error?.(
danger(`slack slash: failed updating session meta: ${formatErrorMessage(err)}`),
),
});
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
@@ -632,7 +635,9 @@ export async function registerSlackMonitorSlashCommands(params: {
...replyPipeline,
deliver: async (payload) => deliverSlashPayloads([payload]),
onError: (err, info) => {
runtime.error?.(danger(`slack slash ${info.kind} reply failed: ${String(err)}`));
runtime.error?.(
danger(`slack slash ${info.kind} reply failed: ${formatErrorMessage(err)}`),
);
},
},
replyOptions: {
@@ -644,7 +649,7 @@ export async function registerSlackMonitorSlashCommands(params: {
await deliverSlashPayloads([]);
}
} catch (err) {
runtime.error?.(danger(`slack slash handler failed: ${String(err)}`));
runtime.error?.(danger(`slack slash handler failed: ${formatErrorMessage(err)}`));
await respond({
text: "Sorry, something went wrong handling that command.",
response_type: "ephemeral",
@@ -803,7 +808,7 @@ export async function registerSlackMonitorSlashCommands(params: {
} catch (err) {
supportsExternalArgMenus = false;
logVerbose(
`slack: external arg-menu registration failed, falling back to static menus: ${String(err)}`,
`slack: external arg-menu registration failed, falling back to static menus: ${formatErrorMessage(err)}`,
);
}

View File

@@ -1,5 +1,6 @@
import type { WebClient as SlackWebClient } from "@slack/web-api";
import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { SlackMessageEvent } from "../types.js";
@@ -35,7 +36,7 @@ async function resolveThreadTsFromHistory(params: {
} catch (err) {
if (shouldLogVerbose()) {
logVerbose(
`slack inbound: failed to resolve thread_ts via conversations.history for channel=${params.channelId} ts=${params.messageTs}: ${String(err)}`,
`slack inbound: failed to resolve thread_ts via conversations.history for channel=${params.channelId} ts=${params.messageTs}: ${formatErrorMessage(err)}`,
);
}
return undefined;

View File

@@ -60,9 +60,9 @@ const allowedRawFetchCallsites = new Set([
bundledPluginCallsite("qqbot", "src/tools/channel.ts", 180),
bundledPluginCallsite("qqbot", "src/utils/audio-convert.ts", 377),
bundledPluginCallsite("signal", "src/install-signal-cli.ts", 224),
bundledPluginCallsite("slack", "src/monitor/media.ts", 98),
bundledPluginCallsite("slack", "src/monitor/media.ts", 117),
bundledPluginCallsite("slack", "src/monitor/media.ts", 122),
bundledPluginCallsite("slack", "src/monitor/media.ts", 99),
bundledPluginCallsite("slack", "src/monitor/media.ts", 118),
bundledPluginCallsite("slack", "src/monitor/media.ts", 123),
bundledPluginCallsite("tlon", "src/tlon-api.ts", 185),
bundledPluginCallsite("tlon", "src/tlon-api.ts", 235),
bundledPluginCallsite("tlon", "src/tlon-api.ts", 289),