mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
fix(discord): send early typing cue for accepted DMs
Co-authored-by: chinar-amrutkar <chinar.amrutkar@gmail.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user