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>
This commit is contained in:
Yi-Cheng Wang
2026-03-08 04:06:07 +08:00
committed by GitHub
parent d6f28a3da7
commit 4682f3cace
14 changed files with 887 additions and 37 deletions

View File

@@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- LINE/`requireMention` group gating: align inbound and reply-stage LINE group policy resolution across raw, `group:`, and `room:` keys (including account-scoped group config), preserve plugin-backed reply-stage fallback behavior, and add regression coverage for prefixed-only group/room config plus reply-stage policy resolution. (#35847) Thanks @kirisame-wang.
- Onboarding/local setup: default unset local `tools.profile` to `coding` instead of `messaging`, restoring file/runtime tools for fresh local installs while preserving explicit user-set profiles. (from #38241, overlap with #34958) Thanks @cgdusek.
- Gateway/Telegram stale-socket restart guard: only apply stale-socket restarts to channels that publish event-liveness timestamps, preventing Telegram providers from being misclassified as stale solely due to long uptime and avoiding restart/pairing storms after upgrade. (openclaw#38464)
- Onboarding/headless Linux daemon probe hardening: treat `systemctl --user is-enabled` probe failures as non-fatal during daemon install flow so onboarding no longer crashes on SSH/headless VPS environments before showing install guidance. (#37297) Thanks @acarbajal-web.

View File

@@ -469,4 +469,52 @@ describe("resolveGroupRequireMention", () => {
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
});
it("respects LINE prefixed group keys in reply-stage requireMention resolution", () => {
const cfg: OpenClawConfig = {
channels: {
line: {
groups: {
"room: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",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
});
it("preserves plugin-backed channel requireMention resolution", () => {
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
groups: {
"chat:primary": { requireMention: false },
},
},
},
};
const ctx: TemplateContext = {
Provider: "bluebubbles",
From: "bluebubbles:group:chat:primary",
};
const groupResolution: GroupKeyResolution = {
key: "bluebubbles:group:chat:primary",
channel: "bluebubbles",
id: "chat:primary",
chatType: "group",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
});
});

View File

@@ -1,6 +1,11 @@
import { getChannelDock } from "../../channels/dock.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import {
getChannelPlugin,
normalizeChannelId as normalizePluginChannelId,
} from "../../channels/plugins/index.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { resolveChannelGroupRequireMention } from "../../config/group-policy.js";
import type { GroupKeyResolution, SessionEntry } from "../../config/sessions.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
import { normalizeGroupActivation } from "../group-activation.js";
@@ -28,6 +33,25 @@ function extractGroupId(raw: string | undefined | null): string | undefined {
return trimmed;
}
function resolveDockChannelId(raw?: string | null): ChannelId | null {
const normalized = raw?.trim().toLowerCase();
if (!normalized) {
return null;
}
try {
if (getChannelDock(normalized as ChannelId)) {
return normalized as ChannelId;
}
} catch {
// Plugin registry may not be initialized in shared/test contexts.
}
try {
return normalizePluginChannelId(raw) ?? (normalized as ChannelId);
} catch {
return normalized as ChannelId;
}
}
export function resolveGroupRequireMention(params: {
cfg: OpenClawConfig;
ctx: TemplateContext;
@@ -35,24 +59,34 @@ export function resolveGroupRequireMention(params: {
}): boolean {
const { cfg, ctx, groupResolution } = params;
const rawChannel = groupResolution?.channel ?? ctx.Provider?.trim();
const channel = normalizeChannelId(rawChannel);
const channel = resolveDockChannelId(rawChannel);
if (!channel) {
return true;
}
const groupId = groupResolution?.id ?? extractGroupId(ctx.From);
const groupChannel = ctx.GroupChannel?.trim() ?? ctx.GroupSubject?.trim();
const groupSpace = ctx.GroupSpace?.trim();
const requireMention = getChannelDock(channel)?.groups?.resolveRequireMention?.({
cfg,
groupId,
groupChannel,
groupSpace,
accountId: ctx.AccountId,
});
let requireMention: boolean | undefined;
try {
requireMention = getChannelDock(channel)?.groups?.resolveRequireMention?.({
cfg,
groupId,
groupChannel,
groupSpace,
accountId: ctx.AccountId,
});
} catch {
requireMention = undefined;
}
if (typeof requireMention === "boolean") {
return requireMention;
}
return true;
return resolveChannelGroupRequireMention({
cfg,
channel,
groupId,
accountId: ctx.AccountId,
});
}
export function defaultGroupActivation(requireMention: boolean): "always" | "mention" {
@@ -70,7 +104,7 @@ function resolveProviderLabel(rawProvider: string | undefined): string {
if (isInternalMessageChannel(providerKey)) {
return "WebChat";
}
const providerId = normalizeChannelId(rawProvider?.trim());
const providerId = resolveDockChannelId(rawProvider?.trim());
if (providerId) {
return getChannelPlugin(providerId)?.meta.label ?? providerId;
}
@@ -114,7 +148,7 @@ export function buildGroupIntro(params: {
const activation =
normalizeGroupActivation(params.sessionEntry?.groupActivation) ?? params.defaultActivation;
const rawProvider = params.sessionCtx.Provider?.trim();
const providerId = normalizeChannelId(rawProvider);
const providerId = resolveDockChannelId(rawProvider);
const activationLine =
activation === "always"
? "Activation: always-on (you receive every group message)."

View File

@@ -26,6 +26,8 @@ import {
resolveGoogleChatGroupToolPolicy,
resolveIMessageGroupRequireMention,
resolveIMessageGroupToolPolicy,
resolveLineGroupRequireMention,
resolveLineGroupToolPolicy,
resolveSlackGroupRequireMention,
resolveSlackGroupToolPolicy,
resolveTelegramGroupRequireMention,
@@ -547,6 +549,18 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
buildIMessageThreadToolContext({ context, hasRepliedRef }),
},
},
line: {
id: "line",
capabilities: {
chatTypes: ["direct", "group"],
media: true,
},
outbound: { textChunkLimit: 5000 },
groups: {
resolveRequireMention: resolveLineGroupRequireMention,
resolveToolPolicy: resolveLineGroupToolPolicy,
},
},
};
function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock {

View File

@@ -4,6 +4,8 @@ import {
resolveBlueBubblesGroupToolPolicy,
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
resolveLineGroupRequireMention,
resolveLineGroupToolPolicy,
resolveSlackGroupRequireMention,
resolveSlackGroupToolPolicy,
resolveTelegramGroupRequireMention,
@@ -208,3 +210,68 @@ describe("group mentions (bluebubbles)", () => {
});
});
});
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);
});
});

View File

@@ -9,6 +9,7 @@ import type {
GroupToolPolicyBySenderConfig,
GroupToolPolicyConfig,
} from "../../config/types.tools.js";
import { resolveExactLineGroupConfigKey } from "../../line/group-keys.js";
import { normalizeAtHashSlug, normalizeHyphenSlug } from "../../shared/string-normalization.js";
import { inspectSlackAccount } from "../../slack/account-inspect.js";
import type { ChannelGroupContext } from "./types.js";
@@ -125,7 +126,8 @@ type ChannelGroupPolicyChannel =
| "whatsapp"
| "imessage"
| "googlechat"
| "bluebubbles";
| "bluebubbles"
| "line";
function resolveSlackChannelPolicyEntry(
params: GroupMentionParams,
@@ -322,3 +324,34 @@ export function resolveBlueBubblesGroupToolPolicy(
): GroupToolPolicyConfig | undefined {
return resolveChannelToolPolicyForSender(params, "bluebubbles");
}
export function resolveLineGroupRequireMention(params: GroupMentionParams): boolean {
const exactGroupId = resolveExactLineGroupConfigKey({
cfg: params.cfg,
accountId: params.accountId,
groupId: params.groupId,
});
if (exactGroupId) {
return resolveChannelGroupRequireMention({
cfg: params.cfg,
channel: "line",
groupId: exactGroupId,
accountId: params.accountId,
});
}
return resolveChannelRequireMention(params, "line");
}
export function resolveLineGroupToolPolicy(
params: GroupMentionParams,
): GroupToolPolicyConfig | undefined {
const exactGroupId = resolveExactLineGroupConfigKey({
cfg: params.cfg,
accountId: params.accountId,
groupId: params.groupId,
});
if (exactGroupId) {
return resolveChannelToolPolicyForSender(params, "line", exactGroupId);
}
return resolveChannelToolPolicyForSender(params, "line");
}

View File

@@ -13,6 +13,7 @@ export const CHAT_CHANNEL_ORDER = [
"slack",
"signal",
"imessage",
"line",
] as const;
export type ChatChannelId = (typeof CHAT_CHANNEL_ORDER)[number];
@@ -107,6 +108,16 @@ const CHAT_CHANNEL_META: Record<ChatChannelId, ChannelMeta> = {
blurb: "this is still a work in progress.",
systemImage: "message.fill",
},
line: {
id: "line",
label: "LINE",
selectionLabel: "LINE (Messaging API)",
detailLabel: "LINE Bot",
docsPath: "/channels/line",
docsLabel: "line",
blurb: "LINE Messaging API webhook bot.",
systemImage: "message",
},
};
export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = {

View File

@@ -6,6 +6,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../globals.js", () => ({
danger: (text: string) => text,
logVerbose: () => {},
shouldLogVerbose: () => false,
}));
vi.mock("../pairing/pairing-labels.js", () => ({
@@ -100,7 +101,7 @@ function createOpenGroupReplayContext(
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: { groupPolicy: "open" },
config: { groupPolicy: "open", groups: { "*": { requireMention: false } } },
},
runtime: createRuntime(),
mediaMaxBytes: 1,
@@ -213,7 +214,11 @@ describe("handleLineWebhookEvents", () => {
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: { groupPolicy: "allowlist", groupAllowFrom: ["user-3"] },
config: {
groupPolicy: "allowlist",
groupAllowFrom: ["user-3"],
groups: { "*": { requireMention: false } },
},
},
runtime: createRuntime(),
mediaMaxBytes: 1,
@@ -511,7 +516,11 @@ describe("handleLineWebhookEvents", () => {
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: { groupPolicy: "allowlist", groupAllowFrom: ["user-dup"] },
config: {
groupPolicy: "allowlist",
groupAllowFrom: ["user-dup"],
groups: { "*": { requireMention: false } },
},
},
runtime: createRuntime(),
mediaMaxBytes: 1,
@@ -586,6 +595,313 @@ describe("handleLineWebhookEvents", () => {
expect(processMessage).toHaveBeenCalledTimes(1);
});
it("skips group messages by default when requireMention is not configured", async () => {
const processMessage = vi.fn();
const event = {
type: "message",
message: { id: "m-default-skip", type: "text", text: "hi there" },
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "group", groupId: "group-default", userId: "user-default" },
mode: "active",
webhookEventId: "evt-default-skip",
deliveryContext: { isRedelivery: false },
} as MessageEvent;
await handleLineWebhookEvents([event], {
cfg: { channels: { line: { groupPolicy: "open" } } },
account: {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: { groupPolicy: "open" },
},
runtime: createRuntime(),
mediaMaxBytes: 1,
processMessage,
});
expect(processMessage).not.toHaveBeenCalled();
expect(buildLineMessageContextMock).not.toHaveBeenCalled();
});
it("records unmentioned group messages as pending history", async () => {
const processMessage = vi.fn();
const groupHistories = new Map<
string,
import("../auto-reply/reply/history.js").HistoryEntry[]
>();
const event = {
type: "message",
message: { id: "m-hist-1", type: "text", text: "hello history" },
replyToken: "reply-token",
timestamp: 1700000000000,
source: { type: "group", groupId: "group-hist-1", userId: "user-hist" },
mode: "active",
webhookEventId: "evt-hist-1",
deliveryContext: { isRedelivery: false },
} as MessageEvent;
await handleLineWebhookEvents([event], {
cfg: { channels: { line: { groupPolicy: "open" } } },
account: {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: { groupPolicy: "open" },
},
runtime: createRuntime(),
mediaMaxBytes: 1,
processMessage,
groupHistories,
});
expect(processMessage).not.toHaveBeenCalled();
const entries = groupHistories.get("group-hist-1");
expect(entries).toHaveLength(1);
expect(entries?.[0]).toMatchObject({
sender: "user:user-hist",
body: "hello history",
timestamp: 1700000000000,
});
});
it("skips group messages without mention when requireMention is set", async () => {
const processMessage = vi.fn();
const event = {
type: "message",
message: { id: "m-mention-1", type: "text", text: "hi there" },
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "group", groupId: "group-mention", userId: "user-mention" },
mode: "active",
webhookEventId: "evt-mention-1",
deliveryContext: { isRedelivery: false },
} as MessageEvent;
await handleLineWebhookEvents([event], {
cfg: { channels: { line: { groupPolicy: "open" } } },
account: {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
runtime: createRuntime(),
mediaMaxBytes: 1,
processMessage,
});
expect(processMessage).not.toHaveBeenCalled();
expect(buildLineMessageContextMock).not.toHaveBeenCalled();
});
it("processes group messages with bot mention when requireMention is set", async () => {
const processMessage = vi.fn();
// Simulate a LINE text message with mention.mentionees containing isSelf=true
const event = {
type: "message",
message: {
id: "m-mention-2",
type: "text",
text: "@Bot hi there",
mention: {
mentionees: [{ index: 0, length: 4, type: "user", isSelf: true }],
},
},
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "group", groupId: "group-mention", userId: "user-mention" },
mode: "active",
webhookEventId: "evt-mention-2",
deliveryContext: { isRedelivery: false },
} as unknown as MessageEvent;
await handleLineWebhookEvents([event], {
cfg: { channels: { line: { groupPolicy: "open" } } },
account: {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
runtime: createRuntime(),
mediaMaxBytes: 1,
processMessage,
});
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
expect(processMessage).toHaveBeenCalledTimes(1);
});
it("processes group messages with @all mention when requireMention is set", async () => {
const processMessage = vi.fn();
const event = {
type: "message",
message: {
id: "m-mention-3",
type: "text",
text: "@All hi there",
mention: {
mentionees: [{ index: 0, length: 4, type: "all" }],
},
},
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "group", groupId: "group-mention", userId: "user-mention" },
mode: "active",
webhookEventId: "evt-mention-3",
deliveryContext: { isRedelivery: false },
} as MessageEvent;
await handleLineWebhookEvents([event], {
cfg: { channels: { line: { groupPolicy: "open" } } },
account: {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
runtime: createRuntime(),
mediaMaxBytes: 1,
processMessage,
});
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
expect(processMessage).toHaveBeenCalledTimes(1);
});
it("does not apply requireMention gating to DM messages", async () => {
const processMessage = vi.fn();
const event = {
type: "message",
message: { id: "m-mention-dm", type: "text", text: "hi" },
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "user", userId: "user-dm" },
mode: "active",
webhookEventId: "evt-mention-dm",
deliveryContext: { isRedelivery: false },
} as MessageEvent;
await handleLineWebhookEvents([event], {
cfg: { channels: { line: { dmPolicy: "open" } } },
account: {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: {
dmPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
runtime: createRuntime(),
mediaMaxBytes: 1,
processMessage,
});
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
expect(processMessage).toHaveBeenCalledTimes(1);
});
it("allows non-text group messages through when requireMention is set (cannot detect mention)", async () => {
const processMessage = vi.fn();
// Image message -- LINE only carries mention metadata on text messages.
const event = {
type: "message",
message: { id: "m-mention-img", type: "image", contentProvider: { type: "line" } },
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "group", groupId: "group-1", userId: "user-img" },
mode: "active",
webhookEventId: "evt-mention-img",
deliveryContext: { isRedelivery: false },
} as MessageEvent;
await handleLineWebhookEvents([event], {
cfg: { channels: { line: { groupPolicy: "open" } } },
account: {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
runtime: createRuntime(),
mediaMaxBytes: 1,
processMessage,
});
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
expect(processMessage).toHaveBeenCalledTimes(1);
});
it("does not bypass mention gating when non-bot mention is present with control command", async () => {
const processMessage = vi.fn();
// Text message mentions another user (not bot) together with a control command.
const event = {
type: "message",
message: {
id: "m-mention-other",
type: "text",
text: "@other !status",
mention: { mentionees: [{ index: 0, length: 6, type: "user", isSelf: false }] },
},
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "group", groupId: "group-1", userId: "user-other" },
mode: "active",
webhookEventId: "evt-mention-other",
deliveryContext: { isRedelivery: false },
} as unknown as MessageEvent;
await handleLineWebhookEvents([event], {
cfg: { channels: { line: { groupPolicy: "open" } } },
account: {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
runtime: createRuntime(),
mediaMaxBytes: 1,
processMessage,
});
// Should be skipped because there is a non-bot mention and the bot was not mentioned.
expect(processMessage).not.toHaveBeenCalled();
});
it("does not mark replay cache when event processing fails", async () => {
const processMessage = vi
.fn()

View File

@@ -8,7 +8,15 @@ import type {
PostbackEvent,
} from "@line/bot-sdk";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import {
clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntryIfEnabled,
type HistoryEntry,
} from "../auto-reply/reply/history.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js";
import { resolveControlCommandGate } from "../channels/command-gating.js";
import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
import type { OpenClawConfig } from "../config/config.js";
import {
resolveAllowlistProviderRuntimeGroupPolicy,
@@ -22,6 +30,7 @@ import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../pairing/pairing-store.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import type { RuntimeEnv } from "../runtime.js";
import {
firstDefined,
@@ -37,6 +46,7 @@ import {
type LineInboundContext,
} from "./bot-message-context.js";
import { downloadLineMedia } from "./download.js";
import { resolveLineGroupConfigEntry } from "./group-keys.js";
import { pushMessageLine, replyMessageLine } from "./send.js";
import type { LineGroupConfig, ResolvedLineAccount } from "./types.js";
@@ -65,6 +75,8 @@ export interface LineHandlerContext {
mediaMaxBytes: number;
processMessage: (ctx: LineInboundContext) => Promise<void>;
replayCache?: LineWebhookReplayCache;
groupHistories?: Map<string, HistoryEntry[]>;
historyLimit?: number;
}
const LINE_WEBHOOK_REPLAY_WINDOW_MS = 10 * 60 * 1000;
@@ -213,14 +225,10 @@ function resolveLineGroupConfig(params: {
groupId?: string;
roomId?: string;
}): LineGroupConfig | undefined {
const groups = params.config.groups ?? {};
if (params.groupId) {
return groups[params.groupId] ?? groups[`group:${params.groupId}`] ?? groups["*"];
}
if (params.roomId) {
return groups[params.roomId] ?? groups[`room:${params.roomId}`] ?? groups["*"];
}
return groups["*"];
return resolveLineGroupConfigEntry(params.config.groups, {
groupId: params.groupId,
roomId: params.roomId,
});
}
async function sendLinePairingReply(params: {
@@ -396,6 +404,34 @@ async function shouldProcessLineEvent(
};
}
/** Extract the mentionees array from a LINE text message (SDK types omit it).
* LINE webhook payloads include `mention.mentionees` on text messages with
* `isSelf: true` for the bot and `type: "all"` for @All mentions.
* The `@line/bot-sdk` types don't expose these fields, so we use a type assertion.
*/
function getLineMentionees(
message: MessageEvent["message"],
): Array<{ type?: string; isSelf?: boolean }> {
if (message.type !== "text") {
return [];
}
const mentionees = (
message as Record<string, unknown> & {
mention?: { mentionees?: Array<{ type?: string; isSelf?: boolean }> };
}
).mention?.mentionees;
return Array.isArray(mentionees) ? mentionees : [];
}
function isLineBotMentioned(message: MessageEvent["message"]): boolean {
return getLineMentionees(message).some((m) => m.isSelf === true || m.type === "all");
}
/** True when *any* @mention exists (bot or other users). */
function hasAnyLineMention(message: MessageEvent["message"]): boolean {
return getLineMentionees(message).length > 0;
}
function resolveEventRawText(event: MessageEvent | PostbackEvent): string {
if (event.type === "message") {
const msg = event.message;
@@ -440,6 +476,62 @@ async function handleMessageEvent(event: MessageEvent, context: LineHandlerConte
return;
}
// Mention gating: skip group messages that don't @mention the bot when required.
// Default requireMention to true (consistent with all other channels) unless
// the group config explicitly sets it to false.
const { isGroup, groupId, roomId } = getLineSourceInfo(event.source);
if (isGroup) {
const groupConfig = resolveLineGroupConfig({ config: account.config, groupId, roomId });
const requireMention = groupConfig?.requireMention !== false;
const rawText = message.type === "text" ? message.text : "";
const peerId = groupId ?? roomId ?? event.source.userId ?? "unknown";
const { agentId } = resolveAgentRoute({
cfg,
channel: "line",
accountId: account.accountId,
peer: { kind: "group", id: peerId },
});
const mentionRegexes = buildMentionRegexes(cfg, agentId);
const wasMentionedByNative = isLineBotMentioned(message);
const wasMentionedByPattern =
message.type === "text" ? matchesMentionPatterns(rawText, mentionRegexes) : false;
const wasMentioned = wasMentionedByNative || wasMentionedByPattern;
const mentionGate = resolveMentionGatingWithBypass({
isGroup: true,
requireMention,
// Only text messages carry mention metadata; non-text (image/video/etc.)
// cannot be gated on mentions, so we let them through.
canDetectMention: message.type === "text",
wasMentioned,
hasAnyMention: hasAnyLineMention(message),
allowTextCommands: true,
hasControlCommand: hasControlCommand(rawText, cfg),
commandAuthorized: decision.commandAuthorized,
});
if (mentionGate.shouldSkip) {
logVerbose(`line: skipping group message (requireMention, not mentioned)`);
// Store as pending history so the agent has context when later mentioned.
const historyKey = groupId ?? roomId;
const senderId =
event.source.type === "group" || event.source.type === "room"
? (event.source.userId ?? "unknown")
: "unknown";
if (historyKey && context.groupHistories) {
recordPendingHistoryEntryIfEnabled({
historyMap: context.groupHistories,
historyKey,
limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
entry: {
sender: `user:${senderId}`,
body: rawText || `<${message.type}>`,
timestamp: event.timestamp,
},
});
}
return;
}
}
// Download media if applicable
const allMedia: MediaRef[] = [];
@@ -467,6 +559,8 @@ async function handleMessageEvent(event: MessageEvent, context: LineHandlerConte
cfg,
account,
commandAuthorized: decision.commandAuthorized,
groupHistories: context.groupHistories,
historyLimit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
});
if (!messageContext) {
@@ -475,6 +569,19 @@ async function handleMessageEvent(event: MessageEvent, context: LineHandlerConte
}
await processMessage(messageContext);
// Clear pending history after a handled group turn so stale skipped messages
// don't replay on subsequent mentions ("since last reply" semantics).
if (isGroup && context.groupHistories) {
const historyKey = groupId ?? roomId;
if (historyKey && context.groupHistories.has(historyKey)) {
clearHistoryEntriesIfEnabled({
historyMap: context.groupHistories,
historyKey,
limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
});
}
}
}
async function handleFollowEvent(event: FollowEvent, _context: LineHandlerContext): Promise<void> {

View File

@@ -114,6 +114,52 @@ describe("buildLineMessageContext", () => {
expect(context?.ctxPayload.To).toBe("line:room:room-1");
});
it("resolves prefixed-only group config through the inbound message context", async () => {
const event = createMessageEvent({ type: "group", groupId: "group-1", userId: "user-1" });
const context = await buildLineMessageContext({
event,
allMedia: [],
cfg,
account: {
...account,
config: {
groups: {
"group:group-1": {
systemPrompt: "Use the prefixed group config",
},
},
},
},
commandAuthorized: true,
});
expect(context?.ctxPayload.GroupSystemPrompt).toBe("Use the prefixed group config");
});
it("resolves prefixed-only room config through the inbound message context", async () => {
const event = createMessageEvent({ type: "room", roomId: "room-1", userId: "user-1" });
const context = await buildLineMessageContext({
event,
allMedia: [],
cfg,
account: {
...account,
config: {
groups: {
"room:room-1": {
systemPrompt: "Use the prefixed room config",
},
},
},
},
commandAuthorized: true,
});
expect(context?.ctxPayload.GroupSystemPrompt).toBe("Use the prefixed room config");
});
it("keeps non-text message contexts fail-closed for command auth", async () => {
const event = createMessageEvent(
{ type: "user", userId: "user-audio" },

View File

@@ -1,5 +1,6 @@
import type { MessageEvent, StickerEventMessage, EventSource, PostbackEvent } from "@line/bot-sdk";
import { formatInboundEnvelope } from "../auto-reply/envelope.js";
import { type HistoryEntry } from "../auto-reply/reply/history.js";
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { formatLocationText, toLocationContext } from "../channels/location.js";
import { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js";
@@ -10,6 +11,7 @@ import { recordChannelActivity } from "../infra/channel-activity.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js";
import { normalizeAllowFrom } from "./bot-access.js";
import { resolveLineGroupConfigEntry, resolveLineGroupHistoryKey } from "./group-keys.js";
import type { ResolvedLineAccount, LineGroupConfig } from "./types.js";
interface MediaRef {
@@ -23,6 +25,8 @@ interface BuildLineMessageContextParams {
cfg: OpenClawConfig;
account: ResolvedLineAccount;
commandAuthorized: boolean;
groupHistories?: Map<string, HistoryEntry[]>;
historyLimit?: number;
}
export type LineSourceInfo = {
@@ -49,11 +53,12 @@ export function getLineSourceInfo(source: EventSource): LineSourceInfo {
}
function buildPeerId(source: EventSource): string {
if (source.type === "group" && source.groupId) {
return source.groupId;
}
if (source.type === "room" && source.roomId) {
return source.roomId;
const groupKey = resolveLineGroupHistoryKey({
groupId: source.type === "group" ? source.groupId : undefined,
roomId: source.type === "room" ? source.roomId : undefined,
});
if (groupKey) {
return groupKey;
}
if (source.type === "user" && source.userId) {
return source.userId;
@@ -211,13 +216,10 @@ function resolveLineGroupSystemPrompt(
groups: Record<string, LineGroupConfig | undefined> | undefined,
source: LineSourceInfoWithPeerId,
): string | undefined {
if (!groups) {
return undefined;
}
const entry =
(source.groupId ? (groups[source.groupId] ?? groups[`group:${source.groupId}`]) : undefined) ??
(source.roomId ? (groups[source.roomId] ?? groups[`room:${source.roomId}`]) : undefined) ??
groups["*"];
const entry = resolveLineGroupConfigEntry(groups, {
groupId: source.groupId,
roomId: source.roomId,
});
return entry?.systemPrompt?.trim() || undefined;
}
@@ -239,6 +241,7 @@ async function finalizeLineInboundContext(params: {
};
locationContext?: ReturnType<typeof toLocationContext>;
verboseLog: { kind: "inbound" | "postback"; mediaCount?: number };
inboundHistory?: Pick<HistoryEntry, "sender" | "body" | "timestamp">[];
}) {
const { fromAddress, toAddress, originatingTo } = resolveLineAddresses({
isGroup: params.source.isGroup,
@@ -308,6 +311,7 @@ async function finalizeLineInboundContext(params: {
GroupSystemPrompt: params.source.isGroup
? resolveLineGroupSystemPrompt(params.account.config.groups, params.source)
: undefined,
InboundHistory: params.inboundHistory,
});
const pinnedMainDmOwner = !params.source.isGroup
@@ -362,7 +366,7 @@ async function finalizeLineInboundContext(params: {
}
export async function buildLineMessageContext(params: BuildLineMessageContextParams) {
const { event, allMedia, cfg, account, commandAuthorized } = params;
const { event, allMedia, cfg, account, commandAuthorized, groupHistories, historyLimit } = params;
const source = event.source;
const { userId, groupId, roomId, isGroup, peerId, route } = resolveLineInboundRoute({
@@ -399,6 +403,19 @@ export async function buildLineMessageContext(params: BuildLineMessageContextPar
});
}
// Build pending history for group chats: unmentioned messages accumulated in
// groupHistories are passed as InboundHistory so the agent has context about
// the conversation that preceded the mention.
const historyKey = isGroup ? peerId : undefined;
const inboundHistory =
historyKey && groupHistories && (historyLimit ?? 0) > 0
? (groupHistories.get(historyKey) ?? []).map((entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp,
}))
: undefined;
const { ctxPayload } = await finalizeLineInboundContext({
cfg,
account,
@@ -420,6 +437,7 @@ export async function buildLineMessageContext(params: BuildLineMessageContextPar
},
locationContext,
verboseLog: { kind: "inbound", mediaCount: allMedia.length },
inboundHistory,
});
return {

View File

@@ -1,5 +1,6 @@
import type { WebhookRequestBody } from "@line/bot-sdk";
import type { Request, Response, NextFunction } from "express";
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js";
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
@@ -42,6 +43,7 @@ export function createLineBot(opts: LineBotOptions): LineBot {
logVerbose("line: no message handler configured");
});
const replayCache = createLineWebhookReplayCache();
const groupHistories = new Map<string, HistoryEntry[]>();
const handleWebhook = async (body: WebhookRequestBody): Promise<void> => {
if (!body.events || body.events.length === 0) {
@@ -55,6 +57,8 @@ export function createLineBot(opts: LineBotOptions): LineBot {
mediaMaxBytes,
processMessage,
replayCache,
groupHistories,
historyLimit: cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
});
};

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
resolveExactLineGroupConfigKey,
resolveLineGroupConfigEntry,
resolveLineGroupHistoryKey,
resolveLineGroupLookupIds,
resolveLineGroupsConfig,
} from "./group-keys.js";
describe("resolveLineGroupLookupIds", () => {
it("expands raw ids to both prefixed candidates", () => {
expect(resolveLineGroupLookupIds("abc123")).toEqual(["abc123", "group:abc123", "room:abc123"]);
});
it("preserves prefixed ids while also checking the raw id", () => {
expect(resolveLineGroupLookupIds("room:abc123")).toEqual(["abc123", "room:abc123"]);
expect(resolveLineGroupLookupIds("group:abc123")).toEqual(["abc123", "group:abc123"]);
});
});
describe("resolveLineGroupConfigEntry", () => {
it("matches raw, prefixed, and wildcard group config entries", () => {
const groups = {
"group:g1": { requireMention: false },
"room:r1": { systemPrompt: "Room prompt" },
"*": { requireMention: true },
};
expect(resolveLineGroupConfigEntry(groups, { groupId: "g1" })).toEqual({
requireMention: false,
});
expect(resolveLineGroupConfigEntry(groups, { roomId: "r1" })).toEqual({
systemPrompt: "Room prompt",
});
expect(resolveLineGroupConfigEntry(groups, { groupId: "missing" })).toEqual({
requireMention: true,
});
});
});
describe("resolveLineGroupHistoryKey", () => {
it("uses the raw group or room id as the shared LINE peer key", () => {
expect(resolveLineGroupHistoryKey({ groupId: "g1" })).toBe("g1");
expect(resolveLineGroupHistoryKey({ roomId: "r1" })).toBe("r1");
expect(resolveLineGroupHistoryKey({})).toBeUndefined();
});
});
describe("account-scoped LINE groups", () => {
it("resolves the effective account-scoped groups map", () => {
const cfg = {
channels: {
line: {
groups: {
"*": { requireMention: true },
},
accounts: {
work: {
groups: {
"group:g1": { requireMention: false },
},
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(resolveLineGroupsConfig(cfg, "work")).toEqual({
"group:g1": { requireMention: false },
});
expect(resolveExactLineGroupConfigKey({ cfg, accountId: "work", groupId: "g1" })).toBe(
"group:g1",
);
expect(resolveExactLineGroupConfigKey({ cfg, accountId: "default", groupId: "g1" })).toBe(
undefined,
);
});
});

72
src/line/group-keys.ts Normal file
View File

@@ -0,0 +1,72 @@
import type { OpenClawConfig } from "../config/config.js";
import { normalizeAccountId } from "../routing/account-id.js";
import { resolveAccountEntry } from "../routing/account-lookup.js";
import type { LineConfig, LineGroupConfig } from "./types.js";
export function resolveLineGroupLookupIds(groupId?: string | null): string[] {
const normalized = groupId?.trim();
if (!normalized) {
return [];
}
if (normalized.startsWith("group:") || normalized.startsWith("room:")) {
const rawId = normalized.split(":").slice(1).join(":");
return rawId ? [rawId, normalized] : [normalized];
}
return [normalized, `group:${normalized}`, `room:${normalized}`];
}
export function resolveLineGroupConfigEntry<T>(
groups: Record<string, T | undefined> | undefined,
params: { groupId?: string | null; roomId?: string | null },
): T | undefined {
if (!groups) {
return undefined;
}
for (const candidate of resolveLineGroupLookupIds(params.groupId)) {
const hit = groups[candidate];
if (hit) {
return hit;
}
}
for (const candidate of resolveLineGroupLookupIds(params.roomId)) {
const hit = groups[candidate];
if (hit) {
return hit;
}
}
return groups["*"];
}
export function resolveLineGroupsConfig(
cfg: OpenClawConfig,
accountId?: string | null,
): Record<string, LineGroupConfig | undefined> | undefined {
const lineConfig = cfg.channels?.line as LineConfig | undefined;
if (!lineConfig) {
return undefined;
}
const normalizedAccountId = normalizeAccountId(accountId);
const accountGroups = resolveAccountEntry(lineConfig.accounts, normalizedAccountId)?.groups;
return accountGroups ?? lineConfig.groups;
}
export function resolveExactLineGroupConfigKey(params: {
cfg: OpenClawConfig;
accountId?: string | null;
groupId?: string | null;
}): string | undefined {
const groups = resolveLineGroupsConfig(params.cfg, params.accountId);
if (!groups) {
return undefined;
}
return resolveLineGroupLookupIds(params.groupId).find((candidate) =>
Object.hasOwn(groups, candidate),
);
}
export function resolveLineGroupHistoryKey(params: {
groupId?: string | null;
roomId?: string | null;
}): string | undefined {
return params.groupId?.trim() || params.roomId?.trim() || undefined;
}