// Discord tests cover message handler.preflight plugin behavior.
import { afterEach, beforeAll, beforeEach, describe, expect, it, onTestFinished, vi } from "vitest";
import { ChannelType, MessageType } from "../internal/discord.js";
import { createPartialDiscordChannelWithThrowingGetters } from "../test-support/partial-channel.js";

const transcribeFirstAudioMock = vi.hoisted(() => vi.fn());
const fetchPluralKitMessageInfoMock = vi.hoisted(() => vi.fn());
const resolveDiscordDmCommandAccessMock = vi.hoisted(() => vi.fn());
const handleDiscordDmCommandDecisionMock = vi.hoisted(() => vi.fn(async () => {}));
const saveRemoteMediaMock = vi.hoisted(() => vi.fn());

vi.mock("../pluralkit.js", () => ({
  fetchPluralKitMessageInfo: (...args: unknown[]) => fetchPluralKitMessageInfoMock(...args),
}));
vi.mock("openclaw/plugin-sdk/media-understanding-runtime", async (importOriginal) => {
  const actual =
    await importOriginal<typeof import("openclaw/plugin-sdk/media-understanding-runtime")>();
  return {
    ...actual,
    createChannelPreflightAudio: (
      params: Parameters<typeof actual.createChannelPreflightAudio>[0],
    ) =>
      actual.createChannelPreflightAudio({
        ...params,
        transcribeFirstAudio: transcribeFirstAudioMock,
      }),
  };
});
vi.mock("./dm-command-auth.js", async (importOriginal) => ({
  ...(await importOriginal<typeof import("./dm-command-auth.js")>()),
  resolveDiscordDmCommandAccess: resolveDiscordDmCommandAccessMock,
}));
vi.mock("./dm-command-decision.js", () => ({
  handleDiscordDmCommandDecision: handleDiscordDmCommandDecisionMock,
}));
import {
  isRecentOutboundMessageIdentity,
  recordOutboundMessageIdentity,
} from "openclaw/plugin-sdk/channel-outbound";
import {
  testing as sessionBindingTesting,
  registerSessionBindingAdapter,
} from "openclaw/plugin-sdk/conversation-runtime";
import { saveRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
import {
  createDiscordMessage,
  createDiscordPreflightArgs,
  createGuildEvent,
  createGuildTextClient,
  DEFAULT_PREFLIGHT_CFG,
  type DiscordClient,
  type DiscordConfig,
  type DiscordMessageEvent,
} from "./message-handler.preflight.test-helpers.js";

vi.mock("openclaw/plugin-sdk/media-runtime", { spy: true });
let preflightDiscordMessage: typeof import("./message-handler.preflight.js").preflightDiscordMessage;
let resolvePreflightMentionRequirement: typeof import("./message-handler.preflight.js").resolvePreflightMentionRequirement;
let shouldIgnoreBoundThreadWebhookMessage: typeof import("./message-handler.preflight.js").shouldIgnoreBoundThreadWebhookMessage;
let createThreadBindingManager: typeof import("./thread-bindings.js").createThreadBindingManager;

beforeAll(async () => {
  ({
    preflightDiscordMessage,
    resolvePreflightMentionRequirement,
    shouldIgnoreBoundThreadWebhookMessage,
  } = await import("./message-handler.preflight.js"));
  ({ createThreadBindingManager } = await import("./thread-bindings.js"));
});

beforeEach(() => {
  fetchPluralKitMessageInfoMock.mockReset();
  saveRemoteMediaMock.mockReset();
  saveRemoteMediaMock.mockImplementation(
    async (options: { fallbackContentType?: string; filePathHint?: string }) => ({
      id: "test-media",
      path: `/tmp/openclaw-discord-test/${options.filePathHint ?? "media"}`,
      size: 5,
      contentType: options.fallbackContentType,
    }),
  );
  vi.mocked(saveRemoteMedia).mockImplementation((...args) => saveRemoteMediaMock(...args));
});

function createThreadBinding(
  overrides?: Partial<import("openclaw/plugin-sdk/conversation-runtime").SessionBindingRecord>,
) {
  return {
    bindingId: "default:thread-1",
    targetSessionKey: "agent:main:subagent:child-1",
    targetKind: "subagent",
    conversation: {
      channel: "discord",
      accountId: "default",
      conversationId: "thread-1",
      parentConversationId: "parent-1",
    },
    status: "active",
    boundAt: 1,
    metadata: {
      agentId: "main",
      boundBy: "test",
      webhookId: "wh-1",
      webhookToken: "tok-1",
    },
    ...overrides,
  } satisfies import("openclaw/plugin-sdk/conversation-runtime").SessionBindingRecord;
}

function createPreflightArgs(params: {
  cfg: import("openclaw/plugin-sdk/config-contracts").OpenClawConfig;
  discordConfig: DiscordConfig;
  data: DiscordMessageEvent;
  client: DiscordClient;
}): Parameters<typeof preflightDiscordMessage>[0] {
  return createDiscordPreflightArgs(params);
}

type DiscordPreflightResult = NonNullable<Awaited<ReturnType<typeof preflightDiscordMessage>>>;

function expectPreflightResult(
  result: Awaited<ReturnType<typeof preflightDiscordMessage>>,
): DiscordPreflightResult {
  if (result === null) {
    throw new Error("Expected Discord preflight result");
  }
  return result;
}

type MockWithCalls = { mock: { calls: unknown[][] } };

function firstMockArg(mock: MockWithCalls, label: string) {
  const call = mock.mock.calls.at(0);
  if (!call) {
    throw new Error(`expected ${label} call`);
  }
  return call[0];
}

function createThreadClient(params: { threadId: string; parentId: string }): DiscordClient {
  return {
    fetchChannel: async (channelId: string) => {
      if (channelId === params.threadId) {
        return {
          id: params.threadId,
          type: ChannelType.PublicThread,
          name: "focus",
          parentId: params.parentId,
          ownerId: "owner-1",
        };
      }
      if (channelId === params.parentId) {
        return {
          id: params.parentId,
          type: ChannelType.GuildText,
          name: "general",
        };
      }
      return null;
    },
  } as unknown as DiscordClient;
}

function createDmClient(channelId: string): DiscordClient {
  return {
    fetchChannel: async (id: string) => {
      if (id === channelId) {
        return {
          id: channelId,
          type: ChannelType.DM,
        };
      }
      return null;
    },
  } as unknown as DiscordClient;
}

function createMissingChannelClient(): DiscordClient {
  return {
    fetchChannel: async () => null,
  } as unknown as DiscordClient;
}

async function runThreadBoundPreflight(params: {
  threadId: string;
  parentId: string;
  message: import("../internal/discord.js").Message;
  threadBinding: import("openclaw/plugin-sdk/conversation-runtime").SessionBindingRecord;
  discordConfig: DiscordConfig;
  registerBindingAdapter?: boolean;
}) {
  if (params.registerBindingAdapter) {
    registerSessionBindingAdapter({
      channel: "discord",
      accountId: "default",
      listBySession: () => [],
      resolveByConversation: (ref) =>
        ref.conversationId === params.threadId ? params.threadBinding : null,
    });
  }

  const client = createThreadClient({
    threadId: params.threadId,
    parentId: params.parentId,
  });

  return preflightDiscordMessage({
    ...createPreflightArgs({
      cfg: DEFAULT_PREFLIGHT_CFG,
      discordConfig: params.discordConfig,
      data: createGuildEvent({
        channelId: params.threadId,
        guildId: "guild-1",
        author: params.message.author,
        message: params.message,
      }),
      client,
    }),
    threadBindings: {
      getByThreadId: (id: string) => (id === params.threadId ? params.threadBinding : undefined),
    } as import("./thread-bindings.js").ThreadBindingManager,
  });
}

async function runGuildPreflight(params: {
  channelId: string;
  guildId: string;
  message: import("../internal/discord.js").Message;
  discordConfig: DiscordConfig;
  cfg?: import("openclaw/plugin-sdk/config-contracts").OpenClawConfig;
  guildEntries?: Parameters<typeof preflightDiscordMessage>[0]["guildEntries"];
  includeGuildObject?: boolean;
  abortSignal?: AbortSignal;
}) {
  return preflightDiscordMessage({
    ...createPreflightArgs({
      cfg: params.cfg ?? DEFAULT_PREFLIGHT_CFG,
      discordConfig: params.discordConfig,
      data: createGuildEvent({
        channelId: params.channelId,
        guildId: params.guildId,
        author: params.message.author,
        message: params.message,
        includeGuildObject: params.includeGuildObject,
      }),
      client: createGuildTextClient(params.channelId),
    }),
    guildEntries: params.guildEntries,
    abortSignal: params.abortSignal,
  });
}

async function runDmPreflight(params: {
  channelId: string;
  message: import("../internal/discord.js").Message;
  discordConfig: DiscordConfig;
}) {
  return preflightDiscordMessage({
    ...createPreflightArgs({
      cfg: DEFAULT_PREFLIGHT_CFG,
      discordConfig: params.discordConfig,
      data: {
        channel_id: params.channelId,
        author: params.message.author,
        message: params.message,
      } as DiscordMessageEvent,
      client: createDmClient(params.channelId),
    }),
  });
}

async function runUnresolvedDmPreflight(params: {
  cfg?: import("openclaw/plugin-sdk/config-contracts").OpenClawConfig;
  channelId: string;
  message: import("../internal/discord.js").Message;
  discordConfig: DiscordConfig;
}) {
  return preflightDiscordMessage({
    ...createPreflightArgs({
      cfg: params.cfg ?? DEFAULT_PREFLIGHT_CFG,
      discordConfig: params.discordConfig,
      data: {
        channel_id: params.channelId,
        author: params.message.author,
        message: params.message,
      } as DiscordMessageEvent,
      client: createMissingChannelClient(),
    }),
  });
}

async function runMentionOnlyBotPreflight(params: {
  channelId: string;
  guildId: string;
  message: import("../internal/discord.js").Message;
}) {
  return runGuildPreflight({
    channelId: params.channelId,
    guildId: params.guildId,
    message: params.message,
    discordConfig: {
      allowBots: "mentions",
    } as DiscordConfig,
  });
}

async function runIgnoreOtherMentionsPreflight(params: {
  channelId: string;
  guildId: string;
  message: import("../internal/discord.js").Message;
}) {
  return runGuildPreflight({
    channelId: params.channelId,
    guildId: params.guildId,
    message: params.message,
    discordConfig: {} as DiscordConfig,
    guildEntries: {
      [params.guildId]: {
        requireMention: false,
        ignoreOtherMentions: true,
      },
    },
  });
}

describe("resolvePreflightMentionRequirement", () => {
  it("requires mention when config requires mention and thread is not bound", () => {
    expect(
      resolvePreflightMentionRequirement({
        shouldRequireMention: true,
        bypassMentionRequirement: false,
      }),
    ).toBe(true);
  });

  it("disables mention requirement when the route explicitly bypasses mentions", () => {
    expect(
      resolvePreflightMentionRequirement({
        shouldRequireMention: true,
        bypassMentionRequirement: true,
      }),
    ).toBe(false);
  });

  it("keeps mention requirement disabled when config already disables it", () => {
    expect(
      resolvePreflightMentionRequirement({
        shouldRequireMention: false,
        bypassMentionRequirement: false,
      }),
    ).toBe(false);
  });
});

describe("preflightDiscordMessage", () => {
  beforeEach(() => {
    sessionBindingTesting.resetSessionBindingAdaptersForTests();
    transcribeFirstAudioMock.mockReset();
    resolveDiscordDmCommandAccessMock.mockReset();
    resolveDiscordDmCommandAccessMock.mockResolvedValue({
      senderAccess: {
        allowed: true,
        decision: "allow",
        reasonCode: "dm_policy_allowlisted",
      },
      commandAccess: {
        authorized: true,
      },
    });
    handleDiscordDmCommandDecisionMock.mockReset();
    handleDiscordDmCommandDecisionMock.mockResolvedValue(undefined);
  });

  it("drops bound-thread bot system messages to prevent ACP self-loop", async () => {
    const threadBinding = createThreadBinding({
      targetKind: "session",
      targetSessionKey: "agent:main:acp:discord-thread-1",
    });
    const threadId = "thread-system-1";
    const parentId = "channel-parent-1";
    const message = createDiscordMessage({
      id: "m-system-1",
      channelId: threadId,
      content:
        "⚙️ codex-acp session active (auto-unfocus in 24h). Messages here go directly to this session.",
      author: {
        id: "relay-bot-1",
        bot: true,
        username: "OpenClaw",
      },
    });

    const result = await runThreadBoundPreflight({
      threadId,
      parentId,
      message,
      threadBinding,
      discordConfig: {
        allowBots: true,
      } as DiscordConfig,
    });

    expect(result).toBeNull();
  });

  it("restores direct-message bindings by user target instead of DM channel id", async () => {
    registerSessionBindingAdapter({
      channel: "discord",
      accountId: "default",
      listBySession: () => [],
      resolveByConversation: (ref) =>
        ref.conversationId === "user:user-1"
          ? createThreadBinding({
              conversation: {
                channel: "discord",
                accountId: "default",
                conversationId: "user:user-1",
              },
              metadata: {
                pluginBindingOwner: "plugin",
                pluginId: "openclaw-codex-app-server",
                pluginRoot: "/Users/huntharo/github/openclaw-app-server",
              },
            })
          : null,
    });

    const result = await runDmPreflight({
      channelId: "dm-channel-1",
      message: createDiscordMessage({
        id: "m-dm-1",
        channelId: "dm-channel-1",
        content: "who are you",
        author: {
          id: "user-1",
          bot: false,
          username: "alice",
        },
      }),
      discordConfig: {
        allowBots: true,
        dmPolicy: "open",
      } as DiscordConfig,
    });

    const preflight = expectPreflightResult(result);
    expect(preflight.threadBinding).toEqual({
      bindingId: "default:thread-1",
      targetSessionKey: "agent:main:subagent:child-1",
      targetKind: "subagent",
      conversation: {
        channel: "discord",
        accountId: "default",
        conversationId: "user:user-1",
      },
      status: "active",
      boundAt: 1,
      metadata: {
        pluginBindingOwner: "plugin",
        pluginId: "openclaw-codex-app-server",
        pluginRoot: "/Users/huntharo/github/openclaw-app-server",
      },
    });
  });

  it("ignores stale route-shaped channel bindings when config now routes to another agent", async () => {
    const channelId = "channel-stale-route";
    registerSessionBindingAdapter({
      channel: "discord",
      accountId: "default",
      listBySession: () => [],
      resolveByConversation: (ref) =>
        ref.conversationId === channelId
          ? createThreadBinding({
              bindingId: "default:channel-stale-route",
              targetKind: "session",
              targetSessionKey: `agent:oldagent:discord:channel:${channelId}`,
              conversation: {
                channel: "discord",
                accountId: "default",
                conversationId: channelId,
              },
              metadata: undefined,
            })
          : null,
    });

    const result = await runGuildPreflight({
      channelId,
      guildId: "guild-stale-route",
      message: createDiscordMessage({
        id: "m-stale-route",
        channelId,
        content: "which agent is this?",
        author: {
          id: "user-1",
          bot: false,
          username: "alice",
        },
      }),
      cfg: {
        agents: {
          list: [{ id: "newagent" }],
        },
        bindings: [
          {
            agentId: "newagent",
            match: {
              channel: "discord",
              accountId: "default",
              peer: { kind: "channel", id: channelId },
            },
          },
        ],
        channels: {
          discord: {},
        },
      },
      discordConfig: {
        allowBots: true,
      } as DiscordConfig,
      guildEntries: {
        "guild-stale-route": {
          channels: {
            [channelId]: {
              enabled: true,
              requireMention: false,
            },
          },
        },
      },
    });

    const preflight = expectPreflightResult(result);
    expect(preflight.route.agentId).toBe("newagent");
    expect(preflight.route.sessionKey).toBe(`agent:newagent:discord:channel:${channelId}`);
    expect(preflight.boundSessionKey).toBeUndefined();
    expect(preflight.threadBinding).toBeUndefined();
  });

  it("preflights direct-message voice notes without mention gating", async () => {
    transcribeFirstAudioMock.mockResolvedValue("hello openclaw from dm audio");

    const result = await runDmPreflight({
      channelId: "dm-channel-audio-1",
      message: createDiscordMessage({
        id: "m-dm-audio-1",
        channelId: "dm-channel-audio-1",
        content: "",
        attachments: [
          {
            id: "att-dm-audio-1",
            url: "https://cdn.discordapp.com/attachments/voice.ogg",
            content_type: "audio/ogg",
            filename: "voice.ogg",
          },
        ],
        author: {
          id: "user-1",
          bot: false,
          username: "alice",
        },
      }),
      discordConfig: {
        dmPolicy: "open",
      } as DiscordConfig,
    });

    expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
    const dmAudioCall = firstMockArg(transcribeFirstAudioMock, "transcribeFirstAudio") as
      | { ctx?: { media?: unknown } }
      | undefined;
    expect(dmAudioCall?.ctx?.media).toEqual([
      {
        url: "https://cdn.discordapp.com/attachments/voice.ogg",
        contentType: "audio/ogg",
      },
    ]);
    const preflight = expectPreflightResult(result);
    expect(preflight.isDirectMessage).toBe(true);
    expect(preflight.preflightAudioTranscript).toBe("hello openclaw from dm audio");
  });

  it("downloads attachments during preflight, before the message reaches the run queue", async () => {
    // Regression for #96165: Discord CDN attachment URLs expire. Downloading
    // must happen at receipt time (preflight), not after a possible run-queue
    // delay, or queued messages lose their media.
    const result = await runDmPreflight({
      channelId: "dm-channel-image-1",
      message: createDiscordMessage({
        id: "m-dm-image-1",
        channelId: "dm-channel-image-1",
        content: "look at this",
        attachments: [
          {
            id: "att-dm-image-1",
            url: "https://cdn.discordapp.com/attachments/1/photo.png?ex=expired",
            content_type: "image/png",
            filename: "photo.png",
          },
        ],
        author: {
          id: "user-1",
          bot: false,
          username: "alice",
        },
      }),
      discordConfig: {
        dmPolicy: "open",
      } as DiscordConfig,
    });

    expect(saveRemoteMediaMock).toHaveBeenCalledTimes(1);
    const preflight = expectPreflightResult(result);
    expect(preflight.preparedMedia).toEqual([
      {
        path: "/tmp/openclaw-discord-test/photo.png",
        contentType: "image/png",
      },
    ]);
  });

  it("keeps no-guild messages direct when channel lookup is unavailable", async () => {
    const result = await runUnresolvedDmPreflight({
      cfg: {
        ...DEFAULT_PREFLIGHT_CFG,
        session: {
          ...DEFAULT_PREFLIGHT_CFG.session,
          dmScope: "per-channel-peer",
        },
      },
      channelId: "dm-channel-unresolved-1",
      message: createDiscordMessage({
        id: "m-dm-unresolved-1",
        channelId: "dm-channel-unresolved-1",
        content: "hello from a degraded dm",
        author: {
          id: "user-1",
          bot: false,
          username: "alice",
        },
      }),
      discordConfig: {
        dmPolicy: "open",
      } as DiscordConfig,
    });

    const preflight = expectPreflightResult(result);
    expect(preflight.channelInfo).toBeNull();
    expect(preflight.isDirectMessage).toBe(true);
    expect(preflight.isGroupDm).toBe(false);
    expect(preflight.route.sessionKey).toBe("agent:main:discord:direct:user-1");
  });

  it("falls back to the default discord account for omitted-account dm authorization", async () => {
    const message = createDiscordMessage({
      id: "m-dm-default-account",
      channelId: "dm-channel-default-account",
      content: "who are you",
      author: {
        id: "user-1",
        bot: false,
        username: "alice",
      },
    });

    await preflightDiscordMessage({
      ...createPreflightArgs({
        cfg: {
          ...DEFAULT_PREFLIGHT_CFG,
          channels: {
            discord: {
              defaultAccount: "work",
              accounts: {
                default: {
                  token: "token-default",
                },
                work: {
                  token: "token-work",
                },
              },
            },
          },
        },
        discordConfig: {
          defaultAccount: "work",
          dmPolicy: "allowlist",
        } as DiscordConfig,
        data: {
          channel_id: "dm-channel-default-account",
          author: message.author,
          message,
        } as DiscordMessageEvent,
        client: createDmClient("dm-channel-default-account"),
      }),
    });

    expect(resolveDiscordDmCommandAccessMock).toHaveBeenCalledTimes(1);
    expect(
      (
        firstMockArg(resolveDiscordDmCommandAccessMock, "resolveDiscordDmCommandAccess") as
          | { accountId?: unknown }
          | undefined
      )?.accountId,
    ).toBe("default");
  });

  it("suppresses repeated bot messages before downloading attachments (#58789)", async () => {
    const channelId = "channel-bot-loop";
    const guildId = "guild-bot-loop";
    const senderBotId = "relay-bot-1";
    const messageTimestamp = "2026-05-13T05:00:00.000Z";

    const message = createDiscordMessage({
      id: "m-loop-1",
      channelId,
      content: "chatter <@openclaw-bot>",
      mentionedUsers: [{ id: "openclaw-bot" }],
      author: { id: senderBotId, bot: true, username: "Relay" },
      timestamp: messageTimestamp,
    });
    const result = await preflightDiscordMessage(
      createPreflightArgs({
        cfg: DEFAULT_PREFLIGHT_CFG,
        discordConfig: {
          allowBots: true,
          botLoopProtection: {
            enabled: true,
            maxEventsPerWindow: 1,
            cooldownSeconds: 60,
          },
        } as DiscordConfig,
        data: createGuildEvent({
          channelId,
          guildId,
          author: message.author,
          message,
        }),
        client: createGuildTextClient(channelId),
      }),
    );

    expect(result).not.toBeNull();

    const repeatedMessage = createDiscordMessage({
      id: "m-loop-2",
      channelId,
      content: "more chatter <@openclaw-bot>",
      mentionedUsers: [{ id: "openclaw-bot" }],
      attachments: [
        {
          id: "att-loop",
          url: "https://cdn.discordapp.com/attachments/1/loop.png",
          content_type: "image/png",
          filename: "loop.png",
        },
      ],
      author: { id: senderBotId, bot: true, username: "Relay" },
      timestamp: "2026-05-13T05:00:00.001Z",
    });

    expect(
      await runGuildPreflight({
        channelId,
        guildId,
        message: repeatedMessage,
        discordConfig: {
          allowBots: true,
          botLoopProtection: {
            enabled: true,
            maxEventsPerWindow: 1,
            cooldownSeconds: 60,
          },
        } as DiscordConfig,
      }),
    ).toBeNull();
    expect(saveRemoteMediaMock).not.toHaveBeenCalled();
  });

  it("passes generic channel defaults for Discord bot loop budgets", async () => {
    const channelId = "channel-bot-loop-defaults";
    const guildId = "guild-bot-loop-defaults";
    const discordConfig = { allowBots: true } as DiscordConfig;
    const runBotMessage = async (id: string) =>
      await runGuildPreflight({
        channelId,
        guildId,
        message: createDiscordMessage({
          id,
          channelId,
          content: "relay <@openclaw-bot>",
          mentionedUsers: [{ id: "openclaw-bot" }],
          author: { id: "relay-bot-defaults", bot: true, username: "Relay" },
        }),
        discordConfig,
        cfg: {
          ...DEFAULT_PREFLIGHT_CFG,
          channels: {
            defaults: {
              botLoopProtection: {
                maxEventsPerWindow: 1,
                cooldownSeconds: 60,
              },
            },
          },
        },
      });

    expect(await runBotMessage("m-loop-default-1")).not.toBeNull();
    expect(await runBotMessage("m-loop-default-2")).toBeNull();
  });

  it("does not count bot messages that earlier preflight gates drop (#58789)", async () => {
    const channelId = "channel-bot-loop-dropped";
    const guildId = "guild-bot-loop-dropped";
    const senderBotId = "relay-bot-dropped";
    const discordConfig = {
      allowBots: true,
      botLoopProtection: {
        enabled: true,
        maxEventsPerWindow: 1,
        cooldownSeconds: 60,
      },
    } as DiscordConfig;
    const guildEntries = {
      [guildId]: {
        requireMention: false,
        ignoreOtherMentions: true,
      },
    };

    for (const messageId of ["m-dropped-1", "m-dropped-2"]) {
      const message = createDiscordMessage({
        id: messageId,
        channelId,
        content: `cc <@999> ${messageId}`,
        mentionedUsers: [{ id: "999" }],
        author: { id: senderBotId, bot: true, username: "Relay" },
      });

      expect(
        await runGuildPreflight({
          channelId,
          guildId,
          message,
          discordConfig,
          guildEntries,
        }),
      ).toBeNull();
    }

    const validMessage = createDiscordMessage({
      id: "m-valid-after-dropped",
      channelId,
      content: "legitimate bot relay",
      author: { id: senderBotId, bot: true, username: "Relay" },
    });

    expect(
      await runGuildPreflight({
        channelId,
        guildId,
        message: validMessage,
        discordConfig,
        guildEntries,
      }),
    ).not.toBeNull();
  });

  it("keeps bound-thread regular bot messages flowing when allowBots=true", async () => {
    const threadBinding = createThreadBinding({
      targetKind: "session",
      targetSessionKey: "agent:main:acp:discord-thread-1",
    });
    const threadId = "thread-bot-regular-1";
    const parentId = "channel-parent-regular-1";
    const message = createDiscordMessage({
      id: "m-bot-regular-1",
      channelId: threadId,
      content: "here is tool output chunk",
      author: {
        id: "relay-bot-1",
        bot: true,
        username: "Relay",
      },
    });

    const result = await runThreadBoundPreflight({
      threadId,
      parentId,
      message,
      threadBinding,
      discordConfig: {
        allowBots: true,
      } as DiscordConfig,
      registerBindingAdapter: true,
    });

    expect(expectPreflightResult(result).boundSessionKey).toBe(threadBinding.targetSessionKey);
  });

  it("drops hydrated bound-thread webhook copies after fetching an empty payload", async () => {
    const threadBinding = createThreadBinding({
      targetKind: "session",
      targetSessionKey: "agent:main:acp:discord-thread-1",
    });
    const threadId = "thread-webhook-hydrated-1";
    const parentId = "channel-parent-webhook-hydrated-1";
    const message = createDiscordMessage({
      id: "m-webhook-hydrated-1",
      channelId: threadId,
      content: "",
      author: {
        id: "relay-bot-1",
        bot: true,
        username: "Relay",
      },
    });
    const restGet = vi.fn(async () => ({
      id: message.id,
      content: "webhook relay",
      webhook_id: "wh-1",
      attachments: [],
      embeds: [],
      mentions: [],
      mention_roles: [],
      mention_everyone: false,
      author: {
        id: "relay-bot-1",
        username: "Relay",
        bot: true,
      },
    }));
    const client = Object.assign(createThreadClient({ threadId, parentId }), {
      rest: {
        get: restGet,
      },
    }) as unknown as DiscordClient;

    const result = await preflightDiscordMessage({
      ...createPreflightArgs({
        cfg: DEFAULT_PREFLIGHT_CFG,
        discordConfig: {
          allowBots: true,
        } as DiscordConfig,
        data: createGuildEvent({
          channelId: threadId,
          guildId: "guild-1",
          author: message.author,
          message,
        }),
        client,
      }),
      threadBindings: {
        getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined),
      } as import("./thread-bindings.js").ThreadBindingManager,
    });

    expect(restGet).toHaveBeenCalledTimes(1);
    expect(result).toBeNull();
  });

  it("drops bound-thread webhook copies from other webhook ids", async () => {
    const threadBinding = createThreadBinding({
      targetKind: "session",
      targetSessionKey: "agent:main:acp:discord-thread-1",
    });
    const threadId = "thread-webhook-proxy-1";
    const parentId = "channel-parent-webhook-proxy-1";
    const message = createDiscordMessage({
      id: "m-webhook-proxy-1",
      channelId: threadId,
      content: "proxied user message",
      webhookId: "pluralkit-webhook-1",
      author: {
        id: "relay-bot-1",
        bot: true,
        username: "Proxy",
      },
    });

    const result = await runThreadBoundPreflight({
      threadId,
      parentId,
      message,
      threadBinding,
      discordConfig: {
        allowBots: true,
      } as DiscordConfig,
    });

    expect(result).toBeNull();
  });

  it("canonicalizes PluralKit webhook messages to the original Discord message id", async () => {
    const abortController = new AbortController();
    fetchPluralKitMessageInfoMock.mockResolvedValue({
      id: "proxy-456",
      original: "orig-123",
      member: { id: "member-1", name: "Echo" },
      system: { id: "system-1", name: "System" },
    });

    const result = await runGuildPreflight({
      channelId: "c1",
      guildId: "g1",
      message: createDiscordMessage({
        id: "proxy-456",
        channelId: "c1",
        content: "<@openclaw-bot> hello",
        webhookId: "pluralkit-webhook-1",
        author: {
          id: "webhook-author",
          bot: true,
          username: "PluralKit",
        },
        mentionedUsers: [{ id: "openclaw-bot" }],
      }),
      discordConfig: {
        pluralkit: { enabled: true },
      } as DiscordConfig,
      abortSignal: abortController.signal,
    });

    expect(fetchPluralKitMessageInfoMock).toHaveBeenCalledTimes(1);
    const pluralKitCall = firstMockArg(
      fetchPluralKitMessageInfoMock,
      "fetchPluralKitMessageInfo",
    ) as { messageId?: unknown; config?: { enabled?: unknown }; signal?: AbortSignal } | undefined;
    expect(pluralKitCall?.messageId).toBe("proxy-456");
    expect(pluralKitCall?.config?.enabled).toBe(true);
    expect(pluralKitCall?.signal).toBe(abortController.signal);
    const preflight = expectPreflightResult(result);
    expect(preflight.sender.isPluralKit).toBe(true);
    expect(preflight.canonicalMessageId).toBe("orig-123");
  });

  it("skips PluralKit lookup for ordinary non-webhook messages", async () => {
    const result = await runGuildPreflight({
      channelId: "c1",
      guildId: "g1",
      message: createDiscordMessage({
        id: "ordinary-human-1",
        channelId: "c1",
        content: "<@openclaw-bot> hello",
        author: {
          id: "human-1",
          bot: false,
          username: "Human",
        },
        mentionedUsers: [{ id: "openclaw-bot" }],
      }),
      discordConfig: {
        pluralkit: { enabled: true },
      } as DiscordConfig,
    });

    expectPreflightResult(result);
    expect(fetchPluralKitMessageInfoMock).not.toHaveBeenCalled();
  });

  it("skips PluralKit lookup for allowed non-webhook bot messages", async () => {
    const result = await runGuildPreflight({
      channelId: "c1",
      guildId: "g1",
      message: createDiscordMessage({
        id: "ordinary-bot-1",
        channelId: "c1",
        content: "<@openclaw-bot> hello",
        author: {
          id: "bot-1",
          bot: true,
          username: "Bot",
        },
        mentionedUsers: [{ id: "openclaw-bot" }],
      }),
      discordConfig: {
        allowBots: true,
        pluralkit: { enabled: true },
      } as DiscordConfig,
    });

    const preflight = expectPreflightResult(result);
    expect(preflight.sender.isPluralKit).toBe(false);
    expect(fetchPluralKitMessageInfoMock).not.toHaveBeenCalled();
  });

  it("uses the resolved PluralKit member id when creating DM pairing requests", async () => {
    fetchPluralKitMessageInfoMock.mockResolvedValue({
      id: "proxy-dm-1",
      original: "orig-dm-1",
      member: { id: "pk-member-1", name: "Echo" },
      system: { id: "system-1", name: "System" },
    });
    resolveDiscordDmCommandAccessMock.mockResolvedValue({
      senderAccess: {
        allowed: false,
        decision: "pairing",
        reasonCode: "dm_policy_pairing_required",
      },
      commandAccess: {
        authorized: false,
      },
    });

    const result = await runDmPreflight({
      channelId: "dm-channel-pk-1",
      message: createDiscordMessage({
        id: "proxy-dm-1",
        channelId: "dm-channel-pk-1",
        content: "hello",
        webhookId: "pluralkit-webhook-1",
        author: {
          id: "webhook-author",
          bot: true,
          username: "PluralKit",
        },
      }),
      discordConfig: {
        allowBots: true,
        dmPolicy: "pairing",
        pluralkit: { enabled: true },
      } as DiscordConfig,
    });

    expect(result).toBeNull();
    expect(resolveDiscordDmCommandAccessMock).toHaveBeenCalledWith(
      expect.objectContaining({
        sender: {
          id: "pk-member-1",
          name: "Echo",
          tag: "Echo",
        },
      }),
    );
    expect(handleDiscordDmCommandDecisionMock).toHaveBeenCalledWith(
      expect.objectContaining({
        sender: {
          id: "pk:pk-member-1",
          tag: "Echo",
          name: "Echo",
        },
      }),
    );
  });

  it("skips PluralKit lookup for bound-thread webhook echoes", async () => {
    const threadBinding = createThreadBinding({
      targetKind: "session",
      targetSessionKey: "agent:main:acp:discord-thread-1",
    });
    const threadId = "thread-webhook-pk-echo-1";
    const parentId = "channel-parent-webhook-pk-echo-1";

    const result = await runThreadBoundPreflight({
      threadId,
      parentId,
      threadBinding,
      message: createDiscordMessage({
        id: "m-webhook-pk-echo-1",
        channelId: threadId,
        content: "proxied user message",
        webhookId: "pluralkit-webhook-1",
        author: {
          id: "relay-bot-1",
          bot: true,
          username: "Proxy",
        },
      }),
      discordConfig: {
        pluralkit: { enabled: true },
      } as DiscordConfig,
    });

    expect(result).toBeNull();
    expect(fetchPluralKitMessageInfoMock).not.toHaveBeenCalled();
  });

  it("bypasses mention gating in bound threads for allowed bot senders", async () => {
    const threadBinding = createThreadBinding();
    const threadId = "thread-bot-focus";
    const parentId = "channel-parent-focus";
    const client = createThreadClient({ threadId, parentId });
    const message = createDiscordMessage({
      id: "m-bot-1",
      channelId: threadId,
      content: "relay message without mention",
      author: {
        id: "relay-bot-1",
        bot: true,
        username: "Relay",
      },
    });

    registerSessionBindingAdapter({
      channel: "discord",
      accountId: "default",
      listBySession: () => [],
      resolveByConversation: (ref) => (ref.conversationId === threadId ? threadBinding : null),
    });

    const result = await preflightDiscordMessage(
      createPreflightArgs({
        cfg: {
          ...DEFAULT_PREFLIGHT_CFG,
        } as import("openclaw/plugin-sdk/config-contracts").OpenClawConfig,
        discordConfig: {
          allowBots: true,
        } as DiscordConfig,
        data: createGuildEvent({
          channelId: threadId,
          guildId: "guild-1",
          author: message.author,
          message,
        }),
        client,
      }),
    );

    const preflight = expectPreflightResult(result);
    expect(preflight.boundSessionKey).toBe(threadBinding.targetSessionKey);
    expect(preflight.shouldRequireMention).toBe(false);
    expect(preflight.groupRequireMention).toBe(true);
  });

  it.each<{
    name: string;
    channelId: string;
    guildId: string;
    messageId: string;
    content: string;
    mentioned?: boolean;
    accepted: boolean;
  }>([
    {
      name: "drops bot messages without mention when allowBots=mentions",
      channelId: "channel-bot-mentions-off",
      guildId: "guild-bot-mentions-off",
      messageId: "m-bot-mentions-off",
      content: "relay chatter",
      accepted: false,
    },
    {
      name: "allows bot messages with explicit mention when allowBots=mentions",
      channelId: "channel-bot-mentions-on",
      guildId: "guild-bot-mentions-on",
      messageId: "m-bot-mentions-on",
      content: "hi <@openclaw-bot>",
      mentioned: true,
      accepted: true,
    },
    {
      name: "still drops bot control commands without a real mention when allowBots=mentions",
      channelId: "channel-bot-command-no-mention",
      guildId: "guild-bot-command-no-mention",
      messageId: "m-bot-command-no-mention",
      content: "/new incident room",
      accepted: false,
    },
    {
      name: "still allows bot control commands with an explicit mention when allowBots=mentions",
      channelId: "channel-bot-command-with-mention",
      guildId: "guild-bot-command-with-mention",
      messageId: "m-bot-command-with-mention",
      content: "<@openclaw-bot> /new incident room",
      mentioned: true,
      accepted: true,
    },
  ])("$name", async ({ channelId, guildId, messageId, content, mentioned, accepted }) => {
    const message = createDiscordMessage({
      id: messageId,
      channelId,
      content,
      ...(mentioned ? { mentionedUsers: [{ id: "openclaw-bot" }] } : {}),
      author: { id: "relay-bot-1", bot: true, username: "Relay" },
    });
    const result = await runMentionOnlyBotPreflight({ channelId, guildId, message });

    if (!accepted) {
      expect(result).toBeNull();
      return;
    }
    expect(expectPreflightResult(result).message.id).toBe(messageId);
  });

  it("hydrates mention metadata from REST when bot mention syntax is present but mentions are missing", async () => {
    const channelId = "channel-bot-mentions-hydrated";
    const guildId = "guild-bot-mentions-hydrated";
    const botId = "123456789012345678";
    const message = createDiscordMessage({
      id: "m-bot-mentions-hydrated",
      channelId,
      content: `hi <@${botId}>`,
      author: {
        id: "relay-bot-1",
        bot: true,
        username: "Relay",
      },
      mentionedUsers: [],
    });
    const client = createGuildTextClient(channelId);
    client.rest = {
      get: vi.fn(async () => ({
        id: message.id,
        content: message.content,
        mentions: [{ id: botId, username: "OpenClaw", bot: true }],
        mention_roles: [],
        mention_everyone: false,
      })),
    } as unknown as DiscordClient["rest"];

    const result = await preflightDiscordMessage({
      ...createPreflightArgs({
        cfg: DEFAULT_PREFLIGHT_CFG,
        discordConfig: {
          allowBots: "mentions",
        } as DiscordConfig,
        data: createGuildEvent({
          channelId,
          guildId,
          author: message.author,
          message,
        }),
        client,
      }),
      botUserId: botId,
    });

    expect(expectPreflightResult(result).message.id).toBe("m-bot-mentions-hydrated");
  });

  it.each(["<@123456789012345678>", "<@!123456789012345678>"])(
    "accepts raw bot mention %s when REST hydration fails",
    async (rawMention) => {
      const channelId = "channel-bot-mentions-raw";
      const guildId = "guild-bot-mentions-raw";
      const botId = "123456789012345678";
      const message = createDiscordMessage({
        id: "m-bot-mentions-raw",
        channelId,
        content: `hi ${rawMention}`,
        author: {
          id: "relay-bot-1",
          bot: true,
          username: "Relay",
        },
        mentionedUsers: [],
      });
      const client = createGuildTextClient(channelId);
      client.rest = {
        get: vi.fn(async () => {
          throw new Error("Discord REST unavailable");
        }),
      } as unknown as DiscordClient["rest"];

      const result = await preflightDiscordMessage({
        ...createPreflightArgs({
          cfg: DEFAULT_PREFLIGHT_CFG,
          discordConfig: {
            allowBots: "mentions",
          } as DiscordConfig,
          data: createGuildEvent({
            channelId,
            guildId,
            author: message.author,
            message,
          }),
          client,
        }),
        botUserId: botId,
      });

      expect(expectPreflightResult(result).message.id).toBe("m-bot-mentions-raw");
    },
  );

  it("does not trust raw mention syntax when REST hydration succeeds without a mention", async () => {
    const channelId = "channel-bot-mentions-authoritative";
    const guildId = "guild-bot-mentions-authoritative";
    const botId = "123456789012345678";
    const message = createDiscordMessage({
      id: "m-bot-mentions-authoritative",
      channelId,
      content: `hi <@${botId}>`,
      author: {
        id: "relay-bot-1",
        bot: true,
        username: "Relay",
      },
      mentionedUsers: [],
    });
    const client = createGuildTextClient(channelId);
    client.rest = {
      get: vi.fn(async () => ({
        id: message.id,
        content: message.content,
        mentions: [],
        mention_roles: [],
        mention_everyone: false,
      })),
    } as unknown as DiscordClient["rest"];

    const result = await preflightDiscordMessage({
      ...createPreflightArgs({
        cfg: DEFAULT_PREFLIGHT_CFG,
        discordConfig: {
          allowBots: "mentions",
        } as DiscordConfig,
        data: createGuildEvent({
          channelId,
          guildId,
          author: message.author,
          message,
        }),
        client,
      }),
      botUserId: botId,
    });

    expect(result).toBeNull();
  });

  it.each([
    { name: "inline code", content: (botId: string) => `example: \`<@${botId}>\`` },
    {
      name: "fenced code",
      content: (botId: string) => `example:\n\`\`\`text\n<@${botId}>\n\`\`\``,
    },
    { name: "escaped syntax", content: (botId: string) => `example: \\<@${botId}>` },
    { name: "another user", content: () => "hi <@987654321098765432>" },
    { name: "role mention", content: (botId: string) => `hi <@&${botId}>` },
    { name: "longer user id", content: (botId: string) => `hi <@${botId}9>` },
  ])("does not trust $name when REST hydration fails", async ({ content }) => {
    const channelId = "channel-bot-mentions-untrusted";
    const guildId = "guild-bot-mentions-untrusted";
    const botId = "123456789012345678";
    const message = createDiscordMessage({
      id: "m-bot-mentions-untrusted",
      channelId,
      content: content(botId),
      author: {
        id: "relay-bot-1",
        bot: true,
        username: "Relay",
      },
      mentionedUsers: [],
    });
    const client = createGuildTextClient(channelId);
    client.rest = {
      get: vi.fn(async () => {
        throw new Error("Discord REST unavailable");
      }),
    } as unknown as DiscordClient["rest"];

    const result = await preflightDiscordMessage({
      ...createPreflightArgs({
        cfg: DEFAULT_PREFLIGHT_CFG,
        discordConfig: {
          allowBots: "mentions",
        } as DiscordConfig,
        data: createGuildEvent({
          channelId,
          guildId,
          author: message.author,
          message,
        }),
        client,
      }),
      botUserId: botId,
    });

    expect(result).toBeNull();
  });

  it("routes ordinary guild text control commands through authorization instead of dropping them", async () => {
    const channelId = "channel-text-control-command";
    const guildId = "guild-text-control-command";
    const message = createDiscordMessage({
      id: "m-text-control-command",
      channelId,
      content: "/steer keep digging",
      author: {
        id: "user-1",
        bot: false,
        username: "Alice",
      },
    });

    const result = await preflightDiscordMessage({
      ...createPreflightArgs({
        cfg: DEFAULT_PREFLIGHT_CFG,
        discordConfig: {} as DiscordConfig,
        data: createGuildEvent({
          channelId,
          guildId,
          author: message.author,
          message,
        }),
        client: createGuildTextClient(channelId),
      }),
      allowFrom: ["discord:user-1"],
      guildEntries: {
        [guildId]: {
          channels: {
            [channelId]: {
              enabled: true,
              requireMention: true,
            },
          },
        },
      },
    });

    const preflight = expectPreflightResult(result);
    expect(preflight.baseText).toBe("/steer keep digging");
    expect(preflight.commandAuthorized).toBe(true);
    expect(preflight.shouldRequireMention).toBe(true);
    expect(preflight.shouldBypassMention).toBe(true);
  });

  it("keeps unmentioned abort requests as user requests when room events are enabled", async () => {
    const channelId = "channel-room-event-abort";
    const guildId = "guild-room-event-abort";
    const message = createDiscordMessage({
      id: "m-room-event-abort",
      channelId,
      content: "please stop",
      author: {
        id: "user-1",
        bot: false,
        username: "Alice",
      },
    });

    const result = await runGuildPreflight({
      channelId,
      guildId,
      message,
      discordConfig: {} as DiscordConfig,
      cfg: {
        ...DEFAULT_PREFLIGHT_CFG,
        messages: {
          groupChat: {
            unmentionedInbound: "room_event",
          },
        },
      } as import("openclaw/plugin-sdk/config-contracts").OpenClawConfig,
      guildEntries: {
        [guildId]: {
          channels: {
            [channelId]: {
              enabled: true,
              requireMention: false,
            },
          },
        },
      },
    });

    const preflight = expectPreflightResult(result);
    expect(preflight.baseText).toBe("please stop");
    expect(preflight.inboundEventKind).toBe("user_request");
  });

  it("still drops Discord native command echo messages", async () => {
    const channelId = "channel-native-command-echo";
    const guildId = "guild-native-command-echo";
    const message = createDiscordMessage({
      id: "m-native-command-echo",
      channelId,
      content: "/steer keep digging",
      type: MessageType.ChatInputCommand,
      author: {
        id: "user-1",
        bot: false,
        username: "Alice",
      },
    });

    const result = await preflightDiscordMessage({
      ...createPreflightArgs({
        cfg: DEFAULT_PREFLIGHT_CFG,
        discordConfig: {} as DiscordConfig,
        data: createGuildEvent({
          channelId,
          guildId,
          author: message.author,
          message,
        }),
        client: createGuildTextClient(channelId),
      }),
      allowFrom: ["discord:user-1"],
      guildEntries: {
        [guildId]: {
          channels: {
            [channelId]: {
              enabled: true,
              requireMention: true,
            },
          },
        },
      },
    });

    expect(result).toBeNull();
  });

  it("does not mask mention gating when bot id is missing but mention patterns can detect", async () => {
    const channelId = "channel-missing-bot-id-mention-gate";
    const guildId = "guild-missing-bot-id-mention-gate";
    const message = createDiscordMessage({
      id: "m-missing-bot-id-mention-gate",
      channelId,
      content: "general update without the configured mention",
      author: {
        id: "user-1",
        bot: false,
        username: "Alice",
      },
    });

    const result = await preflightDiscordMessage({
      ...createPreflightArgs({
        cfg: {
          ...DEFAULT_PREFLIGHT_CFG,
          messages: {
            groupChat: {
              mentionPatterns: ["openclaw"],
            },
          },
        } as import("openclaw/plugin-sdk/config-contracts").OpenClawConfig,
        discordConfig: {} as DiscordConfig,
        data: createGuildEvent({
          channelId,
          guildId,
          author: message.author,
          message,
        }),
        client: createGuildTextClient(channelId),
      }),
      botUserId: undefined,
      guildEntries: {
        [guildId]: {
          channels: {
            [channelId]: {
              enabled: true,
              requireMention: true,
            },
          },
        },
      },
    });

    expect(result).toBeNull();
  });

  it("treats @everyone as a mention when requireMention is true", async () => {
    const channelId = "channel-everyone-mention";
    const guildId = "guild-everyone-mention";
    const message = createDiscordMessage({
      id: "m-everyone-mention",
      channelId,
      content: "@everyone standup time!",
      mentionedEveryone: true,
      author: {
        id: "user-1",
        bot: false,
        username: "Peter",
      },
    });

    const result = await runGuildPreflight({
      channelId,
      guildId,
      message,
      discordConfig: {
        botId: "openclaw-bot",
      } as DiscordConfig,
      guildEntries: {
        [guildId]: {
          channels: {
            [channelId]: {
              enabled: true,
              requireMention: true,
            },
          },
        },
      },
    });

    const preflight = expectPreflightResult(result);
    expect(preflight.shouldRequireMention).toBe(true);
    expect(preflight.wasMentioned).toBe(true);
  });

  it("accepts allowlisted guild messages when guild object is missing", async () => {
    const message = createDiscordMessage({
      id: "m-guild-id-only",
      channelId: "ch-1",
      content: "hello from maintainers",
      author: {
        id: "user-1",
        bot: false,
        username: "Peter",
      },
    });

    const result = await runGuildPreflight({
      channelId: "ch-1",
      guildId: "guild-1",
      message,
      discordConfig: {} as DiscordConfig,
      guildEntries: {
        "guild-1": {
          channels: {
            "ch-1": {
              enabled: true,
              requireMention: false,
            },
          },
        },
      },
      includeGuildObject: false,
    });

    const preflight = expectPreflightResult(result);
    expect(preflight.guildInfo?.id).toBe("guild-1");
    expect(preflight.channelConfig?.allowed).toBe(true);
    expect(preflight.shouldRequireMention).toBe(false);
  });

  it("inherits parent thread allowlist when guild object is missing", async () => {
    const threadId = "thread-1";
    const parentId = "parent-1";
    const message = createDiscordMessage({
      id: "m-thread-id-only",
      channelId: threadId,
      content: "thread hello",
      author: {
        id: "user-1",
        bot: false,
        username: "Peter",
      },
    });

    const result = await preflightDiscordMessage({
      ...createPreflightArgs({
        cfg: DEFAULT_PREFLIGHT_CFG,
        discordConfig: {} as DiscordConfig,
        data: createGuildEvent({
          channelId: threadId,
          guildId: "guild-1",
          author: message.author,
          message,
          includeGuildObject: false,
        }),
        client: createThreadClient({
          threadId,
          parentId,
        }),
      }),
      guildEntries: {
        "guild-1": {
          channels: {
            [parentId]: {
              enabled: true,
              requireMention: false,
            },
          },
        },
      },
    });

    const preflight = expectPreflightResult(result);
    expect(preflight.guildInfo?.id).toBe("guild-1");
    expect(preflight.threadParentId).toBe(parentId);
    expect(preflight.channelConfig?.allowed).toBe(true);
    expect(preflight.shouldRequireMention).toBe(false);
    expect(preflight.groupRequireMention).toBe(false);
  });

  it("handles partial thread channel owner getters during mention preflight", async () => {
    const threadId = "thread-partial-owner";
    const parentId = "parent-partial-owner";
    const message = createDiscordMessage({
      id: "m-thread-partial-owner",
      channelId: threadId,
      content: "thread hello",
      author: {
        id: "user-1",
        bot: false,
        username: "Peter",
      },
    });
    Object.defineProperty(message, "channel", {
      value: createPartialDiscordChannelWithThrowingGetters(
        {
          id: threadId,
          isThread: () => true,
          ownerId: "owner-1",
          parentId,
          parent: { id: parentId, name: "general" },
        },
        ["ownerId", "parentId", "parent"],
      ),
      configurable: true,
      enumerable: true,
    });

    const result = await preflightDiscordMessage({
      ...createPreflightArgs({
        cfg: DEFAULT_PREFLIGHT_CFG,
        discordConfig: {} as DiscordConfig,
        data: createGuildEvent({
          channelId: threadId,
          guildId: "guild-1",
          author: message.author,
          message,
          includeGuildObject: false,
        }),
        client: createThreadClient({
          threadId,
          parentId,
        }),
      }),
      guildEntries: {
        "guild-1": {
          channels: {
            [parentId]: {
              enabled: true,
              requireMention: false,
            },
          },
        },
      },
    });

    const preflight = expectPreflightResult(result);
    expect(preflight.threadParentId).toBe(parentId);
    expect(preflight.shouldRequireMention).toBe(false);
  });

  it("drops guild messages that mention another user when ignoreOtherMentions=true", async () => {
    const channelId = "channel-other-mention-1";
    const guildId = "guild-other-mention-1";
    const message = createDiscordMessage({
      id: "m-other-mention-1",
      channelId,
      content: "hello <@999>",
      mentionedUsers: [{ id: "999" }],
      author: {
        id: "user-1",
        bot: false,
        username: "Alice",
      },
    });

    const result = await runIgnoreOtherMentionsPreflight({ channelId, guildId, message });

    expect(result).toBeNull();
  });

  it("records local image media for skipped mention-gated guild history", async () => {
    const channelId = "channel-history-image";
    const guildId = "guild-history-image";
    const guildHistories = new Map();
    saveRemoteMediaMock.mockResolvedValueOnce({
      id: "test-media",
      path: "C:\\openclaw\\media\\history.png",
      size: 5,
      contentType: "image/png",
    });
    const fetchImpl = vi.fn(
      async () =>
        new Response(Buffer.from("image"), {
          headers: {
            "content-type": "image/png",
          },
        }),
    ) as unknown as typeof fetch;
    const message = createDiscordMessage({
      id: "m-history-image",
      channelId,
      content: "",
      attachments: [
        {
          id: "att-history-image",
          url: "https://cdn.discordapp.com/attachments/1/history.png",
          filename: "history.png",
          content_type: "image/png",
        },
      ],
      author: {
        id: "user-1",
        bot: false,
        username: "Alice",
      },
    });

    const result = await preflightDiscordMessage({
      ...createPreflightArgs({
        cfg: DEFAULT_PREFLIGHT_CFG,
        discordConfig: {} as DiscordConfig,
        data: createGuildEvent({
          channelId,
          guildId,
          author: message.author,
          message,
        }),
        client: createGuildTextClient(channelId),
      }),
      guildHistories,
      historyLimit: 4,
      discordRestFetch: fetchImpl,
      guildEntries: {
        [guildId]: {
          channels: {
            [channelId]: {
              enabled: true,
              requireMention: true,
            },
          },
        },
      },
    });

    expect(result).toBeNull();
    const entries = guildHistories.get(channelId);
    expect(entries).toHaveLength(1);
    expect(entries?.[0]).toMatchObject({
      sender: "Alice",
      body: "<media:image>",
      messageId: "m-history-image",
      senderProvenance: {
        id: "user-1",
        memberRoleIds: [],
      },
      media: [
        {
          contentType: "image/png",
          kind: "image",
          messageId: "m-history-image",
        },
      ],
    });
    expect(entries?.[0]?.media?.[0]?.path).toContain("history");
    expect(entries?.[0]?.media?.[0]?.path).not.toMatch(/^https?:/);
    expect(entries?.[0]?.media?.[0]?.path).toBe("C:\\openclaw\\media\\history.png");
    expect(saveRemoteMediaMock).toHaveBeenCalledTimes(1);
  });

  it("does not download non-image media for skipped mention-gated guild history", async () => {
    const channelId = "channel-history-doc";
    const guildId = "guild-history-doc";
    const guildHistories = new Map();
    const message = createDiscordMessage({
      id: "m-history-doc",
      channelId,
      content: "",
      attachments: [
        {
          id: "att-history-doc",
          url: "https://cdn.discordapp.com/attachments/1/history.pdf",
          filename: "history.pdf",
          content_type: "application/pdf",
        },
      ],
      author: {
        id: "user-1",
        bot: false,
        username: "Alice",
      },
    });

    const result = await preflightDiscordMessage({
      ...createPreflightArgs({
        cfg: DEFAULT_PREFLIGHT_CFG,
        discordConfig: {} as DiscordConfig,
        data: createGuildEvent({
          channelId,
          guildId,
          author: message.author,
          message,
        }),
        client: createGuildTextClient(channelId),
      }),
      guildHistories,
      historyLimit: 4,
      guildEntries: {
        [guildId]: {
          channels: {
            [channelId]: {
              enabled: true,
              requireMention: true,
            },
          },
        },
      },
    });

    expect(result).toBeNull();
    expect(saveRemoteMediaMock).not.toHaveBeenCalled();
    expect(guildHistories.get(channelId)).toEqual([
      expect.objectContaining({
        sender: "Alice",
        body: "<media:document>",
        messageId: "m-history-doc",
      }),
    ]);
    expect(guildHistories.get(channelId)?.[0]?.media).toBeUndefined();
  });

  it("records sticker image media for skipped mention-gated guild history", async () => {
    const channelId = "channel-history-sticker";
    const guildId = "guild-history-sticker";
    const guildHistories = new Map();
    saveRemoteMediaMock.mockResolvedValueOnce({
      id: "test-sticker",
      path: "/tmp/openclaw-discord-test/sticker.png",
      size: 5,
      contentType: "image/png",
    });
    const message = Object.assign(
      createDiscordMessage({
        id: "m-history-sticker",
        channelId,
        content: "",
        author: {
          id: "user-1",
          bot: false,
          username: "Alice",
        },
      }),
      {
        stickers: [
          {
            id: "sticker-history",
            name: "history-sticker",
            format_type: 1,
          },
        ],
      },
    );

    const result = await preflightDiscordMessage({
      ...createPreflightArgs({
        cfg: DEFAULT_PREFLIGHT_CFG,
        discordConfig: {} as DiscordConfig,
        data: createGuildEvent({
          channelId,
          guildId,
          author: message.author,
          message,
        }),
        client: createGuildTextClient(channelId),
      }),
      guildHistories,
      historyLimit: 4,
      guildEntries: {
        [guildId]: {
          channels: {
            [channelId]: {
              enabled: true,
              requireMention: true,
            },
          },
        },
      },
    });

    expect(result).toBeNull();
    expect(guildHistories.get(channelId)).toEqual([
      expect.objectContaining({
        sender: "Alice",
        body: "<media:sticker>",
        messageId: "m-history-sticker",
        media: [
          {
            path: "/tmp/openclaw-discord-test/sticker.png",
            contentType: "image/png",
            kind: "sticker",
            messageId: "m-history-sticker",
          },
        ],
      }),
    ]);
    expect(saveRemoteMediaMock).toHaveBeenCalledTimes(1);
  });

  it("caps skipped history media before falling back to raw Discord stickers", async () => {
    const channelId = "channel-history-cap";
    const guildId = "guild-history-cap";
    const guildHistories = new Map();
    const sticker = {
      id: "sticker-history-cap",
      name: "history-cap-sticker",
      format_type: 1,
    };
    const message = Object.assign(
      createDiscordMessage({
        id: "m-history-cap",
        channelId,
        content: "",
        attachments: Array.from({ length: 4 }, (_, index) => ({
          id: `att-history-cap-${index}`,
          url: `https://cdn.discordapp.com/attachments/1/history-${index}.png`,
          filename: `history-${index}.png`,
          content_type: "image/png",
        })),
        author: {
          id: "user-1",
          bot: false,
          username: "Alice",
        },
      }),
      {
        rawData: {
          sticker_items: [sticker],
        },
        stickers: [sticker],
      },
    );

    const result = await preflightDiscordMessage({
      ...createPreflightArgs({
        cfg: DEFAULT_PREFLIGHT_CFG,
        discordConfig: {} as DiscordConfig,
        data: createGuildEvent({
          channelId,
          guildId,
          author: message.author,
          message,
        }),
        client: createGuildTextClient(channelId),
      }),
      guildHistories,
      historyLimit: 4,
      guildEntries: {
        [guildId]: {
          channels: {
            [channelId]: {
              enabled: true,
              requireMention: true,
            },
          },
        },
      },
    });

    expect(result).toBeNull();
    expect(guildHistories.get(channelId)?.[0]?.media).toHaveLength(4);
    expect(saveRemoteMediaMock).toHaveBeenCalledTimes(4);
  });

  it("does not drop @everyone messages when ignoreOtherMentions=true", async () => {
    const channelId = "channel-other-mention-everyone";
    const guildId = "guild-other-mention-everyone";
    const message = createDiscordMessage({
      id: "m-other-mention-everyone",
      channelId,
      content: "@everyone heads up",
      mentionedEveryone: true,
      author: {
        id: "user-1",
        bot: false,
        username: "Alice",
      },
    });

    const result = await runIgnoreOtherMentionsPreflight({ channelId, guildId, message });

    expect(expectPreflightResult(result).hasAnyMention).toBe(true);
  });

  it("ignores bot-sent @everyone mentions for detection", async () => {
    const channelId = "channel-everyone-1";
    const guildId = "guild-everyone-1";
    const client = createGuildTextClient(channelId);
    const message = createDiscordMessage({
      id: "m-everyone-1",
      channelId,
      content: "@everyone heads up",
      mentionedEveryone: true,
      author: {
        id: "relay-bot-1",
        bot: true,
        username: "Relay",
      },
    });

    const result = await preflightDiscordMessage({
      ...createPreflightArgs({
        cfg: DEFAULT_PREFLIGHT_CFG,
        discordConfig: {
          allowBots: true,
        } as DiscordConfig,
        data: createGuildEvent({
          channelId,
          guildId,
          author: message.author,
          message,
        }),
        client,
      }),
      guildEntries: {
        [guildId]: {
          requireMention: false,
        },
      },
    });

    expect(expectPreflightResult(result).hasAnyMention).toBe(false);
  });

  it("does not treat bot-sent @everyone as wasMentioned", async () => {
    const channelId = "channel-everyone-2";
    const guildId = "guild-everyone-2";
    const client = createGuildTextClient(channelId);
    const message = createDiscordMessage({
      id: "m-everyone-2",
      channelId,
      content: "@everyone relay message",
      mentionedEveryone: true,
      author: {
        id: "relay-bot-2",
        bot: true,
        username: "RelayBot",
      },
    });

    const result = await preflightDiscordMessage({
      ...createPreflightArgs({
        cfg: DEFAULT_PREFLIGHT_CFG,
        discordConfig: {
          allowBots: true,
        } as DiscordConfig,
        data: createGuildEvent({
          channelId,
          guildId,
          author: message.author,
          message,
        }),
        client,
      }),
      guildEntries: {
        [guildId]: {
          requireMention: false,
        },
      },
    });

    expect(expectPreflightResult(result).wasMentioned).toBe(false);
  });

  it("uses attachment content_type for guild audio preflight mention detection", async () => {
    transcribeFirstAudioMock.mockResolvedValue("hey openclaw");

    const channelId = "channel-audio-1";
    const client = createGuildTextClient(channelId);

    const message = createDiscordMessage({
      id: "m-audio-1",
      channelId,
      content: "",
      attachments: [
        {
          id: "att-1",
          url: "https://cdn.discordapp.com/attachments/voice.ogg",
          content_type: "audio/ogg",
          filename: "voice.ogg",
        },
      ],
      author: {
        id: "user-1",
        bot: false,
        username: "Alice",
      },
    });

    const result = await preflightDiscordMessage({
      ...createPreflightArgs({
        cfg: {
          ...DEFAULT_PREFLIGHT_CFG,
          messages: {
            groupChat: {
              mentionPatterns: ["openclaw"],
            },
          },
        } as import("openclaw/plugin-sdk/config-contracts").OpenClawConfig,
        discordConfig: {} as DiscordConfig,
        data: createGuildEvent({
          channelId,
          guildId: "guild-1",
          author: message.author,
          message,
        }),
        client,
      }),
      guildEntries: {
        "guild-1": {
          channels: {
            [channelId]: {
              enabled: true,
              requireMention: true,
            },
          },
        },
      },
    });

    expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
    const guildAudioCall = firstMockArg(transcribeFirstAudioMock, "transcribeFirstAudio") as
      | { ctx?: { media?: unknown } }
      | undefined;
    expect(guildAudioCall?.ctx?.media).toEqual([
      {
        url: "https://cdn.discordapp.com/attachments/voice.ogg",
        contentType: "audio/ogg",
      },
    ]);
    const preflight = expectPreflightResult(result);
    expect(preflight.wasMentioned).toBe(true);
    expect(preflight.preflightAudioTranscript).toBe("hey openclaw");
  });

  it("does not transcribe guild audio from unauthorized members", async () => {
    const channelId = "channel-audio-unauthorized-1";
    const guildId = "guild-audio-unauthorized-1";
    const client = createGuildTextClient(channelId);

    const message = createDiscordMessage({
      id: "m-audio-unauthorized-1",
      channelId,
      content: "",
      attachments: [
        {
          id: "att-1",
          url: "https://cdn.discordapp.com/attachments/voice.ogg",
          content_type: "audio/ogg",
          filename: "voice.ogg",
        },
      ],
      author: {
        id: "user-2",
        bot: false,
        username: "Mallory",
      },
    });

    const result = await preflightDiscordMessage({
      ...createPreflightArgs({
        cfg: {
          ...DEFAULT_PREFLIGHT_CFG,
          messages: {
            groupChat: {
              mentionPatterns: ["openclaw"],
            },
          },
        } as import("openclaw/plugin-sdk/config-contracts").OpenClawConfig,
        discordConfig: {} as DiscordConfig,
        data: createGuildEvent({
          channelId,
          guildId,
          author: message.author,
          message,
        }),
        client,
      }),
      guildEntries: {
        [guildId]: {
          channels: {
            [channelId]: {
              enabled: true,
              requireMention: true,
              users: ["user-1"],
            },
          },
        },
      },
    });

    expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
    expect(result).toBeNull();
  });

  it("drops guild message without mention when channel has configuredBinding and requireMention: true", async () => {
    const conversationRuntime = await import("openclaw/plugin-sdk/conversation-runtime");
    const channelId = "ch-binding-1";
    const bindingRoute = {
      bindingResolution: {
        record: {
          targetSessionKey: "agent:main:acp:binding:discord:default:abc",
          targetKind: "session",
        },
      } as never,
      route: { agentId: "main", matchedBy: "binding.channel" } as never,
      boundSessionKey: "agent:main:acp:binding:discord:default:abc",
      boundAgentId: "main",
    };
    const routeSpy = vi
      .spyOn(conversationRuntime, "resolveConfiguredBindingRoute")
      .mockReturnValue(bindingRoute);
    const ensureSpy = vi
      .spyOn(conversationRuntime, "ensureConfiguredBindingRouteReady")
      .mockResolvedValue({ ok: true });

    try {
      const result = await runGuildPreflight({
        channelId,
        guildId: "guild-1",
        message: createDiscordMessage({
          id: "m-binding-1",
          channelId,
          content: "hello without mention",
          author: { id: "user-1", bot: false, username: "alice" },
        }),
        discordConfig: {} as DiscordConfig,
        guildEntries: {
          "guild-1": { channels: { [channelId]: { enabled: true, requireMention: true } } },
        },
      });
      expect(result).toBeNull();
    } finally {
      routeSpy.mockRestore();
      ensureSpy.mockRestore();
    }
  });

  it("allows guild message with mention when channel has configuredBinding and requireMention: true", async () => {
    const conversationRuntime = await import("openclaw/plugin-sdk/conversation-runtime");
    const channelId = "ch-binding-2";
    const bindingRoute = {
      bindingResolution: {
        record: {
          targetSessionKey: "agent:main:acp:binding:discord:default:def",
          targetKind: "session",
        },
      } as never,
      route: { agentId: "main", matchedBy: "binding.channel" } as never,
      boundSessionKey: "agent:main:acp:binding:discord:default:def",
      boundAgentId: "main",
    };
    const routeSpy = vi
      .spyOn(conversationRuntime, "resolveConfiguredBindingRoute")
      .mockReturnValue(bindingRoute);
    const ensureSpy = vi
      .spyOn(conversationRuntime, "ensureConfiguredBindingRouteReady")
      .mockResolvedValue({ ok: true });

    try {
      const result = await runGuildPreflight({
        channelId,
        guildId: "guild-1",
        message: createDiscordMessage({
          id: "m-binding-2",
          channelId,
          content: "hello <@openclaw-bot>",
          author: { id: "user-1", bot: false, username: "alice" },
          mentionedUsers: [{ id: "openclaw-bot" }],
        }),
        discordConfig: {} as DiscordConfig,
        guildEntries: {
          "guild-1": { channels: { [channelId]: { enabled: true, requireMention: true } } },
        },
      });
      expect(expectPreflightResult(result).message.id).toBe("m-binding-2");
    } finally {
      routeSpy.mockRestore();
      ensureSpy.mockRestore();
    }
  });
});

describe("shouldIgnoreBoundThreadWebhookMessage", () => {
  beforeEach(() => {
    sessionBindingTesting.resetSessionBindingAdaptersForTests();
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  it("returns true when inbound webhook id matches the bound thread webhook", () => {
    expect(
      shouldIgnoreBoundThreadWebhookMessage({
        webhookId: "wh-1",
        threadBinding: createThreadBinding(),
      }),
    ).toBe(true);
  });

  it("returns true when a bound thread receives a different webhook id", () => {
    expect(
      shouldIgnoreBoundThreadWebhookMessage({
        threadId: "thread-1",
        webhookId: "wh-other",
        threadBinding: createThreadBinding(),
      }),
    ).toBe(true);
  });

  it("returns true when a bound thread receives a webhook without a recorded bound webhook id", () => {
    expect(
      shouldIgnoreBoundThreadWebhookMessage({
        threadId: "thread-1",
        webhookId: "wh-1",
        threadBinding: createThreadBinding({
          metadata: {
            webhookId: undefined,
          },
        }),
      }),
    ).toBe(true);
  });

  it("returns false for differing webhook ids without a known thread id", () => {
    expect(
      shouldIgnoreBoundThreadWebhookMessage({
        webhookId: "wh-other",
        threadBinding: createThreadBinding(),
      }),
    ).toBe(false);
  });

  it("leaves a sent webhook identity suppressible after the Discord thread is unbound", async () => {
    let nowMs = 1_000;
    vi.spyOn(Date, "now").mockImplementation(() => nowMs);
    const manager = createThreadBindingManager({
      cfg: DEFAULT_PREFLIGHT_CFG,
      accountId: "default",
      persist: false,
      enableSweeper: false,
    });
    onTestFinished(() => manager.stop());
    const binding = await manager.bindTarget({
      threadId: "thread-1",
      channelId: "parent-1",
      targetKind: "subagent",
      targetSessionKey: "agent:main:subagent:child-1",
      agentId: "main",
      webhookId: "wh-1",
      webhookToken: "tok-1",
    });
    expect(binding).not.toBeNull();
    recordOutboundMessageIdentity({
      channel: "discord",
      accountId: "default",
      conversationId: "thread-1",
      sourceId: "wh-1",
    });

    nowMs += 30_000;
    manager.unbindThread({ threadId: "thread-1", sendFarewell: false });

    expect(
      isRecentOutboundMessageIdentity({
        channel: "discord",
        accountId: "default",
        conversationId: "thread-1",
        sourceId: "wh-1",
      }),
    ).toBe(true);
    expect(
      shouldIgnoreBoundThreadWebhookMessage({
        threadId: "thread-1",
        webhookId: "wh-1",
      }),
    ).toBe(false);

    const guildHistories = new Map();
    const message = createDiscordMessage({
      id: "m-unbound-webhook-echo-1",
      channelId: "thread-1",
      content: "outbound webhook echo without a mention",
      webhookId: "wh-1",
      author: {
        id: "relay-bot-1",
        bot: true,
        username: "OpenClaw",
      },
    });
    const result = await preflightDiscordMessage({
      ...createPreflightArgs({
        cfg: DEFAULT_PREFLIGHT_CFG,
        discordConfig: { allowBots: true } as DiscordConfig,
        data: createGuildEvent({
          channelId: "thread-1",
          guildId: "guild-1",
          author: message.author,
          message,
        }),
        client: createThreadClient({ threadId: "thread-1", parentId: "parent-1" }),
      }),
      guildHistories,
      historyLimit: 4,
      threadBindings: manager,
      guildEntries: {
        "guild-1": {
          channels: {
            "parent-1": {
              enabled: true,
              requireMention: true,
            },
          },
        },
      },
    });

    expect(result).toBeNull();
    expect(guildHistories.get("thread-1")).toBeUndefined();
  });
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
