mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
79
src/line/group-keys.test.ts
Normal file
79
src/line/group-keys.test.ts
Normal 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
72
src/line/group-keys.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user