mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
* 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>
186 lines
6.6 KiB
TypeScript
186 lines
6.6 KiB
TypeScript
import { getChannelDock } from "../../channels/dock.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";
|
|
import type { TemplateContext } from "../templating.js";
|
|
|
|
function extractGroupId(raw: string | undefined | null): string | undefined {
|
|
const trimmed = (raw ?? "").trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
const parts = trimmed.split(":").filter(Boolean);
|
|
if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) {
|
|
return parts.slice(2).join(":") || undefined;
|
|
}
|
|
if (
|
|
parts.length >= 2 &&
|
|
parts[0]?.toLowerCase() === "whatsapp" &&
|
|
trimmed.toLowerCase().includes("@g.us")
|
|
) {
|
|
return parts.slice(1).join(":") || undefined;
|
|
}
|
|
if (parts.length >= 2 && (parts[0] === "group" || parts[0] === "channel")) {
|
|
return parts.slice(1).join(":") || 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;
|
|
groupResolution?: GroupKeyResolution;
|
|
}): boolean {
|
|
const { cfg, ctx, groupResolution } = params;
|
|
const rawChannel = groupResolution?.channel ?? ctx.Provider?.trim();
|
|
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();
|
|
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 resolveChannelGroupRequireMention({
|
|
cfg,
|
|
channel,
|
|
groupId,
|
|
accountId: ctx.AccountId,
|
|
});
|
|
}
|
|
|
|
export function defaultGroupActivation(requireMention: boolean): "always" | "mention" {
|
|
return !requireMention ? "always" : "mention";
|
|
}
|
|
|
|
/**
|
|
* Resolve a human-readable provider label from the raw provider string.
|
|
*/
|
|
function resolveProviderLabel(rawProvider: string | undefined): string {
|
|
const providerKey = rawProvider?.trim().toLowerCase() ?? "";
|
|
if (!providerKey) {
|
|
return "chat";
|
|
}
|
|
if (isInternalMessageChannel(providerKey)) {
|
|
return "WebChat";
|
|
}
|
|
const providerId = resolveDockChannelId(rawProvider?.trim());
|
|
if (providerId) {
|
|
return getChannelPlugin(providerId)?.meta.label ?? providerId;
|
|
}
|
|
return `${providerKey.at(0)?.toUpperCase() ?? ""}${providerKey.slice(1)}`;
|
|
}
|
|
|
|
/**
|
|
* Build a persistent group-chat context block that is always included in the
|
|
* system prompt for group-chat sessions (every turn, not just the first).
|
|
*
|
|
* Contains: group name, participants, and an explicit instruction to reply
|
|
* directly instead of using the message tool.
|
|
*/
|
|
export function buildGroupChatContext(params: { sessionCtx: TemplateContext }): string {
|
|
const subject = params.sessionCtx.GroupSubject?.trim();
|
|
const members = params.sessionCtx.GroupMembers?.trim();
|
|
const providerLabel = resolveProviderLabel(params.sessionCtx.Provider);
|
|
|
|
const lines: string[] = [];
|
|
if (subject) {
|
|
lines.push(`You are in the ${providerLabel} group chat "${subject}".`);
|
|
} else {
|
|
lines.push(`You are in a ${providerLabel} group chat.`);
|
|
}
|
|
if (members) {
|
|
lines.push(`Participants: ${members}.`);
|
|
}
|
|
lines.push(
|
|
"Your replies are automatically sent to this group chat. Do not use the message tool to send to this same group — just reply normally.",
|
|
);
|
|
return lines.join(" ");
|
|
}
|
|
|
|
export function buildGroupIntro(params: {
|
|
cfg: OpenClawConfig;
|
|
sessionCtx: TemplateContext;
|
|
sessionEntry?: SessionEntry;
|
|
defaultActivation: "always" | "mention";
|
|
silentToken: string;
|
|
}): string {
|
|
const activation =
|
|
normalizeGroupActivation(params.sessionEntry?.groupActivation) ?? params.defaultActivation;
|
|
const rawProvider = params.sessionCtx.Provider?.trim();
|
|
const providerId = resolveDockChannelId(rawProvider);
|
|
const activationLine =
|
|
activation === "always"
|
|
? "Activation: always-on (you receive every group message)."
|
|
: "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included).";
|
|
const groupId = params.sessionEntry?.groupId ?? extractGroupId(params.sessionCtx.From);
|
|
const groupChannel =
|
|
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim();
|
|
const groupSpace = params.sessionCtx.GroupSpace?.trim();
|
|
const providerIdsLine = providerId
|
|
? getChannelDock(providerId)?.groups?.resolveGroupIntroHint?.({
|
|
cfg: params.cfg,
|
|
groupId,
|
|
groupChannel,
|
|
groupSpace,
|
|
accountId: params.sessionCtx.AccountId,
|
|
})
|
|
: undefined;
|
|
const silenceLine =
|
|
activation === "always"
|
|
? `If no response is needed, reply with exactly "${params.silentToken}" (and nothing else) so OpenClaw stays silent. Do not add any other words, punctuation, tags, markdown/code blocks, or explanations.`
|
|
: undefined;
|
|
const cautionLine =
|
|
activation === "always"
|
|
? "Be extremely selective: reply only when directly addressed or clearly helpful. Otherwise stay silent."
|
|
: undefined;
|
|
const lurkLine =
|
|
"Be a good group participant: mostly lurk and follow the conversation; reply only when directly addressed or you can add clear value. Emoji reactions are welcome when available.";
|
|
const styleLine =
|
|
"Write like a human. Avoid Markdown tables. Don't type literal \\n sequences; use real line breaks sparingly.";
|
|
return [activationLine, providerIdsLine, silenceLine, cautionLine, lurkLine, styleLine]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.concat(" Address the specific sender noted in the message context.");
|
|
}
|