openclaw/src/channels/plugins/group-mentions.test.ts
Yi-Cheng Wang 4682f3cace
Fix/Complete LINE requireMention gating behavior (#35847)
* fix(line): enforce requireMention gating in group message handler

* fix(line): scope canDetectMention to text messages, pass hasAnyMention

* fix(line): fix TS errors in mentionees type and test casts

* feat(line): register LINE in DOCKS and CHAT_CHANNEL_ORDER

- Add "line" to CHAT_CHANNEL_ORDER and CHAT_CHANNEL_META in registry.ts
- Export resolveLineGroupRequireMention and resolveLineGroupToolPolicy
  in group-mentions.ts using the generic resolveChannelGroupRequireMention
  and resolveChannelGroupToolsPolicy helpers (same pattern as iMessage)
- Add "line" entry to DOCKS in dock.ts so resolveGroupRequireMention
  in the reply stage can correctly read LINE group config

Fixes the third layer of the requireMention bug: previously
getChannelDock("line") returned undefined, causing the reply-stage
resolveGroupRequireMention to fall back to true unconditionally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(line): pending history, requireMention default, mentionPatterns fallback

- Default requireMention to true (consistent with other channels)
- Add mentionPatterns regex fallback alongside native isSelf/@all detection
- Record unmentioned group messages via recordPendingHistoryEntryIfEnabled
- Inject pending history context in buildLineMessageContext when bot is mentioned

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(line): update tests for requireMention default and pending history

- Add requireMention: false to 6 group tests unrelated to mention gating
  (allowlist, replay dedup, inflight dedup, error retry) to preserve
  their original intent after the default changed from false to true
- Add test: skips group messages by default when requireMention not configured
- Add test: records unmentioned group messages as pending history

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(line): use undefined instead of empty string as historyKey sentinel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(line): deliver pending history via InboundHistory, not Body mutation

- Remove post-hoc ctxPayload.Body injection (BodyForAgent takes priority
  in the prompt pipeline, so Body was never reached)
- Pass InboundHistory array to finalizeInboundContext instead, matching
  the Telegram pattern rendered by buildInboundUserContextPrefix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(line): pass agentId to buildMentionRegexes for per-agent mentionPatterns

- Resolve route before mention gating to obtain agentId
- Pass agentId to buildMentionRegexes, matching Telegram behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(line): clear pending history after handled group turn

- Call clearHistoryEntriesIfEnabled after processMessage for group messages
- Prevents stale skipped messages from replaying on subsequent mentions
- Matches Discord, Signal, Slack, iMessage behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* style(line): fix import order and merge orphaned JSDoc in bot-handlers

- Move resolveAgentRoute import from ./local group to ../routing group
- Merge duplicate JSDoc blocks above getLineMentionees into one

Addresses Greptile review comments r2888826724 and r2888826840 on PR #35847.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(line): read historyLimit from config and guard clear with has()

- bot.ts: resolve historyLimit from cfg.messages.groupChat.historyLimit
  with fallback to DEFAULT_GROUP_HISTORY_LIMIT, so setting historyLimit: 0
  actually disables pending history accumulation
- bot-handlers.ts: add groupHistories.has(historyKey) guard before
  clearHistoryEntriesIfEnabled to prevent writing empty buckets for
  groups that have never accumulated pending history (memory leak)

Addresses Codex review comments r2888829146 and r2888829152 on PR #35847.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* style(line): apply oxfmt formatting to bot-handlers and bot

Auto-formatted by oxfmt to fix CI format:check failure on PR #35847.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(line): add shouldLogVerbose to globals mock in bot-handlers test

resolveAgentRoute calls shouldLogVerbose() from globals.js; the mock
was missing this export, causing 13 test failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Address review findings for #35847

---------

Co-authored-by: Kaiyi <me@kaiyi.cool>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Yi-Cheng Wang <yicheng.wang@heph-ai.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-07 14:06:07 -06:00

278 lines
7.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
resolveLineGroupRequireMention,
resolveLineGroupToolPolicy,
resolveSlackGroupRequireMention,
resolveSlackGroupToolPolicy,
resolveTelegramGroupRequireMention,
resolveTelegramGroupToolPolicy,
} from "./group-mentions.js";
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
channels: {
alerts: {
requireMention: false,
tools: { allow: ["message.send"] },
toolsBySender: {
"id:user:alice": { allow: ["sessions.list"] },
},
},
"*": {
requireMention: true,
tools: { deny: ["exec"] },
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
describe("group mentions (slack)", () => {
it("uses matched channel requireMention and wildcard fallback", () => {
expect(resolveSlackGroupRequireMention({ cfg, groupChannel: "#alerts" })).toBe(false);
expect(resolveSlackGroupRequireMention({ cfg, groupChannel: "#missing" })).toBe(true);
});
it("resolves sender override, then channel tools, then wildcard tools", () => {
const senderOverride = resolveSlackGroupToolPolicy({
cfg,
groupChannel: "#alerts",
senderId: "user:alice",
});
expect(senderOverride).toEqual({ allow: ["sessions.list"] });
const channelTools = resolveSlackGroupToolPolicy({
cfg,
groupChannel: "#alerts",
senderId: "user:bob",
});
expect(channelTools).toEqual({ allow: ["message.send"] });
const wildcardTools = resolveSlackGroupToolPolicy({
cfg,
groupChannel: "#missing",
senderId: "user:bob",
});
expect(wildcardTools).toEqual({ deny: ["exec"] });
});
});
describe("group mentions (telegram)", () => {
it("resolves topic-level requireMention and chat-level tools for topic ids", () => {
const telegramCfg = {
channels: {
telegram: {
botToken: "telegram-test",
groups: {
"-1001": {
requireMention: true,
tools: { allow: ["message.send"] },
topics: {
"77": {
requireMention: false,
},
},
},
"*": {
requireMention: true,
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(
resolveTelegramGroupRequireMention({ cfg: telegramCfg, groupId: "-1001:topic:77" }),
).toBe(false);
expect(resolveTelegramGroupToolPolicy({ cfg: telegramCfg, groupId: "-1001:topic:77" })).toEqual(
{
allow: ["message.send"],
},
);
});
});
describe("group mentions (discord)", () => {
it("prefers channel policy, then guild policy, with sender-specific overrides", () => {
const discordCfg = {
channels: {
discord: {
token: "discord-test",
guilds: {
guild1: {
requireMention: false,
tools: { allow: ["message.guild"] },
toolsBySender: {
"id:user:guild-admin": { allow: ["sessions.list"] },
},
channels: {
"123": {
requireMention: true,
tools: { allow: ["message.channel"] },
toolsBySender: {
"id:user:channel-admin": { deny: ["exec"] },
},
},
},
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(
resolveDiscordGroupRequireMention({ cfg: discordCfg, groupSpace: "guild1", groupId: "123" }),
).toBe(true);
expect(
resolveDiscordGroupRequireMention({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "missing",
}),
).toBe(false);
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "123",
senderId: "user:channel-admin",
}),
).toEqual({ deny: ["exec"] });
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "123",
senderId: "user:someone",
}),
).toEqual({ allow: ["message.channel"] });
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "missing",
senderId: "user:guild-admin",
}),
).toEqual({ allow: ["sessions.list"] });
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "missing",
senderId: "user:someone",
}),
).toEqual({ allow: ["message.guild"] });
});
});
describe("group mentions (bluebubbles)", () => {
it("uses generic channel group policy helpers", () => {
const blueBubblesCfg = {
channels: {
bluebubbles: {
groups: {
"chat:primary": {
requireMention: false,
tools: { deny: ["exec"] },
},
"*": {
requireMention: true,
tools: { allow: ["message.send"] },
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(
resolveBlueBubblesGroupRequireMention({ cfg: blueBubblesCfg, groupId: "chat:primary" }),
).toBe(false);
expect(
resolveBlueBubblesGroupRequireMention({ cfg: blueBubblesCfg, groupId: "chat:other" }),
).toBe(true);
expect(
resolveBlueBubblesGroupToolPolicy({ cfg: blueBubblesCfg, groupId: "chat:primary" }),
).toEqual({ deny: ["exec"] });
expect(
resolveBlueBubblesGroupToolPolicy({ cfg: blueBubblesCfg, groupId: "chat:other" }),
).toEqual({
allow: ["message.send"],
});
});
});
describe("group mentions (line)", () => {
it("matches raw and prefixed LINE group keys for requireMention and tools", () => {
const lineCfg = {
channels: {
line: {
groups: {
"room:r123": {
requireMention: false,
tools: { allow: ["message.send"] },
},
"group:g123": {
requireMention: false,
tools: { deny: ["exec"] },
},
"*": {
requireMention: true,
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(resolveLineGroupRequireMention({ cfg: lineCfg, groupId: "r123" })).toBe(false);
expect(resolveLineGroupRequireMention({ cfg: lineCfg, groupId: "room:r123" })).toBe(false);
expect(resolveLineGroupRequireMention({ cfg: lineCfg, groupId: "g123" })).toBe(false);
expect(resolveLineGroupRequireMention({ cfg: lineCfg, groupId: "group:g123" })).toBe(false);
expect(resolveLineGroupRequireMention({ cfg: lineCfg, groupId: "other" })).toBe(true);
expect(resolveLineGroupToolPolicy({ cfg: lineCfg, groupId: "r123" })).toEqual({
allow: ["message.send"],
});
expect(resolveLineGroupToolPolicy({ cfg: lineCfg, groupId: "g123" })).toEqual({
deny: ["exec"],
});
});
it("uses account-scoped prefixed LINE group config for requireMention", () => {
const lineCfg = {
channels: {
line: {
groups: {
"*": {
requireMention: true,
},
},
accounts: {
work: {
groups: {
"group:g123": {
requireMention: false,
},
},
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(
resolveLineGroupRequireMention({ cfg: lineCfg, groupId: "g123", accountId: "work" }),
).toBe(false);
});
});