refactor: route session target matching through plugin parsers

This commit is contained in:
Peter Steinberger
2026-04-01 13:42:24 +01:00
parent 6433e923d4
commit 25e2934809
4 changed files with 163 additions and 11 deletions

View File

@@ -1,7 +1,12 @@
import { beforeEach, describe, expect, it } from "vitest";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { parseExplicitTargetForChannel } from "./target-parsing.js";
import {
comparableChannelTargetsMatch,
comparableChannelTargetsShareRoute,
parseExplicitTargetForChannel,
resolveComparableTargetForChannel,
} from "./target-parsing.js";
function parseTelegramTargetForTest(raw: string): {
to: string;
@@ -111,4 +116,42 @@ describe("parseExplicitTargetForChannel", () => {
chatType: "direct",
});
});
it("builds comparable targets from plugin-owned grammar", () => {
expect(
resolveComparableTargetForChannel({
channel: "telegram",
rawTarget: "telegram:group:-100123:topic:77",
}),
).toEqual({
rawTo: "telegram:group:-100123:topic:77",
to: "-100123",
threadId: 77,
chatType: "group",
});
});
it("matches comparable targets when only the plugin grammar differs", () => {
const topicTarget = resolveComparableTargetForChannel({
channel: "telegram",
rawTarget: "telegram:-100123:topic:77",
});
const bareTarget = resolveComparableTargetForChannel({
channel: "telegram",
rawTarget: "-100123",
});
expect(
comparableChannelTargetsMatch({
left: topicTarget,
right: bareTarget,
}),
).toBe(false);
expect(
comparableChannelTargetsShareRoute({
left: topicTarget,
right: bareTarget,
}),
).toBe(true);
});
});

View File

@@ -8,6 +8,26 @@ export type ParsedChannelExplicitTarget = {
chatType?: ChatType;
};
export type ComparableChannelTarget = {
rawTo: string;
to: string;
threadId?: string | number;
chatType?: ChatType;
};
function normalizeComparableThreadId(
threadId?: string | number | null,
): string | number | undefined {
if (typeof threadId === "number") {
return Number.isFinite(threadId) ? Math.trunc(threadId) : undefined;
}
if (typeof threadId !== "string") {
return undefined;
}
const trimmed = threadId.trim();
return trimmed ? trimmed : undefined;
}
function parseWithPlugin(
rawChannel: string,
rawTarget: string,
@@ -25,3 +45,52 @@ export function parseExplicitTargetForChannel(
): ParsedChannelExplicitTarget | null {
return parseWithPlugin(channel, rawTarget);
}
export function resolveComparableTargetForChannel(params: {
channel: string;
rawTarget?: string | null;
fallbackThreadId?: string | number | null;
}): ComparableChannelTarget | null {
const rawTo = params.rawTarget?.trim();
if (!rawTo) {
return null;
}
const parsed = parseExplicitTargetForChannel(params.channel, rawTo);
const fallbackThreadId = normalizeComparableThreadId(params.fallbackThreadId);
return {
rawTo,
to: parsed?.to ?? rawTo,
threadId: normalizeComparableThreadId(parsed?.threadId ?? fallbackThreadId),
chatType: parsed?.chatType,
};
}
export function comparableChannelTargetsMatch(params: {
left?: ComparableChannelTarget | null;
right?: ComparableChannelTarget | null;
}): boolean {
const left = params.left;
const right = params.right;
if (!left || !right) {
return false;
}
return left.to === right.to && left.threadId === right.threadId;
}
export function comparableChannelTargetsShareRoute(params: {
left?: ComparableChannelTarget | null;
right?: ComparableChannelTarget | null;
}): boolean {
const left = params.left;
const right = params.right;
if (!left || !right) {
return false;
}
if (left.to !== right.to) {
return false;
}
if (left.threadId == null || right.threadId == null) {
return true;
}
return left.threadId === right.threadId;
}

View File

@@ -817,6 +817,25 @@ describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)",
expect(resolved.threadId).toBe(1122);
});
it("matches bare stored Telegram routes against topic-scoped turn routes via plugin grammar", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-forum-topic-mixed-shape",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "-1001234567890",
lastThreadId: 1122,
},
requestedChannel: "last",
turnSourceChannel: "telegram",
turnSourceTo: "telegram:-1001234567890:topic:1122",
});
expect(resolved.channel).toBe("telegram");
expect(resolved.to).toBe("telegram:-1001234567890:topic:1122");
expect(resolved.threadId).toBe(1122);
});
it("does not fall back to session lastThreadId when turnSourceChannel differs from session channel", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {

View File

@@ -1,5 +1,10 @@
import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
import { normalizeChatType, type ChatType } from "../../channels/chat-type.js";
import {
comparableChannelTargetsShareRoute,
parseExplicitTargetForChannel,
resolveComparableTargetForChannel,
} from "../../channels/plugins/target-parsing.js";
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
import { formatCliCommand } from "../../cli/command-format.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -75,11 +80,7 @@ function parseExplicitTargetWithPlugin(params: {
if (!provider) {
return null;
}
return (
resolveOutboundChannelPlugin({ channel: provider })?.messaging?.parseExplicitTarget?.({
raw,
}) ?? null
);
return parseExplicitTargetForChannel(provider, raw);
}
export function resolveSessionDeliveryTarget(params: {
@@ -113,12 +114,26 @@ export function resolveSessionDeliveryTarget(params: {
const context = deliveryContextFromSession(params.entry);
const sessionLastChannel =
context?.channel && isDeliverableMessageChannel(context.channel) ? context.channel : undefined;
const parsedSessionTarget = sessionLastChannel
? resolveComparableTargetForChannel({
channel: sessionLastChannel,
rawTarget: context?.to,
fallbackThreadId: context?.threadId,
})
: null;
// When a turn-source channel is provided, use only turn-scoped metadata.
// Falling back to mutable session fields would re-introduce routing races.
const hasTurnSourceChannel = params.turnSourceChannel != null;
const hasTurnSourceThreadId =
params.turnSourceThreadId != null && params.turnSourceThreadId !== "";
const parsedTurnSourceTarget =
hasTurnSourceChannel && params.turnSourceChannel
? resolveComparableTargetForChannel({
channel: params.turnSourceChannel,
rawTarget: params.turnSourceTo,
fallbackThreadId: params.turnSourceThreadId,
})
: null;
const hasTurnSourceThreadId = parsedTurnSourceTarget?.threadId != null;
const lastChannel = hasTurnSourceChannel ? params.turnSourceChannel : sessionLastChannel;
const lastTo = hasTurnSourceChannel ? params.turnSourceTo : context?.to;
const lastAccountId = hasTurnSourceChannel ? params.turnSourceAccountId : context?.accountId;
@@ -126,13 +141,19 @@ export function resolveSessionDeliveryTarget(params: {
// match the session context. This avoids mixing a turn-scoped `to` with a stale session-scoped
// threadId from a different chat/topic in shared-session scenarios.
const turnToMatchesSession =
!params.turnSourceTo || !context?.to || params.turnSourceTo === context.to;
!params.turnSourceTo ||
!context?.to ||
(params.turnSourceChannel === sessionLastChannel &&
comparableChannelTargetsShareRoute({
left: parsedTurnSourceTarget,
right: parsedSessionTarget,
}));
const lastThreadId = hasTurnSourceThreadId
? params.turnSourceThreadId
? parsedTurnSourceTarget?.threadId
: hasTurnSourceChannel &&
(params.turnSourceChannel !== sessionLastChannel || !turnToMatchesSession)
? undefined
: context?.threadId;
: parsedSessionTarget?.threadId;
const rawRequested = params.requestedChannel ?? "last";
const requested = rawRequested === "last" ? "last" : normalizeMessageChannel(rawRequested);