mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-26 16:41:49 +00:00
fix(line): add ACP binding parity (#56700)
* fix(line): support ACP current-conversation binding * fix(line): add ACP binding routing parity * docs(changelog): note LINE ACP parity * fix(line): accept canonical ACP binding targets
This commit is contained in:
@@ -3,10 +3,16 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { MessageEvent, PostbackEvent } from "@line/bot-sdk";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { setDefaultChannelPluginRegistryForTests } from "../../../src/commands/channel-test-helpers.js";
|
||||
import { __testing as sessionBindingTesting } from "../../../src/infra/outbound/session-binding-service.js";
|
||||
import { buildLineMessageContext, buildLinePostbackContext } from "./bot-message-context.js";
|
||||
import { linePlugin } from "./channel.js";
|
||||
import type { ResolvedLineAccount } from "./types.js";
|
||||
|
||||
type AgentBinding = NonNullable<OpenClawConfig["bindings"]>[number];
|
||||
|
||||
describe("buildLineMessageContext", () => {
|
||||
let tmpDir: string;
|
||||
let storePath: string;
|
||||
@@ -53,12 +59,15 @@ describe("buildLineMessageContext", () => {
|
||||
}) as PostbackEvent;
|
||||
|
||||
beforeEach(async () => {
|
||||
setDefaultChannelPluginRegistryForTests();
|
||||
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,
|
||||
@@ -296,4 +305,64 @@ describe("buildLineMessageContext", () => {
|
||||
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", async () => {
|
||||
const compiled = linePlugin.bindings?.compileConfiguredBinding({
|
||||
binding: {
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: { channel: "line", accountId: "default", peer: { kind: "direct", id: "unused" } },
|
||||
} as AgentBinding,
|
||||
conversationId: "line:user:U1234567890abcdef1234567890abcdef",
|
||||
});
|
||||
|
||||
expect(compiled).toEqual({
|
||||
conversationId: "U1234567890abcdef1234567890abcdef",
|
||||
});
|
||||
expect(
|
||||
linePlugin.bindings?.matchInboundConversation({
|
||||
binding: {
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: { channel: "line", accountId: "default", peer: { kind: "direct", id: "unused" } },
|
||||
} as AgentBinding,
|
||||
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).not.toBeNull();
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,12 +8,19 @@ import {
|
||||
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
ensureConfiguredBindingRouteReady,
|
||||
getSessionBindingService,
|
||||
recordInboundSession,
|
||||
resolvePinnedMainDmOwnerFromAllowlist,
|
||||
resolveConfiguredBindingRoute,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
|
||||
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
deriveLastRoutePolicy,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveAgentRoute,
|
||||
} from "openclaw/plugin-sdk/routing";
|
||||
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeAllowFrom } from "./bot-access.js";
|
||||
import { resolveLineGroupConfigEntry, resolveLineGroupHistoryKey } from "./group-keys.js";
|
||||
@@ -71,18 +78,18 @@ function buildPeerId(source: EventSource): string {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function resolveLineInboundRoute(params: {
|
||||
async function resolveLineInboundRoute(params: {
|
||||
source: EventSource;
|
||||
cfg: OpenClawConfig;
|
||||
account: ResolvedLineAccount;
|
||||
}): {
|
||||
}): Promise<{
|
||||
userId?: string;
|
||||
groupId?: string;
|
||||
roomId?: string;
|
||||
isGroup: boolean;
|
||||
peerId: string;
|
||||
route: ReturnType<typeof resolveAgentRoute>;
|
||||
} {
|
||||
}> {
|
||||
recordChannelActivity({
|
||||
channel: "line",
|
||||
accountId: params.account.accountId,
|
||||
@@ -91,7 +98,7 @@ function resolveLineInboundRoute(params: {
|
||||
|
||||
const { userId, groupId, roomId, isGroup } = getLineSourceInfo(params.source);
|
||||
const peerId = buildPeerId(params.source);
|
||||
const route = resolveAgentRoute({
|
||||
let route = resolveAgentRoute({
|
||||
cfg: params.cfg,
|
||||
channel: "line",
|
||||
accountId: params.account.accountId,
|
||||
@@ -101,6 +108,57 @@ function resolveLineInboundRoute(params: {
|
||||
},
|
||||
});
|
||||
|
||||
const configuredRoute = resolveConfiguredBindingRoute({
|
||||
cfg: params.cfg,
|
||||
route,
|
||||
conversation: {
|
||||
channel: "line",
|
||||
accountId: params.account.accountId,
|
||||
conversationId: peerId,
|
||||
},
|
||||
});
|
||||
let configuredBinding = configuredRoute.bindingResolution;
|
||||
const configuredBindingSessionKey = configuredRoute.boundSessionKey ?? "";
|
||||
route = configuredRoute.route;
|
||||
|
||||
const boundConversation = getSessionBindingService().resolveByConversation({
|
||||
channel: "line",
|
||||
accountId: params.account.accountId,
|
||||
conversationId: peerId,
|
||||
});
|
||||
const boundSessionKey = boundConversation?.targetSessionKey?.trim();
|
||||
if (boundConversation && boundSessionKey) {
|
||||
route = {
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: resolveAgentIdFromSessionKey(boundSessionKey) || route.agentId,
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey: boundSessionKey,
|
||||
mainSessionKey: route.mainSessionKey,
|
||||
}),
|
||||
matchedBy: "binding.channel",
|
||||
};
|
||||
configuredBinding = null;
|
||||
getSessionBindingService().touch(boundConversation.bindingId);
|
||||
logVerbose(`line: routed via bound conversation ${peerId} -> ${boundSessionKey}`);
|
||||
}
|
||||
|
||||
if (configuredBinding) {
|
||||
const ensured = await ensureConfiguredBindingRouteReady({
|
||||
cfg: params.cfg,
|
||||
bindingResolution: configuredBinding,
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
logVerbose(
|
||||
`line: configured ACP binding unavailable for ${peerId} -> ${configuredBindingSessionKey}: ${ensured.error}`,
|
||||
);
|
||||
throw new Error(`Configured ACP binding unavailable: ${ensured.error}`);
|
||||
}
|
||||
logVerbose(
|
||||
`line: using configured ACP binding for ${peerId} -> ${configuredBindingSessionKey}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { userId, groupId, roomId, isGroup, peerId, route };
|
||||
}
|
||||
|
||||
@@ -371,7 +429,7 @@ export async function buildLineMessageContext(params: BuildLineMessageContextPar
|
||||
const { event, allMedia, cfg, account, commandAuthorized, groupHistories, historyLimit } = params;
|
||||
|
||||
const source = event.source;
|
||||
const { userId, groupId, roomId, isGroup, peerId, route } = resolveLineInboundRoute({
|
||||
const { userId, groupId, roomId, isGroup, peerId, route } = await resolveLineInboundRoute({
|
||||
source,
|
||||
cfg,
|
||||
account,
|
||||
@@ -460,7 +518,7 @@ export async function buildLinePostbackContext(params: {
|
||||
const { event, cfg, account, commandAuthorized } = params;
|
||||
|
||||
const source = event.source;
|
||||
const { userId, groupId, roomId, isGroup, peerId, route } = resolveLineInboundRoute({
|
||||
const { userId, groupId, roomId, isGroup, peerId, route } = await resolveLineInboundRoute({
|
||||
source,
|
||||
cfg,
|
||||
account,
|
||||
|
||||
@@ -12,6 +12,27 @@ import { lineSetupAdapter } from "./setup-core.js";
|
||||
import { lineSetupWizard } from "./setup-surface.js";
|
||||
import { lineStatusAdapter } from "./status.js";
|
||||
|
||||
function normalizeLineConversationId(raw?: string | null): string | null {
|
||||
const trimmed = raw?.trim() ?? "";
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const prefixed = trimmed.match(/^line:(?:user|group|room):(.+)$/i)?.[1];
|
||||
return (prefixed ?? trimmed).trim() || null;
|
||||
}
|
||||
|
||||
function resolveLineCommandConversation(params: {
|
||||
originatingTo?: string;
|
||||
commandTo?: string;
|
||||
fallbackTo?: string;
|
||||
}) {
|
||||
const conversationId =
|
||||
normalizeLineConversationId(params.originatingTo) ??
|
||||
normalizeLineConversationId(params.commandTo) ??
|
||||
normalizeLineConversationId(params.fallbackTo);
|
||||
return conversationId ? { conversationId } : null;
|
||||
}
|
||||
|
||||
const lineSecurityAdapter = createRestrictSendersChannelSecurity<ResolvedLineAccount>({
|
||||
channelKey: "line",
|
||||
resolveDmPolicy: (account) => account.config.dmPolicy,
|
||||
@@ -58,6 +79,28 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
|
||||
setup: lineSetupAdapter,
|
||||
status: lineStatusAdapter,
|
||||
gateway: lineGatewayAdapter,
|
||||
bindings: {
|
||||
compileConfiguredBinding: ({ conversationId }) => {
|
||||
const normalized = normalizeLineConversationId(conversationId);
|
||||
return normalized ? { conversationId: normalized } : null;
|
||||
},
|
||||
matchInboundConversation: ({ compiledBinding, conversationId }) => {
|
||||
const normalizedIncoming = normalizeLineConversationId(conversationId);
|
||||
if (!normalizedIncoming || compiledBinding.conversationId !== normalizedIncoming) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
conversationId: normalizedIncoming,
|
||||
matchPriority: 2,
|
||||
};
|
||||
},
|
||||
resolveCommandConversation: ({ originatingTo, commandTo, fallbackTo }) =>
|
||||
resolveLineCommandConversation({
|
||||
originatingTo,
|
||||
commandTo,
|
||||
fallbackTo,
|
||||
}),
|
||||
},
|
||||
agentPrompt: {
|
||||
messageToolHints: () => [
|
||||
"",
|
||||
|
||||
@@ -3,6 +3,15 @@ import { z } from "openclaw/plugin-sdk/zod";
|
||||
|
||||
const DmPolicySchema = z.enum(["open", "allowlist", "pairing", "disabled"]);
|
||||
const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
|
||||
const ThreadBindingsSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
idleHours: z.number().optional(),
|
||||
maxAgeHours: z.number().optional(),
|
||||
spawnSubagentSessions: z.boolean().optional(),
|
||||
spawnAcpSessions: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const LineCommonConfigSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
@@ -18,6 +27,7 @@ const LineCommonConfigSchema = z.object({
|
||||
responsePrefix: z.string().optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
threadBindings: ThreadBindingsSchema.optional(),
|
||||
});
|
||||
|
||||
const LineGroupConfigSchema = z
|
||||
|
||||
@@ -11,6 +11,14 @@ import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
|
||||
|
||||
export type LineTokenSource = "config" | "env" | "file" | "none";
|
||||
|
||||
export interface LineThreadBindingsConfig {
|
||||
enabled?: boolean;
|
||||
idleHours?: number;
|
||||
maxAgeHours?: number;
|
||||
spawnSubagentSessions?: boolean;
|
||||
spawnAcpSessions?: boolean;
|
||||
}
|
||||
|
||||
interface LineAccountBaseConfig {
|
||||
enabled?: boolean;
|
||||
channelAccessToken?: string;
|
||||
@@ -25,6 +33,7 @@ interface LineAccountBaseConfig {
|
||||
responsePrefix?: string;
|
||||
mediaMaxMb?: number;
|
||||
webhookPath?: string;
|
||||
threadBindings?: LineThreadBindingsConfig;
|
||||
groups?: Record<string, LineGroupConfig>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user