Merge pull request #588 from clawdbot/refactor/discord-thread-context
refactor: consolidate discord thread context handling
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
|
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
|
||||||
- Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc
|
- Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc
|
||||||
- Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro
|
- Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro
|
||||||
|
- Discord: avoid category parent overrides for channel allowlists and refactor thread context helpers. (#588) — thanks @steipete
|
||||||
- Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow
|
- Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow
|
||||||
- Commands: accept /models as an alias for /model.
|
- Commands: accept /models as an alias for /model.
|
||||||
- Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp
|
- Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp
|
||||||
|
|||||||
@@ -216,8 +216,7 @@ export async function maybeRepairGatewayServiceConfig(
|
|||||||
) {
|
) {
|
||||||
audit.issues.push({
|
audit.issues.push({
|
||||||
code: SERVICE_AUDIT_CODES.gatewayEntrypointMismatch,
|
code: SERVICE_AUDIT_CODES.gatewayEntrypointMismatch,
|
||||||
message:
|
message: "Gateway service entrypoint does not match the current install.",
|
||||||
"Gateway service entrypoint does not match the current install.",
|
|
||||||
detail: `${currentEntrypoint} -> ${expectedEntrypoint}`,
|
detail: `${currentEntrypoint} -> ${expectedEntrypoint}`,
|
||||||
level: "recommended",
|
level: "recommended",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -193,6 +193,95 @@ describe("discord tool result dispatch", () => {
|
|||||||
expect(fetchChannel).toHaveBeenCalledTimes(1);
|
expect(fetchChannel).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses channel id allowlists for non-thread channels with categories", async () => {
|
||||||
|
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||||
|
let capturedCtx: { SessionKey?: string } | undefined;
|
||||||
|
dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
|
||||||
|
capturedCtx = ctx;
|
||||||
|
dispatcher.sendFinalReply({ text: "hi" });
|
||||||
|
return { queuedFinal: true, counts: { final: 1 } };
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: "anthropic/claude-opus-4-5",
|
||||||
|
workspace: "/tmp/clawd",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: { store: "/tmp/clawdbot-sessions.json" },
|
||||||
|
discord: {
|
||||||
|
dm: { enabled: true, policy: "open" },
|
||||||
|
guilds: {
|
||||||
|
"*": {
|
||||||
|
requireMention: false,
|
||||||
|
channels: { c1: { allow: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
routing: { allowFrom: [] },
|
||||||
|
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
||||||
|
|
||||||
|
const handler = createDiscordMessageHandler({
|
||||||
|
cfg,
|
||||||
|
discordConfig: cfg.discord,
|
||||||
|
accountId: "default",
|
||||||
|
token: "token",
|
||||||
|
runtime: {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: (code: number): never => {
|
||||||
|
throw new Error(`exit ${code}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
botUserId: "bot-id",
|
||||||
|
guildHistories: new Map(),
|
||||||
|
historyLimit: 0,
|
||||||
|
mediaMaxBytes: 10_000,
|
||||||
|
textLimit: 2000,
|
||||||
|
replyToMode: "off",
|
||||||
|
dmEnabled: true,
|
||||||
|
groupDmEnabled: false,
|
||||||
|
guildEntries: {
|
||||||
|
"*": { requireMention: false, channels: { c1: { allow: true } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
fetchChannel: vi.fn().mockResolvedValue({
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
name: "general",
|
||||||
|
parentId: "category-1",
|
||||||
|
}),
|
||||||
|
rest: { get: vi.fn() },
|
||||||
|
} as unknown as Client;
|
||||||
|
|
||||||
|
await handler(
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
id: "m-category",
|
||||||
|
content: "hello",
|
||||||
|
channelId: "c1",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
type: MessageType.Default,
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
mentionedEveryone: false,
|
||||||
|
mentionedUsers: [],
|
||||||
|
mentionedRoles: [],
|
||||||
|
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
|
||||||
|
},
|
||||||
|
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
|
||||||
|
member: { displayName: "Ada" },
|
||||||
|
guild: { id: "g1", name: "Guild" },
|
||||||
|
guild_id: "g1",
|
||||||
|
},
|
||||||
|
client,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:c1");
|
||||||
|
});
|
||||||
|
|
||||||
it("replies with pairing code and sender id when dmPolicy is pairing", async () => {
|
it("replies with pairing code and sender id when dmPolicy is pairing", async () => {
|
||||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||||
const cfg = {
|
const cfg = {
|
||||||
|
|||||||
@@ -255,6 +255,58 @@ function isDiscordThreadType(type: ChannelType | undefined): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DiscordThreadParentInfo = {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
type?: ChannelType;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveDiscordThreadChannel(params: {
|
||||||
|
isGuildMessage: boolean;
|
||||||
|
message: DiscordMessageEvent["message"];
|
||||||
|
channelInfo: DiscordChannelInfo | null;
|
||||||
|
}): DiscordThreadChannel | null {
|
||||||
|
if (!params.isGuildMessage) return null;
|
||||||
|
const { message, channelInfo } = params;
|
||||||
|
const channel =
|
||||||
|
"channel" in message
|
||||||
|
? (message as { channel?: unknown }).channel
|
||||||
|
: undefined;
|
||||||
|
const isThreadChannel =
|
||||||
|
channel &&
|
||||||
|
typeof channel === "object" &&
|
||||||
|
"isThread" in channel &&
|
||||||
|
typeof (channel as { isThread?: unknown }).isThread === "function" &&
|
||||||
|
(channel as { isThread: () => boolean }).isThread();
|
||||||
|
if (isThreadChannel) return channel as unknown as DiscordThreadChannel;
|
||||||
|
if (!isDiscordThreadType(channelInfo?.type)) return null;
|
||||||
|
return {
|
||||||
|
id: message.channelId,
|
||||||
|
name: channelInfo?.name ?? undefined,
|
||||||
|
parentId: channelInfo?.parentId ?? undefined,
|
||||||
|
parent: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDiscordThreadParentInfo(params: {
|
||||||
|
client: Client;
|
||||||
|
threadChannel: DiscordThreadChannel;
|
||||||
|
channelInfo: DiscordChannelInfo | null;
|
||||||
|
}): Promise<DiscordThreadParentInfo> {
|
||||||
|
const { threadChannel, channelInfo, client } = params;
|
||||||
|
const parentId =
|
||||||
|
threadChannel.parentId ??
|
||||||
|
threadChannel.parent?.id ??
|
||||||
|
channelInfo?.parentId ??
|
||||||
|
undefined;
|
||||||
|
if (!parentId) return {};
|
||||||
|
let parentName = threadChannel.parent?.name;
|
||||||
|
const parentInfo = await resolveDiscordChannelInfo(client, parentId);
|
||||||
|
parentName = parentName ?? parentInfo?.name;
|
||||||
|
const parentType = parentInfo?.type;
|
||||||
|
return { id: parentId, name: parentName, type: parentType };
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveDiscordReplyTarget(opts: {
|
export function resolveDiscordReplyTarget(opts: {
|
||||||
replyToMode: ReplyToMode;
|
replyToMode: ReplyToMode;
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
@@ -690,37 +742,23 @@ export function createDiscordMessageHandler(params: {
|
|||||||
"name" in message.channel
|
"name" in message.channel
|
||||||
? message.channel.name
|
? message.channel.name
|
||||||
: undefined);
|
: undefined);
|
||||||
const isThreadChannel =
|
const threadChannel = resolveDiscordThreadChannel({
|
||||||
isGuildMessage &&
|
isGuildMessage,
|
||||||
message.channel &&
|
message,
|
||||||
"isThread" in message.channel &&
|
channelInfo,
|
||||||
message.channel.isThread();
|
});
|
||||||
const isThreadByType =
|
let threadParentId: string | undefined;
|
||||||
isGuildMessage && isDiscordThreadType(channelInfo?.type);
|
let threadParentName: string | undefined;
|
||||||
const threadChannel: DiscordThreadChannel | null = isThreadChannel
|
|
||||||
? (message.channel as DiscordThreadChannel)
|
|
||||||
: isThreadByType
|
|
||||||
? {
|
|
||||||
id: message.channelId,
|
|
||||||
name: channelInfo?.name ?? undefined,
|
|
||||||
parentId: channelInfo?.parentId ?? undefined,
|
|
||||||
parent: undefined,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
const threadParentId =
|
|
||||||
threadChannel?.parentId ??
|
|
||||||
threadChannel?.parent?.id ??
|
|
||||||
channelInfo?.parentId ??
|
|
||||||
undefined;
|
|
||||||
let threadParentName = threadChannel?.parent?.name;
|
|
||||||
let threadParentType: ChannelType | undefined;
|
let threadParentType: ChannelType | undefined;
|
||||||
if (threadChannel && threadParentId) {
|
if (threadChannel) {
|
||||||
const parentInfo = await resolveDiscordChannelInfo(
|
const parentInfo = await resolveDiscordThreadParentInfo({
|
||||||
client,
|
client,
|
||||||
threadParentId,
|
threadChannel,
|
||||||
);
|
channelInfo,
|
||||||
threadParentName = threadParentName ?? parentInfo?.name;
|
});
|
||||||
threadParentType = parentInfo?.type;
|
threadParentId = parentInfo.id;
|
||||||
|
threadParentName = parentInfo.name;
|
||||||
|
threadParentType = parentInfo.type;
|
||||||
}
|
}
|
||||||
const threadName = threadChannel?.name;
|
const threadName = threadChannel?.name;
|
||||||
const configChannelName = threadParentName ?? channelName;
|
const configChannelName = threadParentName ?? channelName;
|
||||||
|
|||||||
Reference in New Issue
Block a user