Channels: add thread-aware model overrides

This commit is contained in:
Shadow
2026-02-20 19:26:25 -06:00
committed by GitHub
parent ee8dd40509
commit f555835b09
53 changed files with 1379 additions and 1398 deletions

View File

@@ -4,36 +4,23 @@ import {
MessageCreateListener,
MessageReactionAddListener,
MessageReactionRemoveListener,
MessageUpdateListener,
PresenceUpdateListener,
type User,
} from "@buape/carbon";
import type { DmPolicy, GroupPolicy } from "../../config/types.base.js";
import { danger } from "../../globals.js";
import { formatDurationSeconds } from "../../infra/format-time/format-duration.ts";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { readChannelAllowFromStore } from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import {
allowListMatches,
isDiscordGroupAllowedByPolicy,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
resolveDiscordMemberAccessState,
resolveGroupDmAllow,
shouldEmitDiscordReactionNotification,
} from "./allow-list.js";
import {
formatDiscordReactionEmoji,
formatDiscordUserTag,
resolveDiscordSystemLocation,
} from "./format.js";
import { resolveDiscordChannelInfo, resolveDiscordMessageChannelId } from "./message-utils.js";
import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js";
import { resolveDiscordChannelInfo } from "./message-utils.js";
import { setPresence } from "./presence-cache.js";
import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js";
type LoadedConfig = ReturnType<typeof import("../../config/config.js").loadConfig>;
type RuntimeEnv = import("../../runtime.js").RuntimeEnv;
@@ -43,8 +30,6 @@ export type DiscordMessageEvent = Parameters<MessageCreateListener["handle"]>[0]
export type DiscordMessageHandler = (data: DiscordMessageEvent, client: Client) => Promise<void>;
export type DiscordMessageUpdateEvent = Parameters<MessageUpdateListener["handle"]>[0];
type DiscordReactionEvent = Parameters<MessageReactionAddListener["handle"]>[0];
type DiscordReactionListenerParams = {
@@ -56,16 +41,6 @@ type DiscordReactionListenerParams = {
logger: Logger;
};
type DiscordMessageUpdateListenerParams = DiscordReactionListenerParams & {
dmEnabled: boolean;
dmPolicy: DmPolicy;
allowFrom?: string[];
groupPolicy: GroupPolicy;
groupDmEnabled: boolean;
groupDmChannels?: string[];
allowBots: boolean;
};
const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 30_000;
const discordEventQueueLog = createSubsystemLogger("discord/event-queue");
@@ -128,22 +103,6 @@ export class DiscordMessageListener extends MessageCreateListener {
}
}
export class DiscordMessageUpdateListener extends MessageUpdateListener {
constructor(private params: DiscordMessageUpdateListenerParams) {
super();
}
async handle(data: DiscordMessageUpdateEvent, client: Client) {
await runDiscordMessageUpdateHandler({
data,
client,
handlerParams: this.params,
listener: this.constructor.name,
event: this.type,
});
}
}
export class DiscordReactionListener extends MessageReactionAddListener {
constructor(private params: DiscordReactionListenerParams) {
super();
@@ -178,30 +137,6 @@ export class DiscordReactionRemoveListener extends MessageReactionRemoveListener
}
}
async function runDiscordMessageUpdateHandler(params: {
data: DiscordMessageUpdateEvent;
client: Client;
handlerParams: DiscordMessageUpdateListenerParams;
listener: string;
event: string;
}): Promise<void> {
const startedAt = Date.now();
try {
await handleDiscordMessageUpdateEvent({
data: params.data,
client: params.client,
handlerParams: params.handlerParams,
});
} finally {
logSlowDiscordListener({
logger: params.handlerParams.logger,
listener: params.listener,
event: params.event,
durationMs: Date.now() - startedAt,
});
}
}
async function runDiscordReactionHandler(params: {
data: DiscordReactionEvent;
client: Client;
@@ -232,223 +167,6 @@ async function runDiscordReactionHandler(params: {
}
}
async function handleDiscordMessageUpdateEvent(params: {
data: DiscordMessageUpdateEvent;
client: Client;
handlerParams: DiscordMessageUpdateListenerParams;
}) {
const { data, client, handlerParams } = params;
try {
const message = data.message;
if (!message) {
return;
}
const editedTimestamp =
message.editedTimestamp ??
(data as { edited_timestamp?: string | null }).edited_timestamp ??
null;
if (!editedTimestamp) {
return;
}
const author =
message.author ?? (message as { rawData?: { author?: User | null } }).rawData?.author;
const authorId = author?.id ? String(author.id) : "";
if (handlerParams.botUserId && authorId && authorId === handlerParams.botUserId) {
return;
}
if (author?.bot && !handlerParams.allowBots) {
return;
}
const messageChannelId = resolveDiscordMessageChannelId({
message,
eventChannelId: data.channel_id,
});
if (!messageChannelId) {
return;
}
const channelInfo = await resolveDiscordChannelInfo(client, messageChannelId);
const isGuildMessage = Boolean(data.guild_id);
if (!channelInfo && !isGuildMessage) {
return;
}
const isDirectMessage = channelInfo?.type === ChannelType.DM;
const isGroupDm = channelInfo?.type === ChannelType.GroupDM;
if (isDirectMessage) {
if (!handlerParams.dmEnabled) {
return;
}
if (handlerParams.dmPolicy === "disabled") {
return;
}
if (!authorId) {
return;
}
if (handlerParams.dmPolicy !== "open") {
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
const effectiveAllowFrom = [...(handlerParams.allowFrom ?? []), ...storeAllowFrom];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, [
"discord:",
"user:",
"pk:",
]);
if (!allowList) {
return;
}
const authorTag = author ? formatDiscordUserTag(author as User) : undefined;
const allowed = allowListMatches(allowList, {
id: authorId,
name: author?.username ?? undefined,
tag: authorTag,
});
if (!allowed) {
return;
}
}
}
if (isGroupDm) {
if (!handlerParams.groupDmEnabled) {
return;
}
const channelName = channelInfo?.name ?? undefined;
const displayChannelName = channelName ?? messageChannelId;
const displayChannelSlug = displayChannelName ? normalizeDiscordSlug(displayChannelName) : "";
const groupDmAllowed = resolveGroupDmAllow({
channels: handlerParams.groupDmChannels,
channelId: messageChannelId,
channelName: displayChannelName,
channelSlug: displayChannelSlug,
});
if (!groupDmAllowed) {
return;
}
}
let threadParentId: string | undefined;
let threadParentName: string | undefined;
const threadChannel = resolveDiscordThreadChannel({
isGuildMessage,
message,
channelInfo,
messageChannelId,
});
if (threadChannel) {
const parentInfo = await resolveDiscordThreadParentInfo({
client,
threadChannel,
channelInfo,
});
threadParentId = parentInfo.id;
threadParentName = parentInfo.name;
}
const guildInfo = isGuildMessage
? resolveDiscordGuildEntry({
guild: data.guild ?? undefined,
guildEntries: handlerParams.guildEntries,
})
: null;
if (
isGuildMessage &&
handlerParams.guildEntries &&
Object.keys(handlerParams.guildEntries).length > 0 &&
!guildInfo
) {
return;
}
const channelName = channelInfo?.name ?? threadChannel?.name ?? undefined;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const parentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : "";
const channelConfig = isGuildMessage
? resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId: messageChannelId,
channelName,
channelSlug,
parentId: threadParentId,
parentName: threadParentName,
parentSlug,
scope: threadChannel ? "thread" : "channel",
})
: null;
if (isGuildMessage && channelConfig?.enabled === false) {
return;
}
const channelAllowlistConfigured =
Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0;
const channelAllowed = channelConfig?.allowed !== false;
if (
isGuildMessage &&
!isDiscordGroupAllowedByPolicy({
groupPolicy: handlerParams.groupPolicy,
guildAllowlisted: Boolean(guildInfo),
channelAllowlistConfigured,
channelAllowed,
})
) {
return;
}
if (isGuildMessage && channelConfig?.allowed === false) {
return;
}
const memberRoles = (data as { member?: { roles?: string[] } }).member?.roles;
const memberRoleIds = Array.isArray(memberRoles)
? memberRoles.map((roleId) => String(roleId))
: [];
const senderTag = author ? formatDiscordUserTag(author as User) : undefined;
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
channelConfig,
guildInfo,
memberRoleIds,
sender: {
id: authorId,
name: author?.username ?? undefined,
tag: senderTag,
},
});
if (isGuildMessage && hasAccessRestrictions && !memberAllowed) {
return;
}
const route = resolveAgentRoute({
cfg: handlerParams.cfg,
channel: "discord",
accountId: handlerParams.accountId,
guildId: data.guild_id ?? undefined,
memberRoleIds,
peer: {
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
id: isDirectMessage ? authorId : messageChannelId,
},
parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined,
});
const location = resolveDiscordSystemLocation({
isDirectMessage,
isGroupDm,
guild: data.guild ?? undefined,
channelName: channelName ?? messageChannelId,
});
const text = `Discord message edited in ${location}.`;
enqueueSystemEvent(text, {
sessionKey: route.sessionKey,
contextKey: `discord:message:edited:${messageChannelId}:${message.id}`,
});
} catch (err) {
handlerParams.logger.error(danger(`discord message update handler failed: ${String(err)}`));
}
}
async function handleDiscordReactionEvent(params: {
data: DiscordReactionEvent;
client: Client;

View File

@@ -173,7 +173,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null;
const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
const groupSubject = isDirectMessage ? undefined : groupChannel;
const channelTopic = isGuildMessage ? channelInfo?.topic : undefined;
const untrustedChannelMetadata = isGuildMessage
? buildUntrustedChannelMetadata({
source: "discord",
@@ -335,7 +334,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
SenderTag: senderTag,
GroupSubject: groupSubject,
GroupChannel: groupChannel,
ChannelTopic: channelTopic,
UntrustedContext: untrustedChannelMetadata ? [untrustedChannelMetadata] : undefined,
GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined,
GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined,

View File

@@ -1,113 +0,0 @@
import { ChannelType } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { DiscordMessageUpdateListener, type DiscordMessageUpdateEvent } from "./listeners.js";
import { __resetDiscordChannelInfoCacheForTest } from "./message-utils.js";
vi.mock("../../infra/system-events.js", () => ({
enqueueSystemEvent: vi.fn(),
}));
describe("DiscordMessageUpdateListener", () => {
const enqueueSystemEventMock = vi.mocked(enqueueSystemEvent);
beforeEach(() => {
enqueueSystemEventMock.mockReset();
__resetDiscordChannelInfoCacheForTest();
});
it("enqueues system event for edited DMs", async () => {
const cfg = { channels: { discord: {} } } as OpenClawConfig;
const listener = new DiscordMessageUpdateListener({
cfg,
accountId: "default",
runtime: { error: vi.fn() } as unknown as import("../../runtime.js").RuntimeEnv,
botUserId: "bot-1",
guildEntries: undefined,
logger: { error: vi.fn(), warn: vi.fn() } as unknown as ReturnType<
typeof import("../../logging/subsystem.js").createSubsystemLogger
>,
dmEnabled: true,
dmPolicy: "open",
allowFrom: [],
groupPolicy: "open",
groupDmEnabled: false,
groupDmChannels: undefined,
allowBots: false,
});
const message = {
id: "msg-1",
channelId: "dm-1",
editedTimestamp: "2026-02-20T00:00:00.000Z",
author: { id: "user-1", username: "Ada", discriminator: "0001", bot: false },
} as unknown as import("@buape/carbon").Message;
const client = {
fetchChannel: vi.fn(async () => ({ type: ChannelType.DM })),
} as unknown as import("@buape/carbon").Client;
await listener.handle(
{
channel_id: "dm-1",
message,
} as DiscordMessageUpdateEvent,
client,
);
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
"Discord message edited in DM.",
expect.objectContaining({
contextKey: "discord:message:edited:dm-1:msg-1",
}),
);
});
it("skips system event when guild allowlist blocks sender", async () => {
const cfg = { channels: { discord: {} } } as OpenClawConfig;
const listener = new DiscordMessageUpdateListener({
cfg,
accountId: "default",
runtime: { error: vi.fn() } as unknown as import("../../runtime.js").RuntimeEnv,
botUserId: "bot-1",
guildEntries: {
"guild-1": { users: ["user-allowed"] },
},
logger: { error: vi.fn(), warn: vi.fn() } as unknown as ReturnType<
typeof import("../../logging/subsystem.js").createSubsystemLogger
>,
dmEnabled: true,
dmPolicy: "open",
allowFrom: [],
groupPolicy: "open",
groupDmEnabled: false,
groupDmChannels: undefined,
allowBots: false,
});
const message = {
id: "msg-2",
channelId: "channel-1",
editedTimestamp: "2026-02-20T00:00:00.000Z",
author: { id: "user-blocked", username: "Ada", discriminator: "0001", bot: false },
} as unknown as import("@buape/carbon").Message;
const client = {
fetchChannel: vi.fn(async () => ({ type: ChannelType.GuildText })),
} as unknown as import("@buape/carbon").Client;
await listener.handle(
{
channel_id: "channel-1",
guild_id: "guild-1",
guild: { id: "guild-1", name: "Test Guild" },
member: { roles: [] },
message,
} as DiscordMessageUpdateEvent,
client,
);
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
});
});

View File

@@ -59,7 +59,6 @@ import { createDiscordGatewayPlugin } from "./gateway-plugin.js";
import { registerGateway, unregisterGateway } from "./gateway-registry.js";
import {
DiscordMessageListener,
DiscordMessageUpdateListener,
DiscordPresenceListener,
DiscordReactionListener,
DiscordReactionRemoveListener,
@@ -606,24 +605,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
});
registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger));
registerDiscordListener(
client.listeners,
new DiscordMessageUpdateListener({
cfg,
accountId: account.accountId,
runtime,
botUserId,
guildEntries,
logger,
dmEnabled,
dmPolicy,
allowFrom,
groupPolicy,
groupDmEnabled,
groupDmChannels,
allowBots: discordCfg.allowBots ?? false,
}),
);
registerDiscordListener(
client.listeners,
new DiscordReactionListener({

View File

@@ -1,53 +0,0 @@
import { ChannelType } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerDiscordComponentEntries } from "./components-registry.js";
import { sendDiscordComponentMessage } from "./send.components.js";
import { makeDiscordRest } from "./send.test-harness.js";
const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ session: { dmScope: "main" } })));
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
loadConfig: () => loadConfigMock(),
};
});
vi.mock("./components-registry.js", () => ({
registerDiscordComponentEntries: vi.fn(),
}));
describe("sendDiscordComponentMessage", () => {
const registerMock = vi.mocked(registerDiscordComponentEntries);
beforeEach(() => {
vi.clearAllMocks();
});
it("maps DM channel targets to direct-session component entries", async () => {
const { rest, postMock, getMock } = makeDiscordRest();
getMock.mockResolvedValueOnce({
type: ChannelType.DM,
recipients: [{ id: "user-1" }],
});
postMock.mockResolvedValueOnce({ id: "msg1", channel_id: "dm-1" });
await sendDiscordComponentMessage(
"channel:dm-1",
{
blocks: [{ type: "actions", buttons: [{ label: "Tap" }] }],
},
{
rest,
token: "t",
sessionKey: "agent:main:discord:channel:dm-1",
agentId: "main",
},
);
expect(registerMock).toHaveBeenCalledTimes(1);
const args = registerMock.mock.calls[0]?.[0];
expect(args?.entries[0]?.sessionKey).toBe("agent:main:main");
});
});

View File

@@ -8,7 +8,6 @@ import type { APIChannel } from "discord-api-types/v10";
import { ChannelType, Routes } from "discord-api-types/v10";
import { loadConfig } from "../config/config.js";
import { recordChannelActivity } from "../infra/channel-activity.js";
import { buildAgentSessionKey } from "../routing/resolve-route.js";
import { loadWebMedia } from "../web/media.js";
import { resolveDiscordAccount } from "./accounts.js";
import { registerDiscordComponentEntries } from "./components-registry.js";
@@ -30,50 +29,6 @@ import type { DiscordSendResult } from "./send.types.js";
const DISCORD_FORUM_LIKE_TYPES = new Set<number>([ChannelType.GuildForum, ChannelType.GuildMedia]);
type DiscordRecipient = Awaited<ReturnType<typeof parseAndResolveRecipient>>;
function resolveDiscordDmRecipientId(channel?: APIChannel): string | undefined {
if (!channel || channel.type !== ChannelType.DM) {
return undefined;
}
const recipients = (channel as { recipients?: Array<{ id?: string }> }).recipients;
const recipientId = recipients?.[0]?.id;
if (typeof recipientId !== "string") {
return undefined;
}
const trimmed = recipientId.trim();
return trimmed ? trimmed : undefined;
}
function resolveDiscordComponentSessionKey(params: {
cfg: ReturnType<typeof loadConfig>;
accountId: string;
agentId?: string;
sessionKey?: string;
recipient: DiscordRecipient;
channel?: APIChannel;
}): string | undefined {
if (!params.sessionKey || !params.agentId) {
return params.sessionKey;
}
if (params.recipient.kind !== "channel") {
return params.sessionKey;
}
const recipientId = resolveDiscordDmRecipientId(params.channel);
if (!recipientId) {
return params.sessionKey;
}
// DM channel IDs should map back to the user session for component interactions.
return buildAgentSessionKey({
agentId: params.agentId,
channel: "discord",
accountId: params.accountId,
peer: { kind: "direct", id: recipientId },
dmScope: params.cfg.session?.dmScope,
identityLinks: params.cfg.session?.identityLinks,
});
}
function extractComponentAttachmentNames(spec: DiscordComponentMessageSpec): string[] {
const names: string[] = [];
for (const block of spec.blocks ?? []) {
@@ -108,10 +63,9 @@ export async function sendDiscordComponentMessage(
const recipient = await parseAndResolveRecipient(to, opts.accountId);
const { channelId } = await resolveChannelId(rest, recipient, request);
let channel: APIChannel | undefined;
let channelType: number | undefined;
try {
channel = (await rest.get(Routes.channel(channelId))) as APIChannel | undefined;
const channel = (await rest.get(Routes.channel(channelId))) as APIChannel | undefined;
channelType = channel?.type;
} catch {
channelType = undefined;
@@ -121,18 +75,9 @@ export async function sendDiscordComponentMessage(
throw new Error("Discord components are not supported in forum-style channels");
}
const componentSessionKey = resolveDiscordComponentSessionKey({
cfg,
accountId: accountInfo.accountId,
agentId: opts.agentId,
sessionKey: opts.sessionKey,
recipient,
channel,
});
const buildResult = buildDiscordComponentMessage({
spec,
sessionKey: componentSessionKey,
sessionKey: opts.sessionKey,
agentId: opts.agentId,
accountId: accountInfo.accountId,
});