fix(discord): send early typing cue for accepted DMs

Co-authored-by: chinar-amrutkar <chinar.amrutkar@gmail.com>
This commit is contained in:
Peter Steinberger
2026-05-03 17:01:43 +01:00
parent fb9030ff67
commit 0636442cde
3 changed files with 206 additions and 7 deletions

View File

@@ -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.

View File

@@ -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<string, unknown>) => void;
function createDeferred<T = void>() {
let resolve: (value: T | PromiseLike<T>) => 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<string, unknown> = {}) {
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();

View File

@@ -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) {