diff --git a/CHANGELOG.md b/CHANGELOG.md index 328bdab7a21..ffd25c6317f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Memory/status: keep plain `openclaw memory status` and `openclaw memory status --json` on the cheap read-only path by reserving vector and embedding provider probes for `--deep` or `--index`. Fixes #76769. Thanks @daruire. - Control UI/Sessions: avoid full `sessions.list` reloads for chat-turn `sessions.changed` payloads, so large session stores no longer add multi-second delays while chat responses are being delivered. (#76676) Thanks @VACInc. - Doctor/Telegram: warn when selected Telegram quote replies can suppress `streaming.preview.toolProgress`, and document the `replyToMode` trade-off without changing runtime delivery. Fixes #73487. Thanks @GodsBoy. +- Channels/Discord: send a best-effort native typing cue immediately after an inbound DM is accepted, so slow pre-dispatch turns show Discord liveness before queueing, context assembly, model, or tool work starts. Fixes #76417. Thanks @mlopez14. - Plugins/install: reject source-only TypeScript package installs and installed plugin packages that are missing compiled runtime output, so broken npm artifacts fail at install/discovery time instead of falling through jiti and surfacing later as unavailable providers. Fixes #76720. - Discord/status: honor explicit `messages.statusReactions.enabled: true` in tool-only guild channels so queued ack reactions can progress through thinking/done lifecycle reactions instead of stopping at the initial emoji. Thanks @Marvinthebored. - Discord/native commands: compare Discord-normalized slash-command descriptions and localized descriptions during reconcile so CJK or multiline command text no longer triggers redundant startup PATCH bursts and rate-limit 429s. Fixes #76587. Thanks @zhengsx. diff --git a/extensions/discord/src/monitor/message-handler.queue.test.ts b/extensions/discord/src/monitor/message-handler.queue.test.ts index 8bd1bb2fe18..33f90079d00 100644 --- a/extensions/discord/src/monitor/message-handler.queue.test.ts +++ b/extensions/discord/src/monitor/message-handler.queue.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { DiscordRetryableInboundError } from "./inbound-dedupe.js"; import { createDiscordMessageHandler, @@ -10,6 +11,23 @@ import { createDiscordPreflightContext, } from "./message-handler.test-helpers.js"; +const earlyTypingMocks = vi.hoisted(() => ({ + createDiscordRestClient: vi.fn(() => ({ + token: "test-token", + rest: { kind: "discord-rest" }, + account: { accountId: "default", config: {} }, + })), + sendTyping: vi.fn(async () => {}), +})); + +vi.mock("../client.js", () => ({ + createDiscordRestClient: earlyTypingMocks.createDiscordRestClient, +})); + +vi.mock("./typing.js", () => ({ + sendTyping: earlyTypingMocks.sendTyping, +})); + type SetStatusFn = (patch: Record) => void; function createDeferred() { let resolve: (value: T | PromiseLike) => void = () => {}; @@ -40,17 +58,40 @@ function createMessageData(messageId: string, channelId = "ch-1") { } function createPreflightContext(channelId = "ch-1") { + const discordConfig = { + enabled: true, + token: "test-token", + groupPolicy: "allowlist" as const, + }; + const cfg: OpenClawConfig = { + channels: { + discord: discordConfig, + }, + messages: { + inbound: { + debounceMs: 0, + }, + }, + }; return { ...createDiscordPreflightContext(channelId), + cfg, accountId: "default", token: "test-token", textLimit: 2_000, replyToMode: "off" as const, - discordConfig: { - enabled: true, - token: "test-token", - groupPolicy: "allowlist" as const, - }, + discordConfig, + }; +} + +function createAcceptedDmPreflightContext(overrides: Record = {}) { + return { + ...createPreflightContext("dm-1"), + isDirectMessage: true, + isGuildMessage: false, + isGroupDm: false, + messageText: "hello", + ...overrides, }; } @@ -104,6 +145,128 @@ async function createLifecycleStopScenario(params: { } describe("createDiscordMessageHandler queue behavior", () => { + beforeEach(() => { + earlyTypingMocks.createDiscordRestClient.mockReset().mockReturnValue({ + token: "test-token", + rest: { kind: "discord-rest" }, + account: { accountId: "default", config: {} }, + }); + earlyTypingMocks.sendTyping.mockReset().mockResolvedValue(undefined); + }); + + it("sends an accepted DM typing cue before queued processing starts", async () => { + preflightDiscordMessageMock.mockReset(); + processDiscordMessageMock.mockReset(); + preflightDiscordMessageMock.mockResolvedValue(createAcceptedDmPreflightContext()); + processDiscordMessageMock.mockResolvedValue(undefined); + + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); + await expect( + handler(createMessageData("m-typing", "dm-1") as never, {} as never), + ).resolves.toBeUndefined(); + + await flushQueueWork(); + + expect(earlyTypingMocks.createDiscordRestClient).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "default", + token: "test-token", + }), + ); + expect(earlyTypingMocks.sendTyping).toHaveBeenCalledWith({ + rest: { kind: "discord-rest" }, + channelId: "dm-1", + }); + expect(earlyTypingMocks.sendTyping.mock.invocationCallOrder[0]).toBeLessThan( + processDiscordMessageMock.mock.invocationCallOrder[0], + ); + }); + + it("keeps accepted DM dispatch running when the early typing cue fails", async () => { + preflightDiscordMessageMock.mockReset(); + processDiscordMessageMock.mockReset(); + earlyTypingMocks.sendTyping.mockRejectedValueOnce(new Error("typing failed")); + preflightDiscordMessageMock.mockResolvedValue(createAcceptedDmPreflightContext()); + processDiscordMessageMock.mockResolvedValue(undefined); + + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); + await expect( + handler(createMessageData("m-typing-fails", "dm-1") as never, {} as never), + ).resolves.toBeUndefined(); + + await flushQueueWork(); + + expect(earlyTypingMocks.sendTyping).toHaveBeenCalledTimes(1); + expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); + }); + + it("does not send early typing when preflight rejects the message", async () => { + preflightDiscordMessageMock.mockReset(); + processDiscordMessageMock.mockReset(); + preflightDiscordMessageMock.mockResolvedValue(null); + + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); + await expect( + handler(createMessageData("m-rejected", "dm-1") as never, {} as never), + ).resolves.toBeUndefined(); + + await flushQueueWork(); + + expect(earlyTypingMocks.sendTyping).not.toHaveBeenCalled(); + expect(processDiscordMessageMock).not.toHaveBeenCalled(); + }); + + it("does not send early typing when typing mode is not instant", async () => { + preflightDiscordMessageMock.mockReset(); + processDiscordMessageMock.mockReset(); + preflightDiscordMessageMock.mockResolvedValue( + createAcceptedDmPreflightContext({ + cfg: { + ...createPreflightContext().cfg, + agents: { + defaults: { + typingMode: "message", + }, + }, + }, + }), + ); + processDiscordMessageMock.mockResolvedValue(undefined); + + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); + await expect( + handler(createMessageData("m-message-mode", "dm-1") as never, {} as never), + ).resolves.toBeUndefined(); + + await flushQueueWork(); + + expect(earlyTypingMocks.sendTyping).not.toHaveBeenCalled(); + expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); + }); + + it("does not send early typing for guild messages", async () => { + preflightDiscordMessageMock.mockReset(); + processDiscordMessageMock.mockReset(); + preflightDiscordMessageMock.mockResolvedValue( + createAcceptedDmPreflightContext({ + isDirectMessage: false, + isGuildMessage: true, + messageChannelId: "guild-channel", + }), + ); + processDiscordMessageMock.mockResolvedValue(undefined); + + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); + await expect( + handler(createMessageData("m-guild", "guild-channel") as never, {} as never), + ).resolves.toBeUndefined(); + + await flushQueueWork(); + + expect(earlyTypingMocks.sendTyping).not.toHaveBeenCalled(); + expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); + }); + it("resets busy counters when the handler is created", () => { preflightDiscordMessageMock.mockReset(); processDiscordMessageMock.mockReset(); diff --git a/extensions/discord/src/monitor/message-handler.ts b/extensions/discord/src/monitor/message-handler.ts index 44ed2624a27..84767d5a791 100644 --- a/extensions/discord/src/monitor/message-handler.ts +++ b/extensions/discord/src/monitor/message-handler.ts @@ -2,8 +2,9 @@ import { createChannelInboundDebouncer, shouldDebounceTextInbound, } from "openclaw/plugin-sdk/channel-inbound"; -import { danger } from "openclaw/plugin-sdk/runtime-env"; +import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy"; +import { createDiscordRestClient } from "../client.js"; import type { Client } from "../internal/discord.js"; import { buildDiscordInboundReplayKey, @@ -16,6 +17,7 @@ import { import { buildDiscordInboundJob } from "./inbound-job.js"; import type { DiscordMessageEvent, DiscordMessageHandler } from "./listeners.js"; import { applyImplicitReplyBatchGate } from "./message-handler.batch-gate.js"; +import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js"; import type { DiscordMessagePreflightParams } from "./message-handler.preflight.types.js"; import { createDiscordMessageRunQueue, @@ -27,6 +29,7 @@ import { resolveDiscordMessageText, } from "./message-utils.js"; import type { DiscordMonitorStatusSink } from "./status.js"; +import { sendTyping } from "./typing.js"; type PreflightDiscordMessage = typeof import("./message-handler.preflight.js").preflightDiscordMessage; @@ -61,6 +64,36 @@ function isNonEmptyString(value: string | undefined): value is string { return typeof value === "string" && value.length > 0; } +function shouldSendAcceptedDiscordTypingCue(ctx: DiscordMessagePreflightContext): boolean { + if (ctx.abortSignal?.aborted) { + return false; + } + if (!ctx.isDirectMessage || ctx.isGuildMessage || ctx.isGroupDm) { + return false; + } + if (!ctx.messageText.trim()) { + return false; + } + const configuredTypingMode = ctx.cfg.session?.typingMode ?? ctx.cfg.agents?.defaults?.typingMode; + return configuredTypingMode === undefined || configuredTypingMode === "instant"; +} + +function queueAcceptedDiscordTypingCue(ctx: DiscordMessagePreflightContext): void { + if (!shouldSendAcceptedDiscordTypingCue(ctx)) { + return; + } + const { rest } = createDiscordRestClient({ + cfg: ctx.cfg, + token: ctx.token, + accountId: ctx.accountId, + }); + void sendTyping({ rest, channelId: ctx.messageChannelId }).catch((err) => { + logVerbose( + `discord early typing cue failed for channel ${ctx.messageChannelId}: ${String(err)}`, + ); + }); +} + export function createDiscordMessageHandler( params: DiscordMessageHandlerParams, ): DiscordMessageHandlerWithLifecycle { @@ -153,6 +186,7 @@ export function createDiscordMessageHandler( return; } applyImplicitReplyBatchGate(ctx, params.replyToMode, false); + queueAcceptedDiscordTypingCue(ctx); messageRunQueue.enqueue(buildDiscordInboundJob(ctx, { replayKeys })); return; } @@ -215,6 +249,7 @@ export function createDiscordMessageHandler( ctxBatch.MessageSidLast = ids[ids.length - 1]; } } + queueAcceptedDiscordTypingCue(ctx); messageRunQueue.enqueue(buildDiscordInboundJob(ctx, { replayKeys })); } catch (error) { if (error instanceof DiscordRetryableInboundError) {