fix(discord): autoThread ack reactions + exec approval null handling (#1511)

* fix(discord): gate autoThread by thread owner

* fix(discord): ack bot-owned autoThreads

* fix(discord): ack mentions in open channels

- Ack reactions in bot-owned autoThreads
- Ack reactions in open channels (no mention required)
- DRY: Pass pre-computed isAutoThreadOwnedByBot to avoid redundant checks
- Consolidate ack logic with explanatory comment

* fix: allow null values in exec.approval.request schema

The ExecApprovalRequestParamsSchema was rejecting null values for optional
fields like resolvedPath, but the calling code in bash-tools.exec.ts passes
null. This caused intermittent 'invalid exec.approval.request params'
validation errors.

Fix: Accept Type.Union([Type.String(), Type.Null()]) for all optional string
fields in the schema. Update test to reflect new behavior.

* fix: align discord ack reactions with mention gating (#1511) (thanks @pvoo)

---------

Co-authored-by: Wimmie <wimmie@tameson.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Paul van Oorschot
2026-01-23 21:01:15 +01:00
committed by GitHub
parent 242add587f
commit 7d0a0ae3ba
9 changed files with 215 additions and 12 deletions

View File

@@ -10,6 +10,8 @@ Docs: https://docs.clawd.bot
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
### Fixes
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
- TUI: include Gateway slash commands in autocomplete and `/help`.
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.

View File

@@ -377,12 +377,63 @@ describe("discord mention gating", () => {
resolveDiscordShouldRequireMention({
isGuildMessage: true,
isThread: true,
botId: "bot123",
threadOwnerId: "bot123",
channelConfig,
guildInfo,
}),
).toBe(false);
});
it("requires mention inside user-created threads with autoThread enabled", () => {
const guildInfo: DiscordGuildEntryResolved = {
requireMention: true,
channels: {
general: { allow: true, autoThread: true },
},
};
const channelConfig = resolveDiscordChannelConfig({
guildInfo,
channelId: "1",
channelName: "General",
channelSlug: "general",
});
expect(
resolveDiscordShouldRequireMention({
isGuildMessage: true,
isThread: true,
botId: "bot123",
threadOwnerId: "user456",
channelConfig,
guildInfo,
}),
).toBe(true);
});
it("requires mention when thread owner is unknown", () => {
const guildInfo: DiscordGuildEntryResolved = {
requireMention: true,
channels: {
general: { allow: true, autoThread: true },
},
};
const channelConfig = resolveDiscordChannelConfig({
guildInfo,
channelId: "1",
channelName: "General",
channelSlug: "general",
});
expect(
resolveDiscordShouldRequireMention({
isGuildMessage: true,
isThread: true,
botId: "bot123",
channelConfig,
guildInfo,
}),
).toBe(true);
});
it("inherits parent channel mention rules for threads", () => {
const guildInfo: DiscordGuildEntryResolved = {
requireMention: true,

View File

@@ -282,14 +282,33 @@ export function resolveDiscordChannelConfigWithFallback(params: {
export function resolveDiscordShouldRequireMention(params: {
isGuildMessage: boolean;
isThread: boolean;
botId?: string | null;
threadOwnerId?: string | null;
channelConfig?: DiscordChannelConfigResolved | null;
guildInfo?: DiscordGuildEntryResolved | null;
/** Pass pre-computed value to avoid redundant checks. */
isAutoThreadOwnedByBot?: boolean;
}): boolean {
if (!params.isGuildMessage) return false;
if (params.isThread && params.channelConfig?.autoThread) return false;
// Only skip mention requirement in threads created by the bot (when autoThread is enabled).
const isBotThread = params.isAutoThreadOwnedByBot ?? isDiscordAutoThreadOwnedByBot(params);
if (isBotThread) return false;
return params.channelConfig?.requireMention ?? params.guildInfo?.requireMention ?? true;
}
export function isDiscordAutoThreadOwnedByBot(params: {
isThread: boolean;
channelConfig?: DiscordChannelConfigResolved | null;
botId?: string | null;
threadOwnerId?: string | null;
}): boolean {
if (!params.isThread) return false;
if (!params.channelConfig?.autoThread) return false;
const botId = params.botId?.trim();
const threadOwnerId = params.threadOwnerId?.trim();
return Boolean(botId && threadOwnerId && botId === threadOwnerId);
}
export function isDiscordGroupAllowedByPolicy(params: {
groupPolicy: "open" | "disabled" | "allowlist";
guildAllowlisted: boolean;

View File

@@ -328,9 +328,12 @@ export async function preflightDiscordMessage(
} satisfies HistoryEntry)
: undefined;
const threadOwnerId = threadChannel ? (threadChannel.ownerId ?? channelInfo?.ownerId) : undefined;
const shouldRequireMention = resolveDiscordShouldRequireMention({
isGuildMessage,
isThread: Boolean(threadChannel),
botId,
threadOwnerId,
channelConfig,
guildInfo,
});

View File

@@ -0,0 +1,123 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const reactMessageDiscord = vi.fn(async () => {});
const removeReactionDiscord = vi.fn(async () => {});
vi.mock("../send.js", () => ({
reactMessageDiscord: (...args: unknown[]) => reactMessageDiscord(...args),
removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args),
}));
vi.mock("../../auto-reply/reply/dispatch-from-config.js", () => ({
dispatchReplyFromConfig: vi.fn(async () => ({
queuedFinal: false,
counts: { final: 0, tool: 0, block: 0 },
})),
}));
vi.mock("../../auto-reply/reply/reply-dispatcher.js", () => ({
createReplyDispatcherWithTyping: vi.fn(() => ({
dispatcher: {},
replyOptions: {},
markDispatchIdle: vi.fn(),
})),
}));
import { processDiscordMessage } from "./message-handler.process.js";
async function createBaseContext(overrides: Record<string, unknown> = {}) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-discord-"));
const storePath = path.join(dir, "sessions.json");
return {
cfg: { messages: { ackReaction: "👀" }, session: { store: storePath } },
discordConfig: {},
accountId: "default",
token: "token",
runtime: { log: () => {}, error: () => {} },
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 1024,
textLimit: 4000,
replyToMode: "off",
ackReactionScope: "group-mentions",
groupPolicy: "open",
data: { guild: { id: "g1", name: "Guild" } },
client: { rest: {} },
message: {
id: "m1",
channelId: "c1",
timestamp: new Date().toISOString(),
attachments: [],
},
author: {
id: "U1",
username: "alice",
discriminator: "0",
globalName: "Alice",
},
channelInfo: { name: "general" },
channelName: "general",
isGuildMessage: true,
isDirectMessage: false,
isGroupDm: false,
commandAuthorized: true,
baseText: "hi",
messageText: "hi",
wasMentioned: false,
shouldRequireMention: true,
canDetectMention: true,
effectiveWasMentioned: true,
shouldBypassMention: false,
threadChannel: null,
threadParentId: undefined,
threadParentName: undefined,
threadParentType: undefined,
threadName: undefined,
displayChannelSlug: "general",
guildInfo: null,
guildSlug: "guild",
channelConfig: null,
baseSessionKey: "agent:main:discord:guild:g1",
route: {
agentId: "main",
channel: "discord",
accountId: "default",
sessionKey: "agent:main:discord:guild:g1",
mainSessionKey: "agent:main:main",
},
...overrides,
};
}
beforeEach(() => {
reactMessageDiscord.mockClear();
removeReactionDiscord.mockClear();
});
describe("processDiscordMessage ack reactions", () => {
it("skips ack reactions for group-mentions when mentions are not required", async () => {
const ctx = await createBaseContext({
shouldRequireMention: false,
effectiveWasMentioned: false,
});
await processDiscordMessage(ctx as any);
expect(reactMessageDiscord).not.toHaveBeenCalled();
});
it("sends ack reactions for mention-gated guild messages when mentioned", async () => {
const ctx = await createBaseContext({
shouldRequireMention: true,
effectiveWasMentioned: true,
});
await processDiscordMessage(ctx as any);
expect(reactMessageDiscord).toHaveBeenCalledWith("c1", "m1", "👀", { rest: {} });
});
});

View File

@@ -16,6 +16,7 @@ export type DiscordChannelInfo = {
name?: string;
topic?: string;
parentId?: string;
ownerId?: string;
};
type DiscordSnapshotAuthor = {
@@ -69,11 +70,13 @@ export async function resolveDiscordChannelInfo(
const name = "name" in channel ? (channel.name ?? undefined) : undefined;
const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined;
const parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined;
const ownerId = "ownerId" in channel ? (channel.ownerId ?? undefined) : undefined;
const payload: DiscordChannelInfo = {
type: channel.type,
name,
topic,
parentId,
ownerId,
};
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: payload,

View File

@@ -14,6 +14,7 @@ export type DiscordThreadChannel = {
name?: string | null;
parentId?: string | null;
parent?: { id?: string; name?: string };
ownerId?: string | null;
};
export type DiscordThreadStarter = {
@@ -63,6 +64,7 @@ export function resolveDiscordThreadChannel(params: {
name: channelInfo?.name ?? undefined,
parentId: channelInfo?.parentId ?? undefined,
parent: undefined,
ownerId: channelInfo?.ownerId ?? undefined,
};
}

View File

@@ -92,13 +92,13 @@ export const ExecApprovalRequestParamsSchema = Type.Object(
{
id: Type.Optional(NonEmptyString),
command: NonEmptyString,
cwd: Type.Optional(Type.String()),
host: Type.Optional(Type.String()),
security: Type.Optional(Type.String()),
ask: Type.Optional(Type.String()),
agentId: Type.Optional(Type.String()),
resolvedPath: Type.Optional(Type.String()),
sessionKey: Type.Optional(Type.String()),
cwd: Type.Optional(Type.Union([Type.String(), Type.Null()])),
host: Type.Optional(Type.Union([Type.String(), Type.Null()])),
security: Type.Optional(Type.Union([Type.String(), Type.Null()])),
ask: Type.Optional(Type.Union([Type.String(), Type.Null()])),
agentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
resolvedPath: Type.Optional(Type.Union([Type.String(), Type.Null()])),
sessionKey: Type.Optional(Type.Union([Type.String(), Type.Null()])),
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
},
{ additionalProperties: false },

View File

@@ -36,16 +36,16 @@ describe("exec approval handlers", () => {
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
// This documents the TypeBox/AJV behavior that caused the Discord exec bug:
// Type.Optional(Type.String()) does NOT accept null, only string or undefined.
it("rejects request with resolvedPath as null", () => {
// Fixed: null is now accepted (Type.Union([Type.String(), Type.Null()]))
// This matches the calling code in bash-tools.exec.ts which passes null.
it("accepts request with resolvedPath as null", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
host: "node",
resolvedPath: null,
};
expect(validateExecApprovalRequestParams(params)).toBe(false);
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
});