From 4682f3cace3fbf85f41247ec9e4d94e9999f3cf8 Mon Sep 17 00:00:00 2001 From: Yi-Cheng Wang <80525895+kirisame-wang@users.noreply.github.com> Date: Sun, 8 Mar 2026 04:06:07 +0800 Subject: [PATCH] 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 * 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 * 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 * fix(line): use undefined instead of empty string as historyKey sentinel Co-Authored-By: Claude Sonnet 4.6 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * Address review findings for #35847 --------- Co-authored-by: Kaiyi Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Yi-Cheng Wang Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + src/auto-reply/inbound.test.ts | 48 +++ src/auto-reply/reply/groups.ts | 58 +++- src/channels/dock.ts | 14 + src/channels/plugins/group-mentions.test.ts | 67 ++++ src/channels/plugins/group-mentions.ts | 35 ++- src/channels/registry.ts | 11 + src/line/bot-handlers.test.ts | 322 +++++++++++++++++++- src/line/bot-handlers.ts | 123 +++++++- src/line/bot-message-context.test.ts | 46 +++ src/line/bot-message-context.ts | 44 ++- src/line/bot.ts | 4 + src/line/group-keys.test.ts | 79 +++++ src/line/group-keys.ts | 72 +++++ 14 files changed, 887 insertions(+), 37 deletions(-) create mode 100644 src/line/group-keys.test.ts create mode 100644 src/line/group-keys.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dc7d1de255..cb20f9f0203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/auto-reply/inbound.test.ts b/src/auto-reply/inbound.test.ts index e4a8dfb9534..f602c7dca60 100644 --- a/src/auto-reply/inbound.test.ts +++ b/src/auto-reply/inbound.test.ts @@ -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); + }); }); diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index 8176499899d..dcf398d5a4b 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -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)." diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 3cabb919f51..6f359fd96f0 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -26,6 +26,8 @@ import { resolveGoogleChatGroupToolPolicy, resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, + resolveLineGroupRequireMention, + resolveLineGroupToolPolicy, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, resolveTelegramGroupRequireMention, @@ -547,6 +549,18 @@ const DOCKS: Record = { 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 { diff --git a/src/channels/plugins/group-mentions.test.ts b/src/channels/plugins/group-mentions.test.ts index a737808a131..5f8e4ed43e9 100644 --- a/src/channels/plugins/group-mentions.test.ts +++ b/src/channels/plugins/group-mentions.test.ts @@ -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); + }); +}); diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts index 551f0d52985..b7f475677c5 100644 --- a/src/channels/plugins/group-mentions.ts +++ b/src/channels/plugins/group-mentions.ts @@ -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"); +} diff --git a/src/channels/registry.ts b/src/channels/registry.ts index 958dbf174a3..16ba6514397 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -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 = { 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 = { diff --git a/src/line/bot-handlers.test.ts b/src/line/bot-handlers.test.ts index c7752c506e7..3e52dd338fa 100644 --- a/src/line/bot-handlers.test.ts +++ b/src/line/bot-handlers.test.ts @@ -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() diff --git a/src/line/bot-handlers.ts b/src/line/bot-handlers.ts index 06ed5b0d09b..8cf9be9d79f 100644 --- a/src/line/bot-handlers.ts +++ b/src/line/bot-handlers.ts @@ -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; replayCache?: LineWebhookReplayCache; + groupHistories?: Map; + 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 & { + 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 { diff --git a/src/line/bot-message-context.test.ts b/src/line/bot-message-context.test.ts index 52cd87b72ab..ab9bfc7188e 100644 --- a/src/line/bot-message-context.test.ts +++ b/src/line/bot-message-context.test.ts @@ -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" }, diff --git a/src/line/bot-message-context.ts b/src/line/bot-message-context.ts index 5df06b6b79c..5a872bfaf29 100644 --- a/src/line/bot-message-context.ts +++ b/src/line/bot-message-context.ts @@ -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; + 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 | 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; verboseLog: { kind: "inbound" | "postback"; mediaCount?: number }; + inboundHistory?: Pick[]; }) { 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 { diff --git a/src/line/bot.ts b/src/line/bot.ts index c7a6f508035..319054c8343 100644 --- a/src/line/bot.ts +++ b/src/line/bot.ts @@ -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(); const handleWebhook = async (body: WebhookRequestBody): Promise => { 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, }); }; diff --git a/src/line/group-keys.test.ts b/src/line/group-keys.test.ts new file mode 100644 index 00000000000..a35f6126b4e --- /dev/null +++ b/src/line/group-keys.test.ts @@ -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, + ); + }); +}); diff --git a/src/line/group-keys.ts b/src/line/group-keys.ts new file mode 100644 index 00000000000..c3f49b9244d --- /dev/null +++ b/src/line/group-keys.ts @@ -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( + groups: Record | 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 | 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; +}