mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-20 06:20:55 +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>
300 lines
8.4 KiB
TypeScript
300 lines
8.4 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { MessageEvent, PostbackEvent } from "@line/bot-sdk";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import { buildLineMessageContext, buildLinePostbackContext } from "./bot-message-context.js";
|
|
import type { ResolvedLineAccount } from "./types.js";
|
|
|
|
describe("buildLineMessageContext", () => {
|
|
let tmpDir: string;
|
|
let storePath: string;
|
|
let cfg: OpenClawConfig;
|
|
const account: ResolvedLineAccount = {
|
|
accountId: "default",
|
|
enabled: true,
|
|
channelAccessToken: "token",
|
|
channelSecret: "secret",
|
|
tokenSource: "config",
|
|
config: {},
|
|
};
|
|
|
|
const createMessageEvent = (
|
|
source: MessageEvent["source"],
|
|
overrides?: Partial<MessageEvent>,
|
|
): MessageEvent =>
|
|
({
|
|
type: "message",
|
|
message: { id: "1", type: "text", text: "hello" },
|
|
replyToken: "reply-token",
|
|
timestamp: Date.now(),
|
|
source,
|
|
mode: "active",
|
|
webhookEventId: "evt-1",
|
|
deliveryContext: { isRedelivery: false },
|
|
...overrides,
|
|
}) as MessageEvent;
|
|
|
|
const createPostbackEvent = (
|
|
source: PostbackEvent["source"],
|
|
overrides?: Partial<PostbackEvent>,
|
|
): PostbackEvent =>
|
|
({
|
|
type: "postback",
|
|
postback: { data: "action=select" },
|
|
replyToken: "reply-token",
|
|
timestamp: Date.now(),
|
|
source,
|
|
mode: "active",
|
|
webhookEventId: "evt-2",
|
|
deliveryContext: { isRedelivery: false },
|
|
...overrides,
|
|
}) as PostbackEvent;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-line-context-"));
|
|
storePath = path.join(tmpDir, "sessions.json");
|
|
cfg = { session: { store: storePath } };
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tmpDir, {
|
|
recursive: true,
|
|
force: true,
|
|
maxRetries: 3,
|
|
retryDelay: 50,
|
|
});
|
|
});
|
|
|
|
it("routes group message replies to the group id", async () => {
|
|
const event = createMessageEvent({ type: "group", groupId: "group-1", userId: "user-1" });
|
|
|
|
const context = await buildLineMessageContext({
|
|
event,
|
|
allMedia: [],
|
|
cfg,
|
|
account,
|
|
commandAuthorized: true,
|
|
});
|
|
expect(context).not.toBeNull();
|
|
if (!context) {
|
|
throw new Error("context missing");
|
|
}
|
|
|
|
expect(context.ctxPayload.OriginatingTo).toBe("line:group:group-1");
|
|
expect(context.ctxPayload.To).toBe("line:group:group-1");
|
|
});
|
|
|
|
it("routes group postback replies to the group id", async () => {
|
|
const event = createPostbackEvent({ type: "group", groupId: "group-2", userId: "user-2" });
|
|
|
|
const context = await buildLinePostbackContext({
|
|
event,
|
|
cfg,
|
|
account,
|
|
commandAuthorized: true,
|
|
});
|
|
|
|
expect(context?.ctxPayload.OriginatingTo).toBe("line:group:group-2");
|
|
expect(context?.ctxPayload.To).toBe("line:group:group-2");
|
|
});
|
|
|
|
it("routes room postback replies to the room id", async () => {
|
|
const event = createPostbackEvent({ type: "room", roomId: "room-1", userId: "user-3" });
|
|
|
|
const context = await buildLinePostbackContext({
|
|
event,
|
|
cfg,
|
|
account,
|
|
commandAuthorized: true,
|
|
});
|
|
|
|
expect(context?.ctxPayload.OriginatingTo).toBe("line:room:room-1");
|
|
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" },
|
|
{
|
|
message: { id: "audio-1", type: "audio", duration: 1000 } as MessageEvent["message"],
|
|
},
|
|
);
|
|
|
|
const context = await buildLineMessageContext({
|
|
event,
|
|
allMedia: [],
|
|
cfg,
|
|
account,
|
|
commandAuthorized: false,
|
|
});
|
|
|
|
expect(context).not.toBeNull();
|
|
expect(context?.ctxPayload.CommandAuthorized).toBe(false);
|
|
});
|
|
|
|
it("sets CommandAuthorized=true when authorized", async () => {
|
|
const event = createMessageEvent({ type: "user", userId: "user-auth" });
|
|
|
|
const context = await buildLineMessageContext({
|
|
event,
|
|
allMedia: [],
|
|
cfg,
|
|
account,
|
|
commandAuthorized: true,
|
|
});
|
|
|
|
expect(context?.ctxPayload.CommandAuthorized).toBe(true);
|
|
});
|
|
|
|
it("sets CommandAuthorized=false when not authorized", async () => {
|
|
const event = createMessageEvent({ type: "user", userId: "user-noauth" });
|
|
|
|
const context = await buildLineMessageContext({
|
|
event,
|
|
allMedia: [],
|
|
cfg,
|
|
account,
|
|
commandAuthorized: false,
|
|
});
|
|
|
|
expect(context?.ctxPayload.CommandAuthorized).toBe(false);
|
|
});
|
|
|
|
it("sets CommandAuthorized on postback context", async () => {
|
|
const event = createPostbackEvent({ type: "user", userId: "user-pb" });
|
|
|
|
const context = await buildLinePostbackContext({
|
|
event,
|
|
cfg,
|
|
account,
|
|
commandAuthorized: true,
|
|
});
|
|
|
|
expect(context?.ctxPayload.CommandAuthorized).toBe(true);
|
|
});
|
|
|
|
it("group peer binding matches raw groupId without prefix (#21907)", async () => {
|
|
const groupId = "Cc7e3bece1234567890abcdef"; // pragma: allowlist secret
|
|
const bindingCfg: OpenClawConfig = {
|
|
session: { store: storePath },
|
|
agents: {
|
|
list: [{ id: "main" }, { id: "line-group-agent" }],
|
|
},
|
|
bindings: [
|
|
{
|
|
agentId: "line-group-agent",
|
|
match: { channel: "line", peer: { kind: "group", id: groupId } },
|
|
},
|
|
],
|
|
};
|
|
|
|
const event = {
|
|
type: "message",
|
|
message: { id: "msg-1", type: "text", text: "hello" },
|
|
replyToken: "reply-token",
|
|
timestamp: Date.now(),
|
|
source: { type: "group", groupId, userId: "user-1" },
|
|
mode: "active",
|
|
webhookEventId: "evt-1",
|
|
deliveryContext: { isRedelivery: false },
|
|
} as MessageEvent;
|
|
|
|
const context = await buildLineMessageContext({
|
|
event,
|
|
allMedia: [],
|
|
cfg: bindingCfg,
|
|
account,
|
|
commandAuthorized: true,
|
|
});
|
|
expect(context).not.toBeNull();
|
|
expect(context!.route.agentId).toBe("line-group-agent");
|
|
expect(context!.route.matchedBy).toBe("binding.peer");
|
|
});
|
|
|
|
it("room peer binding matches raw roomId without prefix (#21907)", async () => {
|
|
const roomId = "Rr1234567890abcdef";
|
|
const bindingCfg: OpenClawConfig = {
|
|
session: { store: storePath },
|
|
agents: {
|
|
list: [{ id: "main" }, { id: "line-room-agent" }],
|
|
},
|
|
bindings: [
|
|
{
|
|
agentId: "line-room-agent",
|
|
match: { channel: "line", peer: { kind: "group", id: roomId } },
|
|
},
|
|
],
|
|
};
|
|
|
|
const event = {
|
|
type: "message",
|
|
message: { id: "msg-2", type: "text", text: "hello" },
|
|
replyToken: "reply-token",
|
|
timestamp: Date.now(),
|
|
source: { type: "room", roomId, userId: "user-2" },
|
|
mode: "active",
|
|
webhookEventId: "evt-2",
|
|
deliveryContext: { isRedelivery: false },
|
|
} as MessageEvent;
|
|
|
|
const context = await buildLineMessageContext({
|
|
event,
|
|
allMedia: [],
|
|
cfg: bindingCfg,
|
|
account,
|
|
commandAuthorized: true,
|
|
});
|
|
expect(context).not.toBeNull();
|
|
expect(context!.route.agentId).toBe("line-room-agent");
|
|
expect(context!.route.matchedBy).toBe("binding.peer");
|
|
});
|
|
});
|