mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 03:31:11 +00:00
* fix(line): keep group history recorded during a mention turn Group history cleanup ran a whole-key clear after each mention turn. Because webhook dispatch is fire-and-forget, a plain (unmentioned) message can be recorded while the agent is still handling a mention; the post-turn clear then wiped the whole window, silently dropping that concurrent message from the next turn's context. Snapshot the identity keys the turn consumes up front and clear only those, retaining anything recorded concurrently. The record path now stamps the LINE message id so entries have a stable identity. Cleanup stays after the turn, so a failed turn still leaves history intact for the retry. The snapshot/clear helpers live in group-history.ts, next to the other group modules. * test(line): cover group history retention when a mention turn fails Assert that a mention turn whose processMessage throws leaves the group history window intact (cleanup runs only after a successful turn), so the retry still has the ambient context. Red/green verified: moving the cleanup before the await fails this test. * fix(line): capture consumed group history at the context read boundary The consumed-key snapshot ran before media download and context construction, while buildLineMessageContext copied the window later. A plain message recorded between those awaits was included in the turn's InboundHistory yet missing from the consumed set, so cleanup retained it and the next mention received it twice. Read the window and capture its identity keys in one synchronous step in the handler (snapshotLineGroupHistory), then pass the materialized inboundHistory into buildLineMessageContext instead of the live map. The consumed set is now derived from the exact entries the turn reads, so an entry is either in the context and cleared, or recorded later and retained - there is no window where both can be true. Regression tests park a mention turn inside context construction, record an ambient message mid-window, and assert it stays out of that turn's InboundHistory, survives cleanup, and is consumed exactly once by the next mention. Red/green verified against the previous snapshot placement. * test(line): point history-window guardrail at group-history.ts The group-history cleanup fix moved the createChannelHistoryWindow facade call out of bot-message-context.ts (which now receives inboundHistory as a parameter) into the new group-history.ts. Update the cross-channel historyWindowFiles ledger to match, mirroring telegram's group-history-window.ts entry already in the same list. Fixes the message-turn-guardrails 'keeps migrated history users on the channel history window facade' assertion that build-artifacts flagged. * fix(line): reserve history across concurrent turns Co-authored-by: Eden <146086744+edenfunf@users.noreply.github.com> * test(line): complete webhook message fixtures * test(line): use explicit token placeholders * refactor(line): keep reservation type private * refactor(line): render reservations through history facade --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
521 lines
16 KiB
TypeScript
521 lines
16 KiB
TypeScript
// Line tests cover bot message context plugin behavior.
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { webhook } from "@line/bot-sdk";
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
|
|
import { testing as sessionBindingTesting } from "openclaw/plugin-sdk/conversation-runtime";
|
|
import {
|
|
createTestRegistry,
|
|
setActivePluginRegistry,
|
|
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { lineBindingsAdapter } from "./bindings.js";
|
|
import { buildLineMessageContext, buildLinePostbackContext } from "./bot-message-context.js";
|
|
import type { ResolvedLineAccount } from "./types.js";
|
|
|
|
const logVerboseMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
|
|
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/runtime-env")>(
|
|
"openclaw/plugin-sdk/runtime-env",
|
|
);
|
|
return {
|
|
...actual,
|
|
logVerbose: logVerboseMock,
|
|
shouldLogVerbose: () => true,
|
|
};
|
|
});
|
|
|
|
type MessageEvent = webhook.MessageEvent;
|
|
type PostbackEvent = webhook.PostbackEvent;
|
|
|
|
const lineBindingsPlugin = {
|
|
id: "line",
|
|
bindings: lineBindingsAdapter,
|
|
conversationBindings: {
|
|
defaultTopLevelPlacement: "current",
|
|
supportsCurrentConversationBinding: true,
|
|
},
|
|
};
|
|
|
|
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 () => {
|
|
logVerboseMock.mockClear();
|
|
setActivePluginRegistry(
|
|
createTestRegistry([
|
|
{
|
|
pluginId: lineBindingsPlugin.id,
|
|
plugin: lineBindingsPlugin,
|
|
source: "test",
|
|
},
|
|
]),
|
|
);
|
|
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-line-context-"));
|
|
storePath = path.join(tmpDir, "sessions.json");
|
|
cfg = { session: { store: storePath } };
|
|
});
|
|
|
|
afterEach(async () => {
|
|
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
|
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?.ctxPayload.OriginatingTo).toBe("line:group:group-1");
|
|
expect(context?.ctxPayload.To).toBe("line:group:group-1");
|
|
});
|
|
|
|
it("passes the caller-provided inbound history through to the context payload", async () => {
|
|
const event = createMessageEvent({ type: "group", groupId: "group-1", userId: "user-1" });
|
|
|
|
const context = await buildLineMessageContext({
|
|
event,
|
|
allMedia: [],
|
|
cfg,
|
|
account,
|
|
commandAuthorized: true,
|
|
inboundHistory: [{ sender: "user:user-2", body: "earlier chatter", timestamp: 1000 }],
|
|
});
|
|
|
|
expect(context?.ctxPayload.InboundHistory).toEqual([
|
|
{ sender: "user:user-2", body: "earlier chatter", timestamp: 1000 },
|
|
]);
|
|
});
|
|
|
|
it("keeps inbound log previews UTF-16 well-formed at the limit", async () => {
|
|
const timestamp = 1_700_000_000_000;
|
|
const logCfg: OpenClawConfig = {
|
|
...cfg,
|
|
agents: { defaults: { envelopeTimestamp: "off" } },
|
|
};
|
|
await buildLineMessageContext({
|
|
event: createMessageEvent({ type: "user", userId: "user-1" }, {
|
|
timestamp,
|
|
message: { id: "baseline", type: "text", text: "BODY_MARKER" },
|
|
} as Partial<MessageEvent>),
|
|
allMedia: [],
|
|
cfg: logCfg,
|
|
account,
|
|
commandAuthorized: true,
|
|
});
|
|
const baselineLog = String(logVerboseMock.mock.calls[0]?.[0]);
|
|
const baselinePreview = baselineLog.match(/preview="(.*)"$/)?.[1] ?? "";
|
|
const markerIndex = baselinePreview.indexOf("BODY_MARKER");
|
|
expect(markerIndex).toBeGreaterThanOrEqual(0);
|
|
const rawBody = `${"x".repeat(199 - markerIndex)}🚀tail`;
|
|
logVerboseMock.mockClear();
|
|
|
|
await buildLineMessageContext({
|
|
event: createMessageEvent({ type: "user", userId: "user-1" }, {
|
|
timestamp,
|
|
message: { id: "1", type: "text", text: rawBody },
|
|
} as Partial<MessageEvent>),
|
|
allMedia: [],
|
|
cfg: logCfg,
|
|
account,
|
|
commandAuthorized: true,
|
|
});
|
|
const expectedPreview = `${baselinePreview.slice(0, markerIndex)}${"x".repeat(199 - markerIndex)}`;
|
|
const formattedBodyLength = markerIndex + rawBody.length;
|
|
|
|
expect(logVerboseMock).toHaveBeenCalledWith(
|
|
`line inbound: from=line:user-1 len=${formattedBodyLength} preview="${expectedPreview}"`,
|
|
);
|
|
});
|
|
|
|
it("replaces a failed media placeholder with an unavailable notice", async () => {
|
|
const event = createMessageEvent({ type: "user", userId: "user-image" }, {
|
|
message: {
|
|
id: "image-1",
|
|
type: "image",
|
|
contentProvider: { type: "line" },
|
|
},
|
|
} as Partial<MessageEvent>);
|
|
|
|
const context = await buildLineMessageContext({
|
|
event,
|
|
allMedia: [],
|
|
mediaUnavailable: true,
|
|
cfg,
|
|
account,
|
|
commandAuthorized: true,
|
|
});
|
|
|
|
expect(context?.ctxPayload.RawBody).toBe("<media:image>");
|
|
expect(context?.ctxPayload.CommandBody).toBe("<media:image>");
|
|
expect(context?.ctxPayload.BodyForAgent).toBe("[line attachment unavailable]");
|
|
expect(context?.ctxPayload.MediaPath).toBeUndefined();
|
|
});
|
|
|
|
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?.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("keeps per-channel-peer direct-message last-route writes on the isolated session", async () => {
|
|
const event = createMessageEvent({ type: "user", userId: "user-1" });
|
|
const directCfg: OpenClawConfig = {
|
|
session: { store: storePath, dmScope: "per-channel-peer" },
|
|
};
|
|
|
|
const context = await buildLineMessageContext({
|
|
event,
|
|
allMedia: [],
|
|
cfg: directCfg,
|
|
account: {
|
|
...account,
|
|
config: { allowFrom: ["user-1"] },
|
|
},
|
|
commandAuthorized: true,
|
|
});
|
|
|
|
expect(context?.route.sessionKey).toBe("agent:main:line:direct:user-1");
|
|
const updateLastRoute = context?.turn.record.updateLastRoute;
|
|
expect(updateLastRoute?.sessionKey).toBe(context?.route.sessionKey);
|
|
expect(updateLastRoute?.sessionKey).not.toBe("agent:main:main");
|
|
expect(updateLastRoute?.channel).toBe("line");
|
|
expect(updateLastRoute?.to).toBe("user-1");
|
|
expect(updateLastRoute?.mainDmOwnerPin).toBeUndefined();
|
|
});
|
|
|
|
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?.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?.route.agentId).toBe("line-room-agent");
|
|
expect(context?.route.matchedBy).toBe("binding.peer");
|
|
});
|
|
|
|
it("normalizes LINE ACP binding conversation ids through the plugin bindings surface", () => {
|
|
const compiled = lineBindingsAdapter.compileConfiguredBinding({
|
|
conversationId: "line:user:U1234567890abcdef1234567890abcdef",
|
|
});
|
|
|
|
expect(compiled).toEqual({
|
|
conversationId: "U1234567890abcdef1234567890abcdef",
|
|
});
|
|
expect(
|
|
lineBindingsAdapter.matchInboundConversation({
|
|
compiledBinding: compiled!,
|
|
conversationId: "U1234567890abcdef1234567890abcdef",
|
|
}),
|
|
).toEqual({
|
|
conversationId: "U1234567890abcdef1234567890abcdef",
|
|
matchPriority: 2,
|
|
});
|
|
});
|
|
|
|
it("normalizes canonical LINE targets through the plugin bindings surface", () => {
|
|
const compiled = lineBindingsAdapter.compileConfiguredBinding({
|
|
conversationId: "line:U1234567890abcdef1234567890abcdef",
|
|
});
|
|
|
|
expect(compiled).toEqual({
|
|
conversationId: "U1234567890abcdef1234567890abcdef",
|
|
});
|
|
expect(
|
|
lineBindingsAdapter.resolveCommandConversation({
|
|
originatingTo: "line:U1234567890abcdef1234567890abcdef",
|
|
}),
|
|
).toEqual({
|
|
conversationId: "U1234567890abcdef1234567890abcdef",
|
|
});
|
|
expect(
|
|
lineBindingsAdapter.matchInboundConversation({
|
|
compiledBinding: compiled!,
|
|
conversationId: "U1234567890abcdef1234567890abcdef",
|
|
}),
|
|
).toEqual({
|
|
conversationId: "U1234567890abcdef1234567890abcdef",
|
|
matchPriority: 2,
|
|
});
|
|
});
|
|
|
|
it("routes LINE conversations through active ACP session bindings", async () => {
|
|
const userId = "U1234567890abcdef1234567890abcdef";
|
|
await getSessionBindingService().bind({
|
|
targetSessionKey: "agent:codex:acp:binding:line:default:test123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "line",
|
|
accountId: "default",
|
|
conversationId: userId,
|
|
},
|
|
placement: "current",
|
|
metadata: {
|
|
agentId: "codex",
|
|
},
|
|
});
|
|
|
|
const event = createMessageEvent({ type: "user", userId });
|
|
const context = await buildLineMessageContext({
|
|
event,
|
|
allMedia: [],
|
|
cfg,
|
|
account,
|
|
commandAuthorized: true,
|
|
});
|
|
|
|
expect(context?.route.agentId).toBe("codex");
|
|
expect(context?.route.sessionKey).toBe("agent:codex:acp:binding:line:default:test123");
|
|
expect(context?.route.matchedBy).toBe("binding.channel");
|
|
});
|
|
});
|