From 1cbb67668f97f6a263ce7e0c2e28a9367bb44786 Mon Sep 17 00:00:00 2001 From: zw-xysk Date: Sun, 12 Jul 2026 16:20:56 +0800 Subject: [PATCH] fix(slack): log typing reaction errors instead of silently swallowing them (#103303) * fix(slack): log typing reaction errors instead of silently swallowing them The typing reaction (emoji) add and remove calls both use empty catch handlers, silently discarding all errors. If the bot token lacks reactions:write scope, the operator has no way to diagnose why the configured typing reaction never appears. Replace the empty catch with logVerbose calls that record the Slack error, matching the existing logging pattern in the same file. * test(slack): cover typing reaction failures --------- Co-authored-by: Peter Steinberger --- .../dispatch.preview-fallback.test.ts | 45 ++++++++++++++++++- .../src/monitor/message-handler/dispatch.ts | 8 +++- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index 80b01c65ae63..b2ad2136431f 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts @@ -28,6 +28,7 @@ const stopSlackStreamMock = vi.fn(async (_params?: unknown) => ({}) as { message const emitSlackMessageSentHooksMock = vi.fn(() => {}); const reactSlackMessageMock = vi.fn(async () => {}); const removeSlackReactionMock = vi.fn(async () => {}); +const logVerboseMock = vi.fn(); class TestSlackStreamNotDeliveredError extends Error { readonly pendingText: string; readonly slackCode: string; @@ -200,6 +201,14 @@ function requireCapturedTyping() { return capturedTyping; } +function createSlackPlatformError(error: string, details?: { needed?: string; provided?: string }) { + // Mirrors @slack/web-api 7.18.0 platformErrorFromResult: message plus structured result data. + return Object.assign(new Error(`An API error occurred: ${error}`), { + code: "slack_webapi_platform_error", + data: { ok: false, error, ...details }, + }); +} + function requireCapturedItemEventHandler() { const handler = capturedReplyOptions?.onItemEvent; if (!handler) { @@ -819,7 +828,7 @@ vi.mock("openclaw/plugin-sdk/reply-payload", () => ({ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({ danger: (message: string) => message, - logVerbose: () => {}, + logVerbose: logVerboseMock, shouldLogVerbose: () => false, })); @@ -1258,6 +1267,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { stopSlackStreamMock.mockReset(); reactSlackMessageMock.mockReset(); removeSlackReactionMock.mockReset(); + logVerboseMock.mockReset(); for (const value of Object.values(statusReactionControllerMock)) { value.mockClear(); } @@ -1982,6 +1992,39 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { ); }); + it("logs the formatted Slack error when adding the typing reaction fails", async () => { + reactSlackMessageMock.mockRejectedValueOnce( + createSlackPlatformError("missing_scope", { + needed: "reactions:write", + provided: "chat:write", + }), + ); + + await dispatchPreparedSlackMessage( + createPreparedSlackMessage({ typingReaction: "hourglass_flowing_sand" }), + ); + await expect(requireCapturedTyping().start()).resolves.toBeUndefined(); + + expect(logVerboseMock).toHaveBeenCalledWith( + "slack send: typing reaction failed: An API error occurred: missing_scope; code: slack_webapi_platform_error; slack error: missing_scope; needed: reactions:write; provided: chat:write", + ); + }); + + it("logs the formatted Slack error when removing the typing reaction fails", async () => { + removeSlackReactionMock.mockRejectedValueOnce(createSlackPlatformError("invalid_auth")); + + await dispatchPreparedSlackMessage( + createPreparedSlackMessage({ typingReaction: "hourglass_flowing_sand" }), + ); + const typing = requireCapturedTyping(); + await typing.start(); + await expect(typing.stop?.()).resolves.toBeUndefined(); + + expect(logVerboseMock).toHaveBeenCalledWith( + "slack send: typing reaction removal failed: An API error occurred: invalid_auth; code: slack_webapi_platform_error; slack error: invalid_auth", + ); + }); + it("keeps Slack status reactions when channel replies are message-tool-only", async () => { await dispatchPreparedSlackMessage( createPreparedSlackMessage({ diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 349b0aa67638..1aa2aa3d9181 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -614,7 +614,9 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag await reactSlackMessage(message.channel, message.ts, typingReaction, { token: ctx.botToken, client: slackClient, - }).catch(() => {}); + }).catch((err: unknown) => { + logVerbose(`slack send: typing reaction failed: ${formatSlackError(err)}`); + }); } }, stop: async () => { @@ -632,7 +634,9 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag await removeSlackReaction(message.channel, message.ts, typingReaction, { token: ctx.botToken, client: slackClient, - }).catch(() => {}); + }).catch((err: unknown) => { + logVerbose(`slack send: typing reaction removal failed: ${formatSlackError(err)}`); + }); } }, onStartError: (err) => {