/** Tests inbound auto-reply handling across channel message contexts. */
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { GroupKeyResolution } from "../config/sessions.js";
import { channelRouteDedupeKey } from "../plugin-sdk/channel-route.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import { createInboundDebouncer, type InboundDebounceCreateParams } from "./inbound-debounce.js";
import { resolveGroupRequireMention } from "./reply/groups.js";
import { finalizeInboundContext } from "./reply/inbound-context.js";
import {
  claimInboundDedupe,
  commitInboundDedupe,
  resetInboundDedupe,
} from "./reply/inbound-dedupe.js";
import { normalizeInboundTextNewlines } from "./reply/inbound-text.js";
import {
  buildMentionRegexes,
  matchesMentionPatterns,
  normalizeMentionText,
  stripMentions,
} from "./reply/mentions.js";
import { initSessionState } from "./reply/session.js";
import { applyTemplate, type MsgContext, type TemplateContext } from "./templating.js";

type TestChannelGroupContext = {
  cfg: OpenClawConfig;
  groupId?: string | null;
  groupChannel?: string | null;
  groupSpace?: string | null;
  accountId?: string | null;
};

function commitInboundForTest(ctx: MsgContext): string {
  const claim = claimInboundDedupe(ctx);
  expect(claim.status).toBe("claimed");
  if (claim.status !== "claimed") {
    throw new Error(`expected inbound dedupe claim, got ${claim.status}`);
  }
  commitInboundDedupe(claim.key);
  return claim.key;
}

function normalizeTestSlug(raw?: string | null): string {
  return raw?.trim().replace(/^#/, "").toLowerCase() ?? "";
}

function resolveDiscordRequireMentionForTest(params: TestChannelGroupContext): boolean {
  const discordCfg = params.cfg.channels?.discord as
    | {
        guilds?: Record<
          string,
          {
            requireMention?: boolean;
            slug?: string;
            channels?: Record<string, { requireMention?: boolean }>;
          }
        >;
      }
    | undefined;
  const guilds = discordCfg?.guilds;
  if (!guilds) {
    return true;
  }
  const space = params.groupSpace?.trim() ?? "";
  const spaceSlug = normalizeTestSlug(space);
  const guild =
    (space ? guilds[space] : undefined) ??
    (spaceSlug ? guilds[spaceSlug] : undefined) ??
    Object.values(guilds).find((entry) => normalizeTestSlug(entry?.slug) === spaceSlug) ??
    guilds["*"];
  const channelSlug = normalizeTestSlug(params.groupChannel);
  const channel =
    (params.groupId ? guild?.channels?.[params.groupId] : undefined) ??
    (channelSlug ? guild?.channels?.[channelSlug] : undefined) ??
    (channelSlug ? guild?.channels?.[`#${channelSlug}`] : undefined);
  return channel?.requireMention ?? guild?.requireMention ?? true;
}

function resolveSlackRequireMentionForTest(params: TestChannelGroupContext): boolean {
  const slackCfg = params.cfg.channels?.slack as
    | {
        defaultAccount?: string;
        channels?: Record<string, { requireMention?: boolean }>;
        accounts?: Record<string, { channels?: Record<string, { requireMention?: boolean }> }>;
      }
    | undefined;
  if (!slackCfg) {
    return true;
  }
  const accountId = params.accountId ?? slackCfg.defaultAccount;
  const channels =
    (accountId ? slackCfg.accounts?.[accountId]?.channels : undefined) ?? slackCfg.channels;
  if (!channels) {
    return true;
  }
  const channelName = params.groupChannel?.trim().replace(/^#/, "");
  const channelSlug = normalizeTestSlug(channelName);
  const candidates = [
    params.groupId?.trim(),
    channelName ? `#${channelName}` : undefined,
    channelName,
    channelSlug,
    "*",
  ];
  for (const candidate of candidates) {
    if (!candidate) {
      continue;
    }
    const entry = channels[candidate];
    if (typeof entry?.requireMention === "boolean") {
      return entry.requireMention;
    }
  }
  return true;
}

function installGroupRequireMentionTestPlugins() {
  setActivePluginRegistry(
    createTestRegistry([
      {
        pluginId: "discord",
        plugin: {
          ...createChannelTestPluginBase({ id: "discord" }),
          groups: { resolveRequireMention: resolveDiscordRequireMentionForTest },
        },
        source: "test",
      },
      {
        pluginId: "slack",
        plugin: {
          ...createChannelTestPluginBase({ id: "slack" }),
          groups: { resolveRequireMention: resolveSlackRequireMentionForTest },
        },
        source: "test",
      },
      {
        pluginId: "line",
        plugin: createChannelTestPluginBase({ id: "line" }),
        source: "test",
      },
      {
        pluginId: "imessage",
        plugin: createChannelTestPluginBase({ id: "imessage" }),
        source: "test",
      },
    ]),
  );
}

describe("applyTemplate", () => {
  it("renders primitive values", () => {
    const ctx = { MessageSid: "sid", IsNewSession: "no" } as TemplateContext;
    const overrides = ctx as Record<string, unknown>;
    overrides.MessageSid = 42;
    overrides.IsNewSession = true;

    expect(applyTemplate("sid={{MessageSid}} new={{IsNewSession}}", ctx)).toBe("sid=42 new=true");
  });

  it("renders arrays of primitives", () => {
    const ctx = { MediaPaths: ["a"] } as TemplateContext;
    (ctx as Record<string, unknown>).MediaPaths = ["a", 2, true, null, { ok: false }];

    expect(applyTemplate("paths={{MediaPaths}}", ctx)).toBe("paths=a,2,true");
  });

  it("drops object values", () => {
    const ctx: TemplateContext = { CommandArgs: { raw: "go" } };

    expect(applyTemplate("args={{CommandArgs}}", ctx)).toBe("args=");
  });

  it("renders missing placeholders as empty", () => {
    const ctx: TemplateContext = {};

    expect(applyTemplate("missing={{Missing}}", ctx)).toBe("missing=");
  });
});

describe("normalizeInboundTextNewlines", () => {
  it("keeps real newlines", () => {
    expect(normalizeInboundTextNewlines("a\nb")).toBe("a\nb");
  });

  it("normalizes CRLF/CR to LF", () => {
    expect(normalizeInboundTextNewlines("a\r\nb")).toBe("a\nb");
    expect(normalizeInboundTextNewlines("a\rb")).toBe("a\nb");
  });

  it("preserves literal backslash-n sequences (Windows paths)", () => {
    // Windows paths like C:\Work\nxxx should NOT have \n converted to newlines
    expect(normalizeInboundTextNewlines("a\\nb")).toBe("a\\nb");
    expect(normalizeInboundTextNewlines("C:\\Work\\nxxx")).toBe("C:\\Work\\nxxx");
  });
});

describe("finalizeInboundContext", () => {
  it("fills BodyForAgent/BodyForCommands and normalizes newlines", () => {
    const ctx: MsgContext = {
      // Use actual CRLF for newline normalization test, not literal \n sequences
      Body: "a\r\nb\r\nc",
      RawBody: "raw\r\nline",
      ChatType: "channel",
      From: "whatsapp:group:123@g.us",
      GroupSubject: "Test",
    };

    const out = finalizeInboundContext(ctx);
    expect(out.Body).toBe("a\nb\nc");
    expect(out.RawBody).toBe("raw\nline");
    // Prefer clean text over legacy envelope-shaped Body when RawBody is present.
    expect(out.BodyForAgent).toBe("raw\nline");
    expect(out.BodyForCommands).toBe("raw\nline");
    expect(out.CommandAuthorized).toBe(false);
    expect(out.CommandTurn).toMatchObject({
      kind: "normal",
      source: "message",
      authorized: false,
    });
    expect(out.ChatType).toBe("channel");
    expect(out.ConversationLabel).toContain("Test");
  });

  it("normalizes structured command turn context and legacy command fields together", () => {
    const out = finalizeInboundContext({
      Body: "/status",
      CommandBody: "/status",
      CommandAuthorized: false,
      CommandTurn: {
        kind: "text-slash" as const,
        source: "text" as const,
        authorized: true,
      },
    });

    expect(out.CommandTurn).toMatchObject({
      kind: "text-slash",
      source: "text",
      authorized: true,
      commandName: "status",
      body: "/status",
    });
    expect(out.CommandSource).toBe("text");
    expect(out.CommandAuthorized).toBe(true);
  });

  it("clears stale legacy command source without dropping normal-turn command auth", () => {
    const out = finalizeInboundContext({
      Body: "hello",
      CommandSource: "native",
      CommandAuthorized: true,
      CommandTurn: {
        kind: "normal" as const,
        source: "message" as const,
        authorized: false,
      },
    });

    expect(out.CommandTurn).toMatchObject({
      kind: "normal",
      source: "message",
      authorized: false,
    });
    expect(out.CommandSource).toBeUndefined();
    expect(out.CommandAuthorized).toBe(true);
  });

  it("keeps normal command authorization stable across repeated finalization", () => {
    const out = finalizeInboundContext({
      Body: "please inspect `/tmp/foo`",
      CommandAuthorized: true,
      CommandTurn: {
        kind: "normal" as const,
        source: "message" as const,
        authorized: false,
      },
    });

    const refinalized = finalizeInboundContext(out);

    expect(refinalized.CommandTurn).toMatchObject({
      kind: "normal",
      source: "message",
      authorized: false,
    });
    expect(refinalized.CommandSource).toBeUndefined();
    expect(refinalized.CommandAuthorized).toBe(true);
  });

  it("normalizes trusted group system prompt newlines without rewriting prompt markers", () => {
    const out = finalizeInboundContext({
      Body: "hello",
      GroupSystemPrompt: "[Assistant] room guidance\r\nSystem: owner instruction",
    });

    expect(out.GroupSystemPrompt).toBe("[Assistant] room guidance\nSystem: owner instruction");
  });

  it("preserves literal backslash-n in Windows paths", () => {
    const ctx: MsgContext = {
      Body: "C:\\Work\\nxxx\\README.md",
      RawBody: "C:\\Work\\nxxx\\README.md",
      ChatType: "direct",
      From: "web:user",
    };

    const out = finalizeInboundContext(ctx);
    expect(out.Body).toBe("C:\\Work\\nxxx\\README.md");
    expect(out.BodyForAgent).toBe("C:\\Work\\nxxx\\README.md");
    expect(out.BodyForCommands).toBe("C:\\Work\\nxxx\\README.md");
  });

  it("can force BodyForCommands to follow updated CommandBody", () => {
    const ctx: MsgContext = {
      Body: "base",
      BodyForCommands: "<media:audio>",
      CommandBody: "say hi",
      From: "signal:+15550001111",
      ChatType: "direct",
    };

    finalizeInboundContext(ctx, { forceBodyForCommands: true });
    expect(ctx.BodyForCommands).toBe("say hi");
  });

  it("fills a generic content type only when media exists", () => {
    const withMedia: MsgContext = {
      Body: "hi",
      media: [{ path: "/tmp/file.bin" }],
    };
    const outWithMedia = finalizeInboundContext(withMedia);
    expect(outWithMedia.media).toEqual([
      expect.objectContaining({ path: "/tmp/file.bin", contentType: "application/octet-stream" }),
    ]);

    const withoutMedia: MsgContext = { Body: "hi" };
    const outWithoutMedia = finalizeInboundContext(withoutMedia);
    expect(outWithoutMedia.media).toBeUndefined();
  });
});

describe("inbound dedupe", () => {
  it("builds a stable key when MessageSid is present", () => {
    const ctx: MsgContext = {
      Provider: "telegram",
      OriginatingChannel: "telegram",
      OriginatingTo: "telegram:123",
      MessageSid: "42",
    };
    expect(claimInboundDedupe(ctx, { inFlight: new Set() })).toEqual({
      status: "claimed",
      key: JSON.stringify([
        "",
        channelRouteDedupeKey({
          channel: "telegram",
          to: "telegram:123",
        }),
        "42",
      ]),
    });
  });

  it("skips duplicates with the same key", () => {
    resetInboundDedupe();
    const ctx: MsgContext = {
      Provider: "whatsapp",
      OriginatingChannel: "whatsapp",
      OriginatingTo: "whatsapp:+1555",
      MessageSid: "msg-1",
    };
    const key = commitInboundForTest(ctx);
    expect(claimInboundDedupe(ctx)).toEqual({ status: "duplicate", key });
  });

  it("does not dedupe when the peer changes", () => {
    resetInboundDedupe();
    const base: MsgContext = {
      Provider: "whatsapp",
      OriginatingChannel: "whatsapp",
      MessageSid: "msg-1",
    };
    commitInboundForTest({ ...base, OriginatingTo: "whatsapp:+1000" });
    expect(claimInboundDedupe({ ...base, OriginatingTo: "whatsapp:+2000" }).status).toBe("claimed");
  });

  it("does not dedupe across agent ids", () => {
    resetInboundDedupe();
    const base: MsgContext = {
      Provider: "whatsapp",
      OriginatingChannel: "whatsapp",
      OriginatingTo: "whatsapp:+1555",
      MessageSid: "msg-1",
    };
    const alphaKey = commitInboundForTest({ ...base, SessionKey: "agent:alpha:main" });
    expect(
      claimInboundDedupe({ ...base, SessionKey: "agent:bravo:whatsapp:direct:+1555" }).status,
    ).toBe("claimed");
    expect(claimInboundDedupe({ ...base, SessionKey: "agent:alpha:main" })).toEqual({
      status: "duplicate",
      key: alphaKey,
    });
  });

  it("dedupes when the same agent sees the same inbound message under different session keys", () => {
    resetInboundDedupe();
    const base: MsgContext = {
      Provider: "telegram",
      OriginatingChannel: "telegram",
      OriginatingTo: "telegram:7463849194",
      MessageSid: "msg-1",
    };
    const key = commitInboundForTest({ ...base, SessionKey: "agent:main:main" });
    expect(
      claimInboundDedupe({ ...base, SessionKey: "agent:main:telegram:direct:7463849194" }),
    ).toEqual({ status: "duplicate", key });
  });
});

describe("createInboundDebouncer", () => {
  type TestInboundDebounceFlush = ReturnType<InboundDebounceCreateParams<unknown>["onFlush"]>;
  const flushOnCompletion = (dispatch: () => void | Promise<void>): TestInboundDebounceFlush => {
    const completion = Promise.resolve().then(dispatch);
    return { admission: completion, completion };
  };

  it("debounces and combines items", async () => {
    vi.useFakeTimers();
    const calls: Array<string[]> = [];

    const debouncer = createInboundDebouncer<{ key: string; id: string }>({
      debounceMs: 10,
      buildKey: (item) => item.key,
      onFlush: (items) =>
        flushOnCompletion(() => {
          calls.push(items.map((entry) => entry.id));
        }),
    });

    await debouncer.enqueue({ key: "a", id: "1" });
    await debouncer.enqueue({ key: "a", id: "2" });

    expect(calls).toStrictEqual([]);
    await vi.advanceTimersByTimeAsync(10);
    expect(calls).toEqual([["1", "2"]]);

    vi.useRealTimers();
  });

  it("flushes sustained same-key messages in complete, ordered batches", async () => {
    vi.useFakeTimers();
    try {
      const calls: Array<string[]> = [];
      const debouncer = createInboundDebouncer<{ key: string; id: string }>({
        debounceMs: 50,
        buildKey: (item) => item.key,
        onFlush: (items) =>
          flushOnCompletion(() => {
            calls.push(items.map((entry) => entry.id));
          }),
      });

      for (let index = 0; index < 12; index += 1) {
        await debouncer.enqueue({ key: "a", id: String(index) });
        await vi.advanceTimersByTimeAsync(20);
      }
      expect(calls).toStrictEqual([]);

      await debouncer.enqueue({ key: "a", id: "12" });
      await vi.advanceTimersByTimeAsync(10);
      expect(calls).toEqual([Array.from({ length: 13 }, (_, index) => String(index))]);

      for (let index = 13; index < 25; index += 1) {
        await debouncer.enqueue({ key: "a", id: String(index) });
        await vi.advanceTimersByTimeAsync(20);
      }
      expect(calls).toHaveLength(1);

      await vi.advanceTimersByTimeAsync(10);
      expect(calls).toEqual([
        Array.from({ length: 13 }, (_, index) => String(index)),
        Array.from({ length: 12 }, (_, index) => String(index + 13)),
      ]);
      await debouncer.drain();
    } finally {
      vi.useRealTimers();
    }
  });

  it("anchors sustained-message deadlines to the first item's debounce window", async () => {
    vi.useFakeTimers();
    try {
      const calls: Array<string[]> = [];
      const debouncer = createInboundDebouncer<{
        key: string;
        id: string;
        windowMs: number;
      }>({
        debounceMs: 0,
        buildKey: (item) => item.key,
        resolveDebounceMs: (item) => item.windowMs,
        onFlush: (items) =>
          flushOnCompletion(() => {
            calls.push(items.map((entry) => entry.id));
          }),
      });

      await debouncer.enqueue({ key: "a", id: "first", windowMs: 50 });
      await vi.advanceTimersByTimeAsync(40);
      await debouncer.enqueue({ key: "a", id: "later", windowMs: 1_000 });
      await vi.advanceTimersByTimeAsync(209);
      expect(calls).toStrictEqual([]);

      await vi.advanceTimersByTimeAsync(1);
      expect(calls).toEqual([["first", "later"]]);
      await debouncer.drain();
    } finally {
      vi.useRealTimers();
    }
  });

  it("flushes sustained messages even when the system clock moves backward", async () => {
    vi.useFakeTimers();
    try {
      const calls: Array<string[]> = [];
      const debouncer = createInboundDebouncer<{ key: string; id: string }>({
        debounceMs: 50,
        buildKey: (item) => item.key,
        onFlush: (items) =>
          flushOnCompletion(() => {
            calls.push(items.map((entry) => entry.id));
          }),
      });

      await debouncer.enqueue({ key: "a", id: "0" });
      for (let index = 1; index < 13; index += 1) {
        await vi.advanceTimersByTimeAsync(20);
        if (index === 6) {
          vi.setSystemTime(Date.now() - 60_000);
        }
        await debouncer.enqueue({ key: "a", id: String(index) });
      }
      expect(calls).toStrictEqual([]);

      await vi.advanceTimersByTimeAsync(10);
      expect(calls).toEqual([Array.from({ length: 13 }, (_, index) => String(index))]);
      await debouncer.drain();
    } finally {
      vi.useRealTimers();
    }
  });

  it("reports buffered items when cancelling a key", async () => {
    vi.useFakeTimers();
    const calls: Array<string[]> = [];
    const canceled: Array<string[]> = [];

    const debouncer = createInboundDebouncer<{ key: string; id: string }>({
      debounceMs: 10,
      buildKey: (item) => item.key,
      onFlush: (items) =>
        flushOnCompletion(() => {
          calls.push(items.map((entry) => entry.id));
        }),
      onCancel: (items) => {
        canceled.push(items.map((entry) => entry.id));
      },
    });

    await debouncer.enqueue({ key: "a", id: "1" });
    await debouncer.enqueue({ key: "a", id: "2" });
    expect(debouncer.cancelKey("a")).toBe(true);
    await vi.advanceTimersByTimeAsync(10);

    expect(canceled).toEqual([["1", "2"]]);
    expect(calls).toEqual([]);

    vi.useRealTimers();
  });

  it("cancels a released flush still waiting behind active same-key work", async () => {
    const calls: Array<string[]> = [];
    const canceled: Array<string[]> = [];
    let releaseFirst!: () => void;
    const firstGate = new Promise<void>((resolve) => {
      releaseFirst = resolve;
    });
    const debouncer = createInboundDebouncer<{ key: string; id: string }>({
      debounceMs: 50,
      buildKey: (item) => item.key,
      onFlush: (items) =>
        flushOnCompletion(async () => {
          const ids = items.map((entry) => entry.id);
          calls.push(ids);
          if (ids[0] === "1") {
            await firstGate;
          }
        }),
      onCancel: (items) => {
        canceled.push(items.map((entry) => entry.id));
      },
    });

    await debouncer.enqueue({ key: "a", id: "1" });
    const firstFlush = debouncer.flushKey("a");
    await vi.waitFor(() => expect(calls).toEqual([["1"]]));

    await debouncer.enqueue({ key: "a", id: "2" });
    const secondFlush = debouncer.flushKey("a");
    expect(debouncer.cancelKey("a")).toBe(true);

    await debouncer.enqueue({ key: "a", id: "3" });
    const thirdFlush = debouncer.flushKey("a");
    releaseFirst();
    await Promise.all([firstFlush, secondFlush, thirdFlush]);

    expect(canceled).toEqual([["2"]]);
    expect(calls).toEqual([["1"], ["3"]]);
  });

  it("flushes buffered items before non-debounced item", async () => {
    vi.useFakeTimers();
    const calls: Array<string[]> = [];

    const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
      debounceMs: 50,
      buildKey: (item) => item.key,
      shouldDebounce: (item) => item.debounce,
      onFlush: (items) =>
        flushOnCompletion(() => {
          calls.push(items.map((entry) => entry.id));
        }),
    });

    await debouncer.enqueue({ key: "a", id: "1", debounce: true });
    await debouncer.enqueue({ key: "a", id: "2", debounce: false });

    expect(calls).toEqual([["1"], ["2"]]);

    vi.useRealTimers();
  });

  it("supports per-item debounce windows when default debounce is disabled", async () => {
    vi.useFakeTimers();
    const calls: Array<string[]> = [];

    const debouncer = createInboundDebouncer<{ key: string; id: string; windowMs: number }>({
      debounceMs: 0,
      buildKey: (item) => item.key,
      resolveDebounceMs: (item) => item.windowMs,
      onFlush: (items) =>
        flushOnCompletion(() => {
          calls.push(items.map((entry) => entry.id));
        }),
    });

    await debouncer.enqueue({ key: "forward", id: "1", windowMs: 30 });
    await debouncer.enqueue({ key: "forward", id: "2", windowMs: 30 });

    expect(calls).toStrictEqual([]);
    await vi.advanceTimersByTimeAsync(30);
    expect(calls).toEqual([["1", "2"]]);

    vi.useRealTimers();
  });

  it("keeps later same-key work behind a timer-backed flush that already started", async () => {
    const started: string[] = [];
    const finished: string[] = [];
    let releaseFirst: (() => void) | undefined;
    const firstGate = new Promise<void>((resolve) => {
      releaseFirst = resolve;
    });

    const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
    const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
      debounceMs: 50,
      buildKey: (item) => item.key,
      shouldDebounce: (item) => item.debounce,
      onFlush: (items) =>
        flushOnCompletion(async () => {
          const ids = items.map((entry) => entry.id).join(",");
          started.push(ids);
          if (ids === "1") {
            await firstGate;
          }
          finished.push(ids);
        }),
    });

    try {
      await debouncer.enqueue({ key: "a", id: "1", debounce: true });

      const timerIndex = setTimeoutSpy.mock.calls.findLastIndex((call) => call[1] === 50);
      expect(timerIndex).toBeGreaterThanOrEqual(0);
      clearTimeout(setTimeoutSpy.mock.results[timerIndex]?.value as ReturnType<typeof setTimeout>);
      const flushTimer = setTimeoutSpy.mock.calls[timerIndex]?.[0] as
        | (() => Promise<void>)
        | undefined;
      const firstFlush = flushTimer?.();

      await vi.waitFor(() => {
        expect(started).toEqual(["1"]);
      });

      const secondEnqueue = debouncer.enqueue({ key: "a", id: "2", debounce: false });
      await Promise.resolve();

      expect(started).toEqual(["1"]);
      expect(finished).toStrictEqual([]);

      if (!releaseFirst) {
        throw new Error("Expected first inbound debounce release callback to be initialized");
      }
      releaseFirst();
      await Promise.all([firstFlush, secondEnqueue]);

      expect(started).toEqual(["1", "2"]);
      expect(finished).toEqual(["1", "2"]);
    } finally {
      setTimeoutSpy.mockRestore();
    }
  });

  it("keeps fire-and-forget keyed work ahead of a later buffered item", async () => {
    const started: string[] = [];
    const finished: string[] = [];
    let releaseFirst: (() => void) | undefined;
    const firstGate = new Promise<void>((resolve) => {
      releaseFirst = resolve;
    });

    const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
    const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
      debounceMs: 50,
      buildKey: (item) => item.key,
      shouldDebounce: (item) => item.debounce,
      onFlush: (items) =>
        flushOnCompletion(async () => {
          const ids = items.map((entry) => entry.id).join(",");
          started.push(ids);
          if (ids === "1") {
            await firstGate;
          }
          finished.push(ids);
        }),
    });

    try {
      await debouncer.enqueue({ key: "a", id: "1", debounce: true });

      const firstTimerIndex = setTimeoutSpy.mock.calls.findLastIndex((call) => call[1] === 50);
      expect(firstTimerIndex).toBeGreaterThanOrEqual(0);
      clearTimeout(
        setTimeoutSpy.mock.results[firstTimerIndex]?.value as ReturnType<typeof setTimeout>,
      );
      (setTimeoutSpy.mock.calls[firstTimerIndex]?.[0] as (() => void) | undefined)?.();

      await vi.waitFor(() => {
        expect(started).toEqual(["1"]);
      });

      const secondEnqueue = debouncer.enqueue({ key: "a", id: "2", debounce: false });
      const thirdEnqueue = debouncer.enqueue({ key: "a", id: "3", debounce: true });

      const thirdTimerIndex = setTimeoutSpy.mock.calls.findLastIndex(
        (call, index) => index > firstTimerIndex && call[1] === 50,
      );
      expect(thirdTimerIndex).toBeGreaterThan(firstTimerIndex);
      clearTimeout(
        setTimeoutSpy.mock.results[thirdTimerIndex]?.value as ReturnType<typeof setTimeout>,
      );
      (setTimeoutSpy.mock.calls[thirdTimerIndex]?.[0] as (() => void) | undefined)?.();

      await Promise.resolve();

      expect(started).toEqual(["1"]);
      expect(finished).toStrictEqual([]);

      if (!releaseFirst) {
        throw new Error("Expected first inbound debounce release callback to be initialized");
      }
      releaseFirst();
      await Promise.all([secondEnqueue, thirdEnqueue]);

      await vi.waitFor(() => {
        expect(started).toEqual(["1", "2", "3"]);
        expect(finished).toEqual(["1", "2", "3"]);
      });
    } finally {
      setTimeoutSpy.mockRestore();
    }
  });

  it("does not serialize keyed turns by default when debounce is disabled", async () => {
    const started: string[] = [];
    let releaseFirst: (() => void) | undefined;
    const firstGate = new Promise<void>((resolve) => {
      releaseFirst = resolve;
    });

    const debouncer = createInboundDebouncer<{ key: string; id: string }>({
      debounceMs: 0,
      buildKey: (item) => item.key,
      onFlush: (items) =>
        flushOnCompletion(async () => {
          const id = items[0]?.id ?? "";
          started.push(id);
          if (id === "1") {
            await firstGate;
          }
        }),
    });

    const first = debouncer.enqueue({ key: "a", id: "1" });
    await Promise.resolve();
    const second = debouncer.enqueue({ key: "a", id: "2" });
    await Promise.resolve();

    expect(started).toEqual(["1", "2"]);

    if (!releaseFirst) {
      throw new Error("Expected first inbound debounce release callback to be initialized");
    }
    releaseFirst();
    await Promise.all([first, second]);
  });

  it("serializes keyed turns when immediate serialization is enabled", async () => {
    const started: string[] = [];
    let releaseFirst: (() => void) | undefined;
    const firstGate = new Promise<void>((resolve) => {
      releaseFirst = resolve;
    });

    const debouncer = createInboundDebouncer<{ key: string; id: string }>({
      debounceMs: 0,
      serializeImmediate: true,
      buildKey: (item) => item.key,
      onFlush: (items) =>
        flushOnCompletion(async () => {
          const id = items[0]?.id ?? "";
          started.push(id);
          if (id === "1") {
            await firstGate;
          }
        }),
    });

    const first = debouncer.enqueue({ key: "a", id: "1" });
    await Promise.resolve();
    const second = debouncer.enqueue({ key: "a", id: "2" });
    await Promise.resolve();

    expect(started).toEqual(["1"]);

    if (!releaseFirst) {
      throw new Error("Expected first inbound debounce release callback to be initialized");
    }
    releaseFirst();
    await Promise.all([first, second]);
    expect(started).toEqual(["1", "2"]);
  });

  it("releases a keyed chain at admission and drains full flush completions", async () => {
    const started: string[] = [];
    const completed: string[] = [];
    let releaseFirst!: () => void;
    const firstCompletion = new Promise<void>((resolve) => {
      releaseFirst = resolve;
    });
    const debouncer = createInboundDebouncer<{ key: string; id: string }>({
      debounceMs: 50,
      buildKey: (item) => item.key,
      onFlush: (items, createFlush) =>
        createFlush({
          dispatch: async (lifecycle) => {
            const id = items[0]?.id ?? "";
            started.push(id);
            await lifecycle.onAdopted();
            if (id === "1") {
              await firstCompletion;
            }
            completed.push(id);
          },
        }),
    });

    await debouncer.enqueue({ key: "a", id: "1" });
    await debouncer.flushKey("a");
    await debouncer.enqueue({ key: "a", id: "2" });
    await debouncer.flushKey("a");

    expect(started).toEqual(["1", "2"]);
    expect(completed).toEqual(["2"]);

    let drained = false;
    const drain = debouncer.drain().then(() => {
      drained = true;
    });
    await Promise.resolve();
    expect(drained).toBe(false);

    releaseFirst();
    await drain;
    expect(completed).toEqual(["2", "1"]);
  });

  it("drains same-key flushes queued before their completion is tracked", async () => {
    const started: string[] = [];
    let releaseFirst!: () => void;
    let releaseSecond!: () => void;
    const firstCompletion = new Promise<void>((resolve) => {
      releaseFirst = resolve;
    });
    const secondCompletion = new Promise<void>((resolve) => {
      releaseSecond = resolve;
    });
    const debouncer = createInboundDebouncer<{ key: string; id: string }>({
      debounceMs: 50,
      buildKey: (item) => item.key,
      onFlush: (items, createFlush) =>
        createFlush({
          dispatch: async () => {
            const id = items[0]?.id ?? "";
            started.push(id);
            await (id === "1" ? firstCompletion : secondCompletion);
          },
        }),
    });

    await debouncer.enqueue({ key: "a", id: "1" });
    const firstFlush = debouncer.flushKey("a");
    await vi.waitFor(() => expect(started).toEqual(["1"]));
    await debouncer.enqueue({ key: "a", id: "2" });
    const secondFlush = debouncer.flushKey("a");

    let drained = false;
    const drain = debouncer.drain().then(() => {
      drained = true;
    });
    releaseFirst();
    await vi.waitFor(() => expect(started).toEqual(["1", "2"]));
    expect(drained).toBe(false);

    releaseSecond();
    await Promise.all([firstFlush, secondFlush, drain]);
    expect(drained).toBe(true);
  });

  it("swallows onError failures so keyed chains still complete", async () => {
    const calls: string[] = [];
    const debouncer = createInboundDebouncer<{ key: string; id: string }>({
      debounceMs: 0,
      buildKey: (item) => item.key,
      onFlush: (items) =>
        flushOnCompletion(() => {
          calls.push(items[0]?.id ?? "");
          throw new Error("flush failed");
        }),
      onError: () => {
        throw new Error("handler failed");
      },
    });

    await expect(debouncer.enqueue({ key: "a", id: "1" })).resolves.toBeUndefined();
    await expect(debouncer.enqueue({ key: "a", id: "2" })).resolves.toBeUndefined();

    expect(calls).toEqual(["1", "2"]);
  });

  it("does not leak unhandled rejections when a keyed flush failure is awaited", async () => {
    const debouncer = createInboundDebouncer<{ key: string; id: string }>({
      debounceMs: 0,
      buildKey: (item) => item.key,
      onFlush: () =>
        flushOnCompletion(() => {
          throw new Error("flush failed");
        }),
    });
    const unhandled: unknown[] = [];
    const onUnhandledRejection = (reason: unknown) => {
      unhandled.push(reason);
    };
    process.on("unhandledRejection", onUnhandledRejection);

    try {
      await expect(debouncer.enqueue({ key: "a", id: "1" })).resolves.toBeUndefined();
      await new Promise<void>((resolve) => {
        setImmediate(resolve);
      });
      expect(unhandled).toStrictEqual([]);
    } finally {
      process.off("unhandledRejection", onUnhandledRejection);
    }
  });

  it("bypasses debouncing for new keys once the tracked-key cap is reached", async () => {
    vi.useFakeTimers();
    const calls: Array<string[]> = [];

    const debouncer = createInboundDebouncer<{ key: string; id: string }>({
      debounceMs: 50,
      maxTrackedKeys: 1,
      buildKey: (item) => item.key,
      onFlush: (items) =>
        flushOnCompletion(() => {
          calls.push(items.map((entry) => entry.id));
        }),
    });

    await debouncer.enqueue({ key: "a", id: "1" });
    await debouncer.enqueue({ key: "b", id: "2" });

    expect(calls).toEqual([["2"]]);

    await vi.advanceTimersByTimeAsync(50);
    expect(calls).toEqual([["2"], ["1"]]);

    vi.useRealTimers();
  });

  it("keeps same-key overflow work ordered after falling back to immediate flushes", async () => {
    const started: string[] = [];
    const finished: string[] = [];
    let releaseOverflow: (() => void) | undefined;
    const overflowGate = new Promise<void>((resolve) => {
      releaseOverflow = resolve;
    });

    const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
    const debouncer = createInboundDebouncer<{ key: string; id: string }>({
      debounceMs: 50,
      maxTrackedKeys: 1,
      buildKey: (item) => item.key,
      onFlush: (items) =>
        flushOnCompletion(async () => {
          const ids = items.map((entry) => entry.id).join(",");
          started.push(ids);
          if (ids === "2") {
            await overflowGate;
          }
          finished.push(ids);
        }),
    });

    try {
      await debouncer.enqueue({ key: "a", id: "1" });
      const callCountBeforeOverflow = setTimeoutSpy.mock.calls.length;
      clearTimeout(
        setTimeoutSpy.mock.results[callCountBeforeOverflow - 1]?.value as ReturnType<
          typeof setTimeout
        >,
      );

      const overflowEnqueue = debouncer.enqueue({ key: "b", id: "2" });
      await vi.waitFor(() => {
        expect(started).toEqual(["2"]);
      });

      const bufferedEnqueue = debouncer.enqueue({ key: "b", id: "3" });
      const bufferedTimerIndex = setTimeoutSpy.mock.calls.findLastIndex(
        (call, index) => index >= callCountBeforeOverflow && call[1] === 50,
      );
      expect(bufferedTimerIndex).toBeGreaterThanOrEqual(callCountBeforeOverflow);
      clearTimeout(
        setTimeoutSpy.mock.results[bufferedTimerIndex]?.value as ReturnType<typeof setTimeout>,
      );
      (setTimeoutSpy.mock.calls[bufferedTimerIndex]?.[0] as (() => void) | undefined)?.();

      await Promise.resolve();
      expect(started).toEqual(["2"]);
      expect(finished).toStrictEqual([]);

      if (!releaseOverflow) {
        throw new Error("Expected inbound overflow release callback to be initialized");
      }
      releaseOverflow();
      await Promise.all([overflowEnqueue, bufferedEnqueue]);

      await vi.waitFor(() => {
        expect(started).toEqual(["2", "3"]);
        expect(finished).toEqual(["2", "3"]);
      });
    } finally {
      setTimeoutSpy.mockRestore();
    }
  });

  it("counts tracked debounce keys by union of buffers and active chains", async () => {
    const started: string[] = [];
    const finished: string[] = [];
    let releaseChainOnly: (() => void) | undefined;
    const chainOnlyGate = new Promise<void>((resolve) => {
      releaseChainOnly = resolve;
    });

    const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
    const debouncer = createInboundDebouncer<{ key: string; id: string }>({
      debounceMs: 50,
      maxTrackedKeys: 3,
      buildKey: (item) => item.key,
      onFlush: (items) =>
        flushOnCompletion(async () => {
          const ids = items.map((entry) => entry.id).join(",");
          started.push(ids);
          if (ids === "2") {
            await chainOnlyGate;
          }
          finished.push(ids);
        }),
    });

    try {
      await debouncer.enqueue({ key: "a", id: "1" });
      const firstTimerIndex = setTimeoutSpy.mock.calls.findLastIndex((call) => call[1] === 50);
      expect(firstTimerIndex).toBeGreaterThanOrEqual(0);
      clearTimeout(
        setTimeoutSpy.mock.results[firstTimerIndex]?.value as ReturnType<typeof setTimeout>,
      );

      await debouncer.enqueue({ key: "b", id: "2" });
      const secondTimerIndex = setTimeoutSpy.mock.calls.findLastIndex(
        (call, index) => index > firstTimerIndex && call[1] === 50,
      );
      expect(secondTimerIndex).toBeGreaterThan(firstTimerIndex);
      clearTimeout(
        setTimeoutSpy.mock.results[secondTimerIndex]?.value as ReturnType<typeof setTimeout>,
      );
      const secondFlush = (
        setTimeoutSpy.mock.calls[secondTimerIndex]?.[0] as (() => Promise<void>) | undefined
      )?.();

      await vi.waitFor(() => {
        expect(started).toEqual(["2"]);
      });

      await debouncer.enqueue({ key: "c", id: "3" });
      const timerCountBeforeOverflow = setTimeoutSpy.mock.calls.length;
      const thirdTimerIndex = setTimeoutSpy.mock.calls.findLastIndex(
        (call, index) => index > secondTimerIndex && call[1] === 50,
      );
      expect(thirdTimerIndex).toBeGreaterThan(secondTimerIndex);
      clearTimeout(
        setTimeoutSpy.mock.results[thirdTimerIndex]?.value as ReturnType<typeof setTimeout>,
      );

      const overflowEnqueue = debouncer.enqueue({ key: "d", id: "4" });

      expect(setTimeoutSpy.mock.calls).toHaveLength(timerCountBeforeOverflow);
      await vi.waitFor(() => {
        expect(started).toEqual(["2", "4"]);
        expect(finished).toEqual(["4"]);
      });

      if (!releaseChainOnly) {
        throw new Error("Expected inbound chain-only release callback to be initialized");
      }
      releaseChainOnly();
      await Promise.all([secondFlush, overflowEnqueue]);
      expect(finished).toEqual(["4", "2"]);
    } finally {
      setTimeoutSpy.mockRestore();
    }
  });
});

const senderMetaTempDirs = createSuiteTempRootTracker({
  prefix: "openclaw-sender-meta-",
});

describe("initSessionState BodyStripped", () => {
  beforeAll(async () => {
    await senderMetaTempDirs.setup();
  });

  afterAll(async () => {
    await senderMetaTempDirs.cleanup();
  });

  it("prefers BodyForAgent over Body for group chats", async () => {
    const root = await senderMetaTempDirs.make("group");
    const storePath = path.join(root, "sessions.json");
    const cfg = { session: { store: storePath } } as OpenClawConfig;

    const result = await initSessionState({
      ctx: finalizeInboundContext({
        Body: "[WhatsApp 123@g.us] ping",
        BodyForAgent: "ping",
        ChatType: "group",
        SenderName: "Bob",
        SenderE164: "+222",
        SenderId: "222@s.whatsapp.net",
        SessionKey: "agent:main:whatsapp:group:123@g.us",
      }),
      cfg,
      commandAuthorized: true,
    });

    expect(result.sessionCtx.BodyStripped).toBe("ping");
  });

  it("prefers BodyForAgent over Body for direct chats", async () => {
    const root = await senderMetaTempDirs.make("direct");
    const storePath = path.join(root, "sessions.json");
    const cfg = { session: { store: storePath } } as OpenClawConfig;

    const result = await initSessionState({
      ctx: finalizeInboundContext({
        Body: "[WhatsApp +1] ping",
        BodyForAgent: "ping",
        ChatType: "direct",
        SenderName: "Bob",
        SenderE164: "+222",
        SessionKey: "agent:main:whatsapp:dm:+222",
      }),
      cfg,
      commandAuthorized: true,
    });

    expect(result.sessionCtx.BodyStripped).toBe("ping");
  });
});

describe("mention helpers", () => {
  it("builds regexes and skips invalid or unsafe patterns", () => {
    const regexes = buildMentionRegexes({
      messages: {
        groupChat: { mentionPatterns: ["\\bopenclaw\\b", "(invalid", "(a+)+$"] },
      },
    });
    expect(regexes).toHaveLength(1);
    expect(regexes[0]?.test("openclaw")).toBe(true);
  });

  it("normalizes zero-width characters", () => {
    expect(normalizeMentionText("open\u200bclaw")).toBe("openclaw");
  });

  it("matches patterns case-insensitively", () => {
    const regexes = buildMentionRegexes({
      messages: { groupChat: { mentionPatterns: ["\\bopenclaw\\b"] } },
    });
    expect(matchesMentionPatterns("OPENCLAW: hi", regexes)).toBe(true);
  });

  it("lets catch-all mention patterns match empty text", () => {
    const catchAllRegexes = buildMentionRegexes({
      messages: { groupChat: { mentionPatterns: [".*"] } },
    });
    const specificRegexes = buildMentionRegexes({
      messages: { groupChat: { mentionPatterns: ["\\bopenclaw\\b"] } },
    });

    expect(matchesMentionPatterns("", catchAllRegexes)).toBe(true);
    expect(matchesMentionPatterns("", specificRegexes)).toBe(false);
  });

  it("uses per-agent mention patterns when configured", () => {
    const regexes = buildMentionRegexes(
      {
        messages: {
          groupChat: { mentionPatterns: ["\\bglobal\\b"] },
        },
        agents: {
          list: [
            {
              id: "work",
              groupChat: { mentionPatterns: ["\\bworkbot\\b"] },
            },
          ],
        },
      },
      "work",
    );
    expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true);
    expect(matchesMentionPatterns("global: hi", regexes)).toBe(false);
  });

  it("scopes configured mention patterns by provider conversation policy", () => {
    const cfg = {
      messages: {
        groupChat: {
          mentionPatterns: ["\\bopenclaw\\b"],
        },
      },
      channels: {
        slack: {
          mentionPatterns: {
            mode: "deny",
            allowIn: ["C123"],
          },
        },
      },
    } satisfies OpenClawConfig;

    const allowed = buildMentionRegexes(cfg, undefined, {
      provider: "slack",
      conversationId: "C123",
    });
    const denied = buildMentionRegexes(cfg, undefined, {
      provider: "slack",
      conversationId: "C999",
    });

    expect(matchesMentionPatterns("openclaw: hi", allowed)).toBe(true);
    expect(matchesMentionPatterns("openclaw: hi", denied)).toBe(false);
  });

  it("preserves mention patterns for callers without scoped policy facts", () => {
    const regexes = buildMentionRegexes({
      messages: {
        groupChat: {
          mentionPatterns: ["\\bopenclaw\\b"],
        },
      },
    });

    expect(matchesMentionPatterns("openclaw", regexes)).toBe(true);
  });

  it("lets provider deny lists override globally allowed mention patterns", () => {
    const cfg = {
      messages: {
        groupChat: {
          mentionPatterns: ["\\bopenclaw\\b"],
        },
      },
      channels: {
        telegram: {
          mentionPatterns: {
            denyIn: ["-100:topic:7"],
          },
        },
      },
    } satisfies OpenClawConfig;

    expect(
      buildMentionRegexes(cfg, undefined, {
        provider: "telegram",
        conversationId: "-100:topic:7",
      }),
    ).toEqual([]);
    expect(
      matchesMentionPatterns(
        "openclaw",
        buildMentionRegexes(cfg, undefined, {
          provider: "telegram",
          conversationId: "-100:topic:8",
        }),
      ),
    ).toBe(true);
  });

  it("strips safe mention patterns and ignores unsafe ones", () => {
    const stripped = stripMentions("openclaw " + "a".repeat(28) + "!", {} as MsgContext, {
      messages: {
        groupChat: { mentionPatterns: ["\\bopenclaw\\b", "(a+)+$"] },
      },
    });
    expect(stripped).toBe(`${"a".repeat(28)}!`);
  });

  it("strips provider mention regexes without config compilation", () => {
    const stripped = stripMentions("<@12345> hello", { Provider: "discord" } as MsgContext, {});
    expect(stripped).toBe("< > hello");
  });
});

describe("resolveGroupRequireMention", () => {
  beforeEach(() => {
    resetPluginRuntimeStateForTest();
    installGroupRequireMentionTestPlugins();
  });

  it("respects Discord guild/channel requireMention settings", async () => {
    const cfg: OpenClawConfig = {
      channels: {
        discord: {
          guilds: {
            "145": {
              channels: {
                "123": { requireMention: false },
              },
            },
          },
        },
      },
    };
    const ctx: TemplateContext = {
      Provider: "discord",
      From: "discord:group:123",
      GroupChannel: "#general",
      GroupSpace: "145",
    };
    const groupResolution: GroupKeyResolution = {
      key: "discord:group:123",
      channel: "discord",
      id: "123",
      chatType: "group",
    };

    await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
  });

  it("respects Slack channel requireMention settings", async () => {
    const cfg: OpenClawConfig = {
      channels: {
        slack: {
          channels: {
            C123: { requireMention: false },
          },
        },
      },
    };
    const ctx: TemplateContext = {
      Provider: "slack",
      From: "slack:channel:C123",
      GroupSubject: "#general",
    };
    const groupResolution: GroupKeyResolution = {
      key: "slack:group:C123",
      channel: "slack",
      id: "C123",
      chatType: "group",
    };

    await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
  });

  it("uses Slack fallback resolver semantics for default-account wildcard channels", async () => {
    const cfg: OpenClawConfig = {
      channels: {
        slack: {
          defaultAccount: "work",
          accounts: {
            work: {
              channels: {
                "*": { requireMention: false },
              },
            },
          },
        },
      },
    };
    const ctx: TemplateContext = {
      Provider: "slack",
      From: "slack:channel:C123",
      GroupSubject: "#alerts",
    };
    const groupResolution: GroupKeyResolution = {
      key: "slack:group:C123",
      channel: "slack",
      id: "C123",
      chatType: "group",
    };

    await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
  });

  it("keeps core reply-stage resolution aligned for Slack default-account wildcard fallbacks", async () => {
    const cfg: OpenClawConfig = {
      channels: {
        slack: {
          defaultAccount: "work",
          accounts: {
            work: {
              channels: {
                "*": { requireMention: false },
              },
            },
          },
        },
      },
    };
    const ctx: TemplateContext = {
      Provider: "slack",
      From: "slack:channel:C123",
      GroupSubject: "#alerts",
    };
    const groupResolution: GroupKeyResolution = {
      key: "slack:group:C123",
      channel: "slack",
      id: "C123",
      chatType: "group",
    };

    await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
  });

  it("uses Discord fallback resolver semantics for guild slug matches", async () => {
    const cfg: OpenClawConfig = {
      channels: {
        discord: {
          guilds: {
            "145": {
              slug: "dev",
              requireMention: false,
            },
          },
        },
      },
    };
    const ctx: TemplateContext = {
      Provider: "discord",
      From: "discord:group:123",
      GroupChannel: "#general",
      GroupSpace: "dev",
    };
    const groupResolution: GroupKeyResolution = {
      key: "discord:group:123",
      channel: "discord",
      id: "123",
      chatType: "group",
    };

    await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
  });

  it("keeps core reply-stage resolution aligned for Discord slug + wildcard guild fallbacks", async () => {
    const cfg: OpenClawConfig = {
      channels: {
        discord: {
          guilds: {
            "*": {
              requireMention: false,
              channels: {
                help: { requireMention: true },
              },
            },
          },
        },
      },
    };
    const ctx: TemplateContext = {
      Provider: "discord",
      From: "discord:group:999",
      GroupChannel: "#help",
      GroupSpace: "guild-slug",
    };
    const groupResolution: GroupKeyResolution = {
      key: "discord:group:999",
      channel: "discord",
      id: "999",
      chatType: "group",
    };

    await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(true);
  });

  it("respects LINE prefixed group keys in reply-stage requireMention resolution", async () => {
    const cfg: OpenClawConfig = {
      channels: {
        line: {
          groups: {
            r123: { requireMention: false },
          },
        },
      },
    };
    const ctx: TemplateContext = {
      Provider: "line",
      From: "line:room:r123",
    };
    const groupResolution: GroupKeyResolution = {
      key: "line:group:r123",
      channel: "line",
      id: "r123",
      chatType: "group",
    };

    await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
  });

  it("preserves plugin-backed channel requireMention resolution", async () => {
    const cfg: OpenClawConfig = {
      channels: {
        imessage: {
          groups: {
            "chat:primary": { requireMention: false },
          },
        },
      },
    };
    const ctx: TemplateContext = {
      Provider: "imessage",
      From: "imessage:group:chat:primary",
    };
    const groupResolution: GroupKeyResolution = {
      key: "imessage:group:chat:primary",
      channel: "imessage",
      id: "chat:primary",
      chatType: "group",
    };

    await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
  });
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
