mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 00:52:57 +00:00
refactor: use channel target resolution APIs (#85814)
* refactor: use channel target resolution apis * refactor: satisfy delivery lint * refactor: remove unused target parsing shim * fix: preserve routed cron topic targets
This commit is contained in:
committed by
GitHub
parent
fd2a9adbe6
commit
c4f0da00a9
@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Maintainer skills: exclude plugin SDK/API boundary work from `openclaw-landable-bug-sweep` so bugbash sweeps stay focused on small paper-cut fixes.
|
||||
- QA-Lab/diagnostics: extend the OpenTelemetry smoke harness to prove trace, metric, and log export, and add first-class Prometheus and observability smoke aliases.
|
||||
- Plugin SDK: add a generic channel-message poll sender so channel plugins can expose poll delivery without depending on channel-specific SDK facades.
|
||||
- Plugin SDK/cron delivery: route cron delivery through the modern target resolver and outbound session-route APIs, deprecate parser-backed target helpers and `plugin-sdk/messaging-targets`, and move bundled callers to `plugin-sdk/channel-targets`.
|
||||
- Crabbox: keep the local wrapper's provider validation synced with the installed Crabbox binary while preserving supported aliases such as `docker` and `blacksmith`. (#85302) Thanks @hxy91819.
|
||||
- Maintainer skills: add `openclaw-landable-bug-sweep` for producing five small, reviewed, CI-green OpenClaw bugfix PRs from issue/PR sweeps.
|
||||
- Control UI/chat: add search and Load More pagination to the chat session picker, keeping initial session loads bounded while making older conversations reachable. (#85237) Thanks @amknight.
|
||||
|
||||
@@ -119,9 +119,9 @@ route-like fields, compare a child thread with its parent route, or build a
|
||||
stable dedupe key from `{ channel, to, accountId, threadId }`. The helper
|
||||
normalizes numeric thread ids the same way core does, so plugins should prefer
|
||||
it over ad hoc `String(threadId)` comparisons.
|
||||
Plugins with provider-specific target grammar can inject their parser into
|
||||
`resolveChannelRouteTargetWithParser(...)` and still get the same route target
|
||||
shape and thread fallback semantics core uses.
|
||||
Plugins with provider-specific target grammar should expose
|
||||
`messaging.resolveOutboundSessionRoute(...)` so core gets provider-native
|
||||
session and thread identity without using parser shims.
|
||||
|
||||
Bundled plugins that need the same parsing before the channel registry boots
|
||||
can also expose a top-level `session-key-api.ts` file with a matching
|
||||
@@ -253,7 +253,7 @@ surfaces:
|
||||
- `openclaw/plugin-sdk/inbound-envelope` and
|
||||
`openclaw/plugin-sdk/inbound-reply-dispatch` for inbound route/envelope and
|
||||
record-and-dispatch wiring
|
||||
- `openclaw/plugin-sdk/messaging-targets` for target parsing/matching
|
||||
- `openclaw/plugin-sdk/channel-targets` for target parsing helpers
|
||||
- `openclaw/plugin-sdk/outbound-media` and
|
||||
`openclaw/plugin-sdk/outbound-runtime` for media loading plus outbound
|
||||
identity/send delegates and payload planning
|
||||
|
||||
@@ -467,16 +467,23 @@ releases.
|
||||
| `channelRouteIdentityKey(...)` | `channelRouteDedupeKey(...)` |
|
||||
| `channelRouteKey(...)` | `channelRouteCompactKey(...)` |
|
||||
| `ComparableChannelTarget` | `ChannelRouteParsedTarget` |
|
||||
| `resolveComparableTargetForChannel(...)` | `resolveRouteTargetForChannel(...)` |
|
||||
| `resolveComparableTargetForLoadedChannel(...)` | `resolveRouteTargetForLoadedChannel(...)` |
|
||||
| `comparableChannelTargetsMatch(...)` | `channelRouteTargetsMatchExact(...)` |
|
||||
| `comparableChannelTargetsShareRoute(...)` | `channelRouteTargetsShareConversation(...)` |
|
||||
|
||||
The modern route helpers normalize `{ channel, to, accountId, threadId }`
|
||||
consistently across native approvals, reply suppression, inbound dedupe,
|
||||
cron delivery, and session routing. If your plugin owns custom target
|
||||
grammar, use `resolveChannelRouteTargetWithParser(...)` to adapt that
|
||||
parser into the same route target contract.
|
||||
cron delivery, and session routing.
|
||||
|
||||
Do not add new uses of `ChannelMessagingAdapter.parseExplicitTarget` or
|
||||
the parser-backed loaded-route helpers (`parseExplicitTargetForLoadedChannel`
|
||||
or `resolveRouteTargetForLoadedChannel`) or
|
||||
`resolveChannelRouteTargetWithParser(...)` from `plugin-sdk/channel-route`.
|
||||
Those hooks are deprecated and remain only for older plugins during the
|
||||
migration window. New channel plugins should use
|
||||
`messaging.targetResolver.resolveTarget(...)` for target id normalization
|
||||
and directory-miss fallback, `messaging.inferTargetChatType(...)` when core
|
||||
needs an early peer kind, and `messaging.resolveOutboundSessionRoute(...)`
|
||||
for provider-native session and thread identity.
|
||||
|
||||
</Step>
|
||||
|
||||
@@ -518,7 +525,7 @@ releases.
|
||||
| `plugin-sdk/channel-lifecycle` | Account status and draft stream lifecycle helpers | `createAccountStatusSink`, draft preview finalization helpers |
|
||||
| `plugin-sdk/inbound-envelope` | Inbound envelope helpers | Shared route + envelope builder helpers |
|
||||
| `plugin-sdk/inbound-reply-dispatch` | Inbound reply helpers | Shared record-and-dispatch helpers |
|
||||
| `plugin-sdk/messaging-targets` | Messaging target parsing | Target parsing/matching helpers |
|
||||
| `plugin-sdk/messaging-targets` | Deprecated target parsing import path | Use `plugin-sdk/channel-targets` for generic target parsing helpers, `plugin-sdk/channel-route` for route comparison, and plugin-owned `messaging.targetResolver` / `messaging.resolveOutboundSessionRoute` for provider-specific target resolution |
|
||||
| `plugin-sdk/outbound-media` | Outbound media helpers | Shared outbound media loading |
|
||||
| `plugin-sdk/outbound-send-deps` | Outbound send dependency helpers | Lightweight `resolveOutboundSendDep` lookup without importing the full outbound runtime |
|
||||
| `plugin-sdk/outbound-runtime` | Outbound runtime helpers | Outbound delivery, identity/send delegate, session, formatting, and payload planning helpers |
|
||||
|
||||
@@ -117,7 +117,7 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
|
||||
| `plugin-sdk/channel-message-runtime` | Runtime delivery helpers that may load outbound delivery, including `deliverInboundReplyWithMessageSendContext`, `sendDurableMessageBatch`, and `withDurableMessageSendContext`. Deprecated reply-dispatch bridges remain importable for compatibility dispatchers only. Use from monitor/send runtime modules, not hot plugin bootstrap files. |
|
||||
| `plugin-sdk/inbound-envelope` | Shared inbound route + envelope builder helpers |
|
||||
| `plugin-sdk/inbound-reply-dispatch` | Legacy shared inbound record-and-dispatch helpers, visible/final dispatch predicates, and deprecated `deliverDurableInboundReplyPayload` compatibility for prepared channel dispatchers. New channel receive/dispatch code should import runtime lifecycle helpers from `plugin-sdk/channel-message-runtime`. |
|
||||
| `plugin-sdk/messaging-targets` | Target parsing/matching helpers |
|
||||
| `plugin-sdk/messaging-targets` | Deprecated target parsing alias; use `plugin-sdk/channel-targets` |
|
||||
| `plugin-sdk/outbound-media` | Shared outbound media loading helpers |
|
||||
| `plugin-sdk/outbound-send-deps` | Lightweight outbound send dependency lookup for channel adapters |
|
||||
| `plugin-sdk/outbound-runtime` | Outbound identity, send delegate, session, formatting, and payload planning helpers. Direct delivery helpers such as `deliverOutboundPayloads` are deprecated compatibility substrate; use `plugin-sdk/channel-message-runtime` for new send paths. |
|
||||
|
||||
@@ -93,14 +93,6 @@ export const clickClackPlugin: ChannelPlugin<ResolvedClickClackAccount> = create
|
||||
messaging: {
|
||||
targetPrefixes: ["clickclack", "cc"],
|
||||
normalizeTarget: normalizeClickClackTarget,
|
||||
parseExplicitTarget: ({ raw }) => {
|
||||
const parsed = parseClickClackTarget(raw);
|
||||
return {
|
||||
to: buildClickClackTarget(parsed),
|
||||
threadId: parsed.kind === "thread" ? parsed.id : undefined,
|
||||
chatType: parsed.chatType,
|
||||
};
|
||||
},
|
||||
inferTargetChatType: ({ to }) => parseClickClackTarget(to).chatType,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeClickClackTarget,
|
||||
|
||||
@@ -142,18 +142,3 @@ export function resolveDiscordInboundConversation(params: {
|
||||
});
|
||||
return conversationId ? { conversationId } : null;
|
||||
}
|
||||
|
||||
export function parseDiscordExplicitTarget(raw: string) {
|
||||
try {
|
||||
const target = parseDiscordTarget(raw, { defaultKind: "channel" });
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: target.normalized,
|
||||
chatType: target.kind === "user" ? ("direct" as const) : ("channel" as const),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,28 +200,20 @@ describe("discordPlugin outbound", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves normalized explicit Discord targets for delivery routing", () => {
|
||||
const parseExplicitTarget = discordPlugin.messaging?.parseExplicitTarget;
|
||||
if (!parseExplicitTarget) {
|
||||
throw new Error("Expected discordPlugin.messaging.parseExplicitTarget to be defined");
|
||||
it("preserves normalized Discord targets for delivery routing", () => {
|
||||
const messaging = discordPlugin.messaging;
|
||||
if (!messaging?.normalizeTarget || !messaging.inferTargetChatType) {
|
||||
throw new Error("Expected discordPlugin.messaging target helpers to be defined");
|
||||
}
|
||||
|
||||
expect(parseExplicitTarget({ raw: "user:123" })).toEqual({
|
||||
to: "user:123",
|
||||
chatType: "direct",
|
||||
});
|
||||
expect(parseExplicitTarget({ raw: "<@!456>" })).toEqual({
|
||||
to: "user:456",
|
||||
chatType: "direct",
|
||||
});
|
||||
expect(parseExplicitTarget({ raw: "channel:789" })).toEqual({
|
||||
to: "channel:789",
|
||||
chatType: "channel",
|
||||
});
|
||||
expect(parseExplicitTarget({ raw: "1470130713209602050" })).toEqual({
|
||||
to: "channel:1470130713209602050",
|
||||
chatType: "channel",
|
||||
});
|
||||
expect(messaging.normalizeTarget("user:123")).toBe("user:123");
|
||||
expect(messaging.inferTargetChatType({ to: "user:123" })).toBe("direct");
|
||||
expect(messaging.normalizeTarget("<@!456>")).toBe("user:456");
|
||||
expect(messaging.inferTargetChatType({ to: "<@!456>" })).toBe("direct");
|
||||
expect(messaging.normalizeTarget("channel:789")).toBe("channel:789");
|
||||
expect(messaging.inferTargetChatType({ to: "channel:789" })).toBe("channel");
|
||||
expect(messaging.normalizeTarget("1470130713209602050")).toBe("channel:1470130713209602050");
|
||||
expect(messaging.inferTargetChatType({ to: "1470130713209602050" })).toBe("channel");
|
||||
});
|
||||
|
||||
it("resolves Discord usernames through the messaging target resolver", async () => {
|
||||
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
buildDiscordCrossContextPresentation,
|
||||
matchDiscordAcpConversation,
|
||||
normalizeDiscordAcpConversationId,
|
||||
parseDiscordExplicitTarget,
|
||||
resolveDiscordAttachedOutboundTarget,
|
||||
resolveDiscordCommandConversation,
|
||||
resolveDiscordInboundConversation,
|
||||
@@ -320,8 +319,17 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
|
||||
normalizeExplicitSessionKey: ({ sessionKey, ctx }) =>
|
||||
normalizeExplicitDiscordSessionKey(sessionKey, ctx),
|
||||
resolveSessionTarget: ({ id }) => normalizeDiscordMessagingTarget(`channel:${id}`),
|
||||
parseExplicitTarget: ({ raw }) => parseDiscordExplicitTarget(raw),
|
||||
inferTargetChatType: ({ to }) => parseDiscordExplicitTarget(to)?.chatType,
|
||||
inferTargetChatType: ({ to }) => {
|
||||
try {
|
||||
const parsed = parseDiscordTarget(to, { defaultKind: "channel" });
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed?.kind === "user" ? "direct" : "channel";
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
buildCrossContextPresentation: buildDiscordCrossContextPresentation,
|
||||
resolveOutboundSessionRoute: (params) => resolveDiscordOutboundSessionRoute(params),
|
||||
targetResolver: {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type MessagingTarget,
|
||||
type MessagingTargetKind,
|
||||
type MessagingTargetParseOptions,
|
||||
} from "openclaw/plugin-sdk/messaging-targets";
|
||||
} from "openclaw/plugin-sdk/channel-targets";
|
||||
|
||||
export type DiscordTargetKind = MessagingTargetKind;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { buildMessagingTarget, type MessagingTarget } from "openclaw/plugin-sdk/channel-targets";
|
||||
import type { DirectoryConfigParams } from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { buildMessagingTarget, type MessagingTarget } from "openclaw/plugin-sdk/messaging-targets";
|
||||
import { resolveDiscordAccount, resolveDiscordAccountAllowFrom } from "./accounts.js";
|
||||
import { rememberDiscordDirectoryUser } from "./directory-cache.js";
|
||||
import { listDiscordDirectoryPeersLive } from "./directory-live.js";
|
||||
|
||||
@@ -92,14 +92,6 @@ export const qaChannelPlugin: ChannelPlugin<ResolvedQaChannelAccount> = createCh
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeQaTarget,
|
||||
parseExplicitTarget: ({ raw }) => {
|
||||
const parsed = parseQaTarget(raw);
|
||||
return {
|
||||
to: buildQaTarget(parsed),
|
||||
threadId: parsed.threadId,
|
||||
chatType: parsed.chatType,
|
||||
};
|
||||
},
|
||||
inferTargetChatType: ({ to }) => parseQaTarget(to).chatType,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) =>
|
||||
|
||||
@@ -150,17 +150,6 @@ function inferSignalTargetChatType(rawTo: string) {
|
||||
return "direct" as const;
|
||||
}
|
||||
|
||||
function parseSignalExplicitTarget(raw: string) {
|
||||
const normalized = normalizeSignalMessagingTarget(raw);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: normalized,
|
||||
chatType: inferSignalTargetChatType(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSignalBaseSessionKey(params: {
|
||||
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
|
||||
agentId: string;
|
||||
@@ -308,7 +297,6 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
|
||||
messaging: {
|
||||
targetPrefixes: ["signal"],
|
||||
normalizeTarget: normalizeSignalMessagingTarget,
|
||||
parseExplicitTarget: ({ raw }) => parseSignalExplicitTarget(raw),
|
||||
inferTargetChatType: ({ to }) => inferSignalTargetChatType(to),
|
||||
resolveOutboundSessionRoute: (params) => resolveSignalOutboundSessionRoute(params),
|
||||
targetResolver: {
|
||||
|
||||
@@ -232,7 +232,7 @@ async function resolveSlackSendContext(params: {
|
||||
return { send, threadTsValue, tokenOverride };
|
||||
}
|
||||
|
||||
function parseSlackExplicitTarget(raw: string) {
|
||||
function resolveSlackRouteTarget(raw: string) {
|
||||
const target = parseSlackTarget(raw, { defaultKind: "channel" });
|
||||
if (!target) {
|
||||
return null;
|
||||
@@ -584,8 +584,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
: { to: normalizeSlackMessagingTarget(`channel:${child}`) };
|
||||
},
|
||||
resolveSessionTarget: ({ id }) => normalizeSlackMessagingTarget(`channel:${id}`),
|
||||
parseExplicitTarget: ({ raw }) => parseSlackExplicitTarget(raw),
|
||||
inferTargetChatType: ({ to }) => parseSlackExplicitTarget(to)?.chatType,
|
||||
inferTargetChatType: ({ to }) => resolveSlackRouteTarget(to)?.chatType,
|
||||
resolveOutboundSessionRoute: async (params) => await resolveSlackOutboundSessionRoute(params),
|
||||
transformReplyPayload: ({ payload, cfg, accountId }) =>
|
||||
isSlackInteractiveRepliesEnabled({ cfg, accountId })
|
||||
@@ -604,7 +603,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
looksLikeId: looksLikeSlackTargetId,
|
||||
hint: "<channelId|user:ID|channel:ID>",
|
||||
resolveTarget: async ({ input }) => {
|
||||
const parsed = parseSlackExplicitTarget(input);
|
||||
const parsed = resolveSlackRouteTarget(input);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type MessagingTarget,
|
||||
type MessagingTargetKind,
|
||||
type MessagingTargetParseOptions,
|
||||
} from "openclaw/plugin-sdk/messaging-targets";
|
||||
} from "openclaw/plugin-sdk/channel-targets";
|
||||
|
||||
export type SlackTargetKind = MessagingTargetKind;
|
||||
|
||||
|
||||
@@ -464,7 +464,7 @@ function resolveTelegramDeliveryTarget(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function parseTelegramExplicitTarget(raw: string) {
|
||||
function resolveTelegramRouteTarget(raw: string) {
|
||||
const target = parseTelegramTarget(raw);
|
||||
return {
|
||||
to: target.chatId,
|
||||
@@ -496,7 +496,7 @@ function shouldStripTelegramThreadFromAnnounceOrigin(params: {
|
||||
if (!requesterChannel && !requesterTo.startsWith("telegram:")) {
|
||||
return true;
|
||||
}
|
||||
const requesterTarget = parseTelegramExplicitTarget(requesterTo);
|
||||
const requesterTarget = resolveTelegramRouteTarget(requesterTo);
|
||||
if (requesterTarget.chatType !== "group") {
|
||||
return true;
|
||||
}
|
||||
@@ -504,7 +504,7 @@ function shouldStripTelegramThreadFromAnnounceOrigin(params: {
|
||||
if (!entryTo) {
|
||||
return false;
|
||||
}
|
||||
const entryTarget = parseTelegramExplicitTarget(entryTo);
|
||||
const entryTarget = resolveTelegramRouteTarget(entryTo);
|
||||
return entryTarget.to !== requesterTarget.to;
|
||||
}
|
||||
|
||||
@@ -753,8 +753,7 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
resolveSessionConversation: ({ kind, rawId }) =>
|
||||
resolveTelegramSessionConversation({ kind, rawId }),
|
||||
resolveSessionTarget: ({ kind, id }) => resolveTelegramSessionTarget({ kind, id }),
|
||||
parseExplicitTarget: ({ raw }) => parseTelegramExplicitTarget(raw),
|
||||
inferTargetChatType: ({ to }) => parseTelegramExplicitTarget(to).chatType,
|
||||
inferTargetChatType: ({ to }) => resolveTelegramRouteTarget(to).chatType,
|
||||
preserveHeartbeatThreadIdForGroupRoute: true,
|
||||
formatTargetDisplay: ({ target, display, kind }) => {
|
||||
const formatted = display?.trim();
|
||||
|
||||
@@ -51,7 +51,7 @@ const loadWhatsAppChannelReactAction = createLazyRuntimeModule(
|
||||
() => import("./channel-react-action.js"),
|
||||
);
|
||||
|
||||
function parseWhatsAppExplicitTarget(raw: string) {
|
||||
function resolveWhatsAppTargetInfo(raw: string) {
|
||||
const normalized = normalizeWhatsAppTarget(raw);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
@@ -120,8 +120,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
|
||||
targetPrefixes: ["whatsapp"],
|
||||
normalizeTarget: normalizeWhatsAppMessagingTarget,
|
||||
resolveOutboundSessionRoute: (params) => resolveWhatsAppOutboundSessionRoute(params),
|
||||
parseExplicitTarget: ({ raw }) => parseWhatsAppExplicitTarget(raw),
|
||||
inferTargetChatType: ({ to }) => parseWhatsAppExplicitTarget(to)?.chatType,
|
||||
inferTargetChatType: ({ to }) => resolveWhatsAppTargetInfo(to)?.chatType,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeWhatsAppTargetId,
|
||||
hint: "<E.164|group JID|newsletter JID>",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"matrix",
|
||||
"mattermost",
|
||||
"media-generation-runtime-shared",
|
||||
"messaging-targets",
|
||||
"memory-core",
|
||||
"memory-core-engine-runtime",
|
||||
"memory-core-host-events",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { resolveAgentWorkspaceDir } from "../../agents/agent-scope-config.js";
|
||||
import {
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../../agents/agent-scope-config.js";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
|
||||
import { normalizeReplyPayload } from "../../auto-reply/reply/normalize-reply.js";
|
||||
@@ -11,7 +14,7 @@ import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import {
|
||||
resolveAgentDeliveryPlan,
|
||||
resolveAgentDeliveryPlanWithSessionRoute,
|
||||
resolveAgentOutboundTarget,
|
||||
} from "../../infra/outbound/agent-delivery.js";
|
||||
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
|
||||
@@ -416,6 +419,13 @@ export async function deliverAgentCommandResult(
|
||||
): Promise<AgentCommandDeliveryResult> {
|
||||
const { cfg, deps, runtime, opts, outboundSession, sessionEntry, payloads, result } = params;
|
||||
const effectiveSessionKey = outboundSession?.key ?? opts.sessionKey;
|
||||
const deliveryAgentId =
|
||||
outboundSession?.agentId ??
|
||||
resolveSessionAgentId({
|
||||
sessionKey: effectiveSessionKey,
|
||||
config: cfg,
|
||||
}) ??
|
||||
resolveDefaultAgentId(cfg);
|
||||
const deliver = opts.deliver === true;
|
||||
const bestEffortDeliver = opts.bestEffortDeliver === true;
|
||||
const turnSourceChannel = opts.runContext?.messageChannel ?? opts.messageChannel;
|
||||
@@ -424,7 +434,10 @@ export async function deliverAgentCommandResult(
|
||||
const turnSourceThreadId = opts.runContext?.currentThreadTs ?? opts.threadId;
|
||||
const explicitChannelHint = (opts.replyChannel ?? opts.channel)?.trim();
|
||||
const resolveDeliveryRouting = async (candidateSessionEntry: SessionEntry | undefined) => {
|
||||
const deliveryPlan = resolveAgentDeliveryPlan({
|
||||
const deliveryPlan = await resolveAgentDeliveryPlanWithSessionRoute({
|
||||
cfg,
|
||||
agentId: deliveryAgentId,
|
||||
currentSessionKey: effectiveSessionKey,
|
||||
sessionEntry: candidateSessionEntry,
|
||||
requestedChannel: opts.replyChannel ?? opts.channel,
|
||||
explicitTo: opts.replyTo ?? opts.to,
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { resolveRouteTargetForLoadedChannel } from "../channels/plugins/target-parsing-loaded.js";
|
||||
import { getLoadedChannelPluginForRead } from "../channels/plugins/registry-loaded-read.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.public.js";
|
||||
import {
|
||||
stripTargetKindPrefix,
|
||||
stripTargetProviderPrefix,
|
||||
stripTargetTopicSuffix,
|
||||
} from "../infra/outbound/channel-target-prefix.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import {
|
||||
deliveryContextFromSession,
|
||||
@@ -12,31 +18,20 @@ import type {
|
||||
import { isInternalMessageChannel } from "../utils/message-channel.js";
|
||||
export type { DeliveryContext } from "../utils/delivery-context.types.js";
|
||||
|
||||
function stripThreadRouteSuffix(target: string): string {
|
||||
return /^(.*):topic:[^:]+$/u.exec(target)?.[1] ?? target;
|
||||
}
|
||||
|
||||
function normalizeAnnounceRouteTarget(context?: DeliveryContext): string | undefined {
|
||||
const rawTo = normalizeOptionalString(context?.to);
|
||||
if (!rawTo) {
|
||||
return undefined;
|
||||
}
|
||||
const channel = normalizeOptionalString(context?.channel);
|
||||
const parsed = channel
|
||||
? resolveRouteTargetForLoadedChannel({
|
||||
channel,
|
||||
rawTarget: rawTo,
|
||||
fallbackThreadId: context?.threadId,
|
||||
})
|
||||
: null;
|
||||
let route = stripThreadRouteSuffix(parsed?.to ?? rawTo);
|
||||
if (channel && route.toLowerCase().startsWith(`${channel}:`)) {
|
||||
route = route.slice(channel.length + 1);
|
||||
}
|
||||
if (route.startsWith("group:") || route.startsWith("channel:")) {
|
||||
route = route.slice(route.indexOf(":") + 1);
|
||||
}
|
||||
return route || undefined;
|
||||
const messaging = channel
|
||||
? getLoadedChannelPluginForRead(channel as ChannelId)?.messaging
|
||||
: undefined;
|
||||
const route = stripTargetTopicSuffix(
|
||||
stripTargetKindPrefix(stripTargetProviderPrefix(rawTo, channel ?? ""), ["group", "channel"]),
|
||||
);
|
||||
const normalized = messaging?.normalizeTarget?.(route) ?? route;
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function shouldStripThreadFromAnnounceEntry(
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import {
|
||||
createChannelTestPluginBase,
|
||||
createTestRegistry,
|
||||
} from "../../test-utils/channel-plugins.js";
|
||||
import { extractSimpleExplicitGroupId } from "./group-id-simple.js";
|
||||
import { extractExplicitGroupId } from "./group-id.js";
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry());
|
||||
});
|
||||
|
||||
describe("extractSimpleExplicitGroupId", () => {
|
||||
it("returns undefined for empty/null input", () => {
|
||||
@@ -42,3 +52,53 @@ describe("extractSimpleExplicitGroupId", () => {
|
||||
expect(extractSimpleExplicitGroupId("just-a-string")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractExplicitGroupId", () => {
|
||||
it("strips Telegram numeric topic shorthand after target normalization", () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "telegram",
|
||||
source: "test",
|
||||
plugin: {
|
||||
...createChannelTestPluginBase({
|
||||
id: "telegram",
|
||||
capabilities: { chatTypes: ["group"] },
|
||||
}),
|
||||
messaging: {
|
||||
normalizeTarget: () => "telegram:-100200300:77",
|
||||
inferTargetChatType: () => "group",
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(extractExplicitGroupId("telegram:-100200300:77")).toBe("-100200300");
|
||||
});
|
||||
|
||||
it("keeps legacy parser-only group target extraction quarantined", () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "legacygroup",
|
||||
source: "test",
|
||||
plugin: {
|
||||
...createChannelTestPluginBase({
|
||||
id: "legacygroup",
|
||||
capabilities: { chatTypes: ["group"] },
|
||||
}),
|
||||
messaging: {
|
||||
parseExplicitTarget: ({ raw }: { raw: string }) =>
|
||||
raw.startsWith("legacygroup:")
|
||||
? { to: "group:room-a:topic:77", chatType: "group" as const }
|
||||
: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(extractExplicitGroupId("legacygroup:room-a:topic:77")).toBe("room-a");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,71 @@
|
||||
import { getLoadedChannelPluginById } from "../../channels/plugins/registry-loaded.js";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
|
||||
import { getLoadedChannelPluginForRead } from "../../channels/plugins/registry-loaded-read.js";
|
||||
import type { ChannelMessagingAdapter } from "../../channels/plugins/types.public.js";
|
||||
import { normalizeAnyChannelId } from "../../channels/registry.js";
|
||||
import {
|
||||
stripTargetKindPrefix,
|
||||
stripTargetProviderPrefix,
|
||||
stripTargetTopicSuffix,
|
||||
} from "../../infra/outbound/channel-target-prefix.js";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "../../shared/string-coerce.js";
|
||||
import { extractSimpleExplicitGroupId } from "./group-id-simple.js";
|
||||
|
||||
function extractInferredGroupTargetId(params: {
|
||||
raw: string;
|
||||
channelId: string;
|
||||
messaging?: ChannelMessagingAdapter;
|
||||
}): string | undefined {
|
||||
const normalized = params.messaging?.normalizeTarget?.(params.raw);
|
||||
const candidates = [normalized, params.raw].filter(
|
||||
(candidate, index, values): candidate is string =>
|
||||
Boolean(candidate) && values.indexOf(candidate) === index,
|
||||
);
|
||||
for (const candidate of candidates) {
|
||||
const chatType = params.messaging?.inferTargetChatType?.({ to: candidate });
|
||||
if (chatType === "direct" || chatType == null) {
|
||||
continue;
|
||||
}
|
||||
const target = stripTargetTopicSuffix(
|
||||
stripTargetKindPrefix(stripTargetProviderPrefix(candidate, params.channelId), [
|
||||
"group",
|
||||
"channel",
|
||||
"conversation",
|
||||
"room",
|
||||
"thread",
|
||||
]),
|
||||
{ allowNumericShorthand: params.channelId === "telegram" },
|
||||
);
|
||||
if (target) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractLegacyParsedGroupTargetId(params: {
|
||||
raw: string;
|
||||
channelId: string;
|
||||
messaging?: ChannelMessagingAdapter;
|
||||
}): string | undefined {
|
||||
const parsed = params.messaging?.parseExplicitTarget?.({ raw: params.raw });
|
||||
if (parsed?.chatType === "direct" || parsed?.chatType == null) {
|
||||
return undefined;
|
||||
}
|
||||
const target = stripTargetTopicSuffix(
|
||||
stripTargetKindPrefix(stripTargetProviderPrefix(parsed.to, params.channelId), [
|
||||
"group",
|
||||
"channel",
|
||||
"conversation",
|
||||
"room",
|
||||
"thread",
|
||||
]),
|
||||
{ allowNumericShorthand: params.channelId === "telegram" },
|
||||
);
|
||||
return target || undefined;
|
||||
}
|
||||
|
||||
export function extractExplicitGroupId(raw: string | undefined | null): string | undefined {
|
||||
const trimmed = normalizeOptionalString(raw) ?? "";
|
||||
if (!trimmed) {
|
||||
@@ -19,12 +78,20 @@ export function extractExplicitGroupId(raw: string | undefined | null): string |
|
||||
const firstPart = trimmed.split(":").find(Boolean);
|
||||
const channelId =
|
||||
normalizeAnyChannelId(firstPart ?? "") ?? normalizeOptionalLowercaseString(firstPart);
|
||||
const messaging = channelId
|
||||
? (getLoadedChannelPluginById(channelId) as ChannelPlugin | undefined)?.messaging
|
||||
: undefined;
|
||||
const parsed = messaging?.parseExplicitTarget?.({ raw: trimmed }) ?? null;
|
||||
if (parsed && parsed.chatType && parsed.chatType !== "direct") {
|
||||
return parsed.to.replace(/:topic:.*$/, "") || undefined;
|
||||
const messaging = channelId ? getLoadedChannelPluginForRead(channelId)?.messaging : undefined;
|
||||
if (!channelId) {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
return (
|
||||
extractInferredGroupTargetId({
|
||||
raw: trimmed,
|
||||
channelId,
|
||||
messaging,
|
||||
}) ??
|
||||
extractLegacyParsedGroupTargetId({
|
||||
raw: trimmed,
|
||||
channelId,
|
||||
messaging,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,6 +162,120 @@ describe("conversation resolution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("strips provider prefixes from normalized fallback conversation targets", () => {
|
||||
registerChannelPlugin({
|
||||
...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }),
|
||||
messaging: {
|
||||
normalizeTarget: () => "telegram:-1001234567890:topic:77",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCommandConversationResolution({
|
||||
cfg: testConfig,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
originatingTo: "-1001234567890:topic:77",
|
||||
})?.canonical,
|
||||
).toEqual({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-1001234567890",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips kind-prefixed normalized topic routes before fallback resolution", () => {
|
||||
registerChannelPlugin({
|
||||
...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }),
|
||||
messaging: {
|
||||
normalizeTarget: () => "telegram:group:-1001234567890:topic:77",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCommandConversationResolution({
|
||||
cfg: testConfig,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
originatingTo: "group:-1001234567890:topic:77",
|
||||
})?.canonical,
|
||||
).toEqual({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-1001234567890",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes alias-prefixed topic routes before fallback resolution", () => {
|
||||
registerChannelPlugin({
|
||||
...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }),
|
||||
messaging: {
|
||||
targetPrefixes: ["tg"],
|
||||
normalizeTarget: () => "telegram:group:-1001234567890:topic:77",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCommandConversationResolution({
|
||||
cfg: testConfig,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
originatingTo: "tg:group:-1001234567890:topic:77",
|
||||
})?.canonical,
|
||||
).toEqual({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-1001234567890",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips Telegram numeric topic shorthand in fallback resolution", () => {
|
||||
registerChannelPlugin({
|
||||
...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }),
|
||||
messaging: {
|
||||
normalizeTarget: () => "telegram:-1001234567890:77",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCommandConversationResolution({
|
||||
cfg: testConfig,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
originatingTo: "-1001234567890:77",
|
||||
})?.canonical,
|
||||
).toEqual({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-1001234567890",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps parser-only fallback conversation targets during the migration window", () => {
|
||||
registerChannelPlugin({
|
||||
...createChannelTestPluginBase({ id: "legacychat", label: "Legacy chat" }),
|
||||
messaging: {
|
||||
parseExplicitTarget: ({ raw }) =>
|
||||
raw === "room-a:topic:77"
|
||||
? { to: "room-a", threadId: 77, chatType: "group" as const }
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCommandConversationResolution({
|
||||
cfg: testConfig,
|
||||
channel: "legacychat",
|
||||
accountId: "default",
|
||||
originatingTo: "room-a:topic:77",
|
||||
})?.canonical,
|
||||
).toEqual({
|
||||
channel: "legacychat",
|
||||
accountId: "default",
|
||||
conversationId: "room-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes numeric command thread ids through the shared route contract", () => {
|
||||
registerChannelPlugin({
|
||||
...createChannelTestPluginBase({ id: "test-chat", label: "Test chat" }),
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
resolveTargetPrefixedChannel,
|
||||
stripTargetKindPrefix,
|
||||
stripTargetProviderPrefix,
|
||||
stripTargetTopicSuffix,
|
||||
} from "../infra/outbound/channel-target-prefix.js";
|
||||
import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js";
|
||||
import { normalizeConversationTargetRef } from "../infra/outbound/session-binding-normalization.js";
|
||||
import { stringifyRouteThreadId } from "../plugin-sdk/channel-route.js";
|
||||
@@ -9,7 +15,7 @@ import {
|
||||
normalizeOptionalString,
|
||||
} from "../shared/string-coerce.js";
|
||||
import { getLoadedChannelPlugin, normalizeChannelId } from "./plugins/index.js";
|
||||
import { parseExplicitTargetForChannel } from "./plugins/target-parsing.js";
|
||||
import { resolveExplicitDeliveryTargetCompat } from "./plugins/target-parsing-loaded.js";
|
||||
import {
|
||||
resolveBundledChannelThreadBindingDefaultPlacement,
|
||||
resolveBundledChannelThreadBindingInboundConversation,
|
||||
@@ -165,6 +171,30 @@ function resolveBindingAccountId(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFallbackConversationTargetId(params: {
|
||||
rawTarget: string;
|
||||
allowNumericTopicShorthand?: boolean;
|
||||
}): string | undefined {
|
||||
const { allowNumericTopicShorthand = false } = params;
|
||||
const target = normalizeOptionalString(params.rawTarget);
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
const withoutKind = stripTargetKindPrefix(target);
|
||||
const withoutTopic = stripTargetTopicSuffix(withoutKind, {
|
||||
allowNumericShorthand: allowNumericTopicShorthand,
|
||||
});
|
||||
return (
|
||||
resolveConversationIdFromTargets({
|
||||
targets: [withoutTopic],
|
||||
}) ??
|
||||
(withoutTopic !== target ? withoutTopic : undefined) ??
|
||||
resolveConversationIdFromTargets({
|
||||
targets: [target],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function resolveChannelTargetId(params: {
|
||||
channel: string;
|
||||
target?: string | null;
|
||||
@@ -186,20 +216,38 @@ function resolveChannelTargetId(params: {
|
||||
return target;
|
||||
}
|
||||
|
||||
const explicitConversationId = resolveConversationIdFromTargets({
|
||||
targets: [target],
|
||||
});
|
||||
if (explicitConversationId) {
|
||||
return explicitConversationId;
|
||||
const prefixedChannel = resolveTargetPrefixedChannel(target);
|
||||
if (!prefixedChannel || prefixedChannel !== params.channel) {
|
||||
const explicitConversationId = resolveFallbackConversationTargetId({
|
||||
rawTarget: target,
|
||||
allowNumericTopicShorthand: params.channel === "telegram",
|
||||
});
|
||||
if (explicitConversationId) {
|
||||
return explicitConversationId;
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseExplicitTargetForChannel(params.channel, target);
|
||||
const parsedTarget = normalizeOptionalString(parsed?.to);
|
||||
if (parsedTarget) {
|
||||
const normalizedTarget = normalizeOptionalString(
|
||||
resolveRuntimeChannelPlugin(params.channel)?.messaging?.normalizeTarget?.(target),
|
||||
);
|
||||
if (normalizedTarget) {
|
||||
const withoutProvider = stripTargetProviderPrefix(normalizedTarget, params.channel);
|
||||
const conversationId = resolveFallbackConversationTargetId({
|
||||
rawTarget: withoutProvider,
|
||||
allowNumericTopicShorthand: params.channel === "telegram",
|
||||
});
|
||||
return conversationId || withoutProvider || normalizedTarget;
|
||||
}
|
||||
|
||||
const parsedTarget = resolveExplicitDeliveryTargetCompat({
|
||||
channel: params.channel,
|
||||
rawTarget: target,
|
||||
});
|
||||
if (parsedTarget?.to) {
|
||||
return (
|
||||
resolveConversationIdFromTargets({
|
||||
targets: [parsedTarget],
|
||||
}) ?? parsedTarget
|
||||
targets: [parsedTarget.to],
|
||||
}) ?? parsedTarget.to
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,51 @@
|
||||
import {
|
||||
channelRouteTargetsMatchExact,
|
||||
channelRouteTargetsShareConversation,
|
||||
resolveChannelRouteTargetWithParser,
|
||||
type ChannelRouteExplicitTarget,
|
||||
type ChannelRouteParsedTarget,
|
||||
} from "../../plugin-sdk/channel-route.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
normalizeOptionalThreadValue,
|
||||
} from "../../shared/string-coerce.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "./index.js";
|
||||
import { getLoadedChannelPluginForRead } from "./registry-loaded-read.js";
|
||||
|
||||
export type { ChannelRouteParsedTarget } from "../../plugin-sdk/channel-route.js";
|
||||
|
||||
export type ParsedChannelExplicitTarget = ChannelRouteExplicitTarget;
|
||||
/** @deprecated Use `ChannelRouteParsedTarget`; provider-specific target grammar should live in `messaging.resolveOutboundSessionRoute`. */
|
||||
export type ParsedChannelExplicitTarget = {
|
||||
to: string;
|
||||
threadId?: string | number;
|
||||
chatType?: "direct" | "group" | "channel";
|
||||
};
|
||||
|
||||
export function resolveCompatParsedRouteTarget(params: {
|
||||
channel: string;
|
||||
rawTarget?: string | null;
|
||||
fallbackThreadId?: string | number | null;
|
||||
parseTarget: (channel: string, rawTarget: string) => ParsedChannelExplicitTarget | null;
|
||||
}): ChannelRouteParsedTarget | null {
|
||||
const channel = normalizeLowercaseStringOrEmpty(params.channel);
|
||||
const rawTo = normalizeOptionalString(params.rawTarget);
|
||||
if (!channel || !rawTo) {
|
||||
return null;
|
||||
}
|
||||
const parsed = params.parseTarget(channel, rawTo);
|
||||
const fallbackThreadId = normalizeOptionalThreadValue(params.fallbackThreadId);
|
||||
return {
|
||||
channel,
|
||||
rawTo,
|
||||
to: parsed?.to ?? rawTo,
|
||||
threadId: normalizeOptionalThreadValue(parsed?.threadId ?? fallbackThreadId),
|
||||
chatType: parsed?.chatType,
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated Use `ChannelRouteParsedTarget`. */
|
||||
export type ComparableChannelTarget = ChannelRouteParsedTarget;
|
||||
|
||||
/** @deprecated Use `messaging.targetResolver` and `messaging.resolveOutboundSessionRoute`. */
|
||||
export function parseExplicitTargetForLoadedChannel(
|
||||
channel: string,
|
||||
rawTarget: string,
|
||||
@@ -23,25 +54,39 @@ export function parseExplicitTargetForLoadedChannel(
|
||||
if (!resolvedChannel) {
|
||||
return null;
|
||||
}
|
||||
const normalizedChannel = normalizeChannelId(resolvedChannel) ?? resolvedChannel;
|
||||
return (
|
||||
getLoadedChannelPluginForRead(resolvedChannel)?.messaging?.parseExplicitTarget?.({
|
||||
getLoadedChannelPluginForRead(normalizedChannel)?.messaging?.parseExplicitTarget?.({
|
||||
raw: rawTarget,
|
||||
}) ?? null
|
||||
}) ??
|
||||
getChannelPlugin(normalizedChannel)?.messaging?.parseExplicitTarget?.({
|
||||
raw: rawTarget,
|
||||
}) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Use `messaging.resolveOutboundSessionRoute` for provider-specific target grammar. */
|
||||
export function resolveRouteTargetForLoadedChannel(params: {
|
||||
channel: string;
|
||||
rawTarget?: string | null;
|
||||
fallbackThreadId?: string | number | null;
|
||||
}): ChannelRouteParsedTarget | null {
|
||||
return resolveChannelRouteTargetWithParser({
|
||||
return resolveCompatParsedRouteTarget({
|
||||
...params,
|
||||
parseExplicitTarget: parseExplicitTargetForLoadedChannel,
|
||||
parseTarget: parseExplicitTargetForLoadedChannel,
|
||||
});
|
||||
}
|
||||
|
||||
/** @deprecated Use `resolveRouteTargetForLoadedChannel`. */
|
||||
export function resolveExplicitDeliveryTargetCompat(params: {
|
||||
channel: string;
|
||||
rawTarget?: string | null;
|
||||
fallbackThreadId?: string | number | null;
|
||||
}): ChannelRouteParsedTarget | null {
|
||||
return resolveRouteTargetForLoadedChannel(params);
|
||||
}
|
||||
|
||||
/** @deprecated Use `messaging.resolveOutboundSessionRoute` for provider-specific target grammar. */
|
||||
export function resolveComparableTargetForLoadedChannel(params: {
|
||||
channel: string;
|
||||
rawTarget?: string | null;
|
||||
|
||||
@@ -7,12 +7,10 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import {
|
||||
comparableChannelTargetsMatch,
|
||||
parseExplicitTargetForChannel,
|
||||
parseExplicitTargetForLoadedChannel,
|
||||
resolveComparableTargetForChannel,
|
||||
resolveRouteTargetForChannel,
|
||||
resolveComparableTargetForLoadedChannel,
|
||||
resolveRouteTargetForLoadedChannel,
|
||||
} from "./target-parsing.js";
|
||||
} from "./target-parsing-loaded.js";
|
||||
|
||||
function parseThreadedTargetForTest(raw: string): {
|
||||
to: string;
|
||||
@@ -99,27 +97,27 @@ function setMinimalTargetParsingRegistry(): void {
|
||||
);
|
||||
}
|
||||
|
||||
describe("parseExplicitTargetForChannel", () => {
|
||||
describe("parseExplicitTargetForLoadedChannel", () => {
|
||||
beforeEach(() => {
|
||||
setMinimalTargetParsingRegistry();
|
||||
});
|
||||
|
||||
it("parses threaded targets via the registered channel plugin contract", () => {
|
||||
expect(
|
||||
parseExplicitTargetForChannel("mock-threaded", "threaded:group:room-a:topic:77"),
|
||||
parseExplicitTargetForLoadedChannel("mock-threaded", "threaded:group:room-a:topic:77"),
|
||||
).toEqual({
|
||||
to: "room-a",
|
||||
threadId: 77,
|
||||
chatType: "group",
|
||||
});
|
||||
expect(parseExplicitTargetForChannel("mock-threaded", "room-a")).toEqual({
|
||||
expect(parseExplicitTargetForLoadedChannel("mock-threaded", "room-a")).toEqual({
|
||||
to: "room-a",
|
||||
chatType: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses registered non-bundled channel targets via the active plugin contract", () => {
|
||||
expect(parseExplicitTargetForChannel("demo-target", "team-room")).toEqual({
|
||||
expect(parseExplicitTargetForLoadedChannel("demo-target", "team-room")).toEqual({
|
||||
to: "TEAM-ROOM",
|
||||
chatType: "direct",
|
||||
});
|
||||
@@ -131,7 +129,7 @@ describe("parseExplicitTargetForChannel", () => {
|
||||
|
||||
it("builds route targets from plugin-owned grammar", () => {
|
||||
expect(
|
||||
resolveRouteTargetForChannel({
|
||||
resolveRouteTargetForLoadedChannel({
|
||||
channel: "mock-threaded",
|
||||
rawTarget: "threaded:group:room-a:topic:77",
|
||||
}),
|
||||
@@ -157,11 +155,11 @@ describe("parseExplicitTargetForChannel", () => {
|
||||
});
|
||||
|
||||
it("matches route targets when only the plugin grammar differs", () => {
|
||||
const topicTarget = resolveRouteTargetForChannel({
|
||||
const topicTarget = resolveRouteTargetForLoadedChannel({
|
||||
channel: "mock-threaded",
|
||||
rawTarget: "threaded:room-a:topic:77",
|
||||
});
|
||||
const bareTarget = resolveRouteTargetForChannel({
|
||||
const bareTarget = resolveRouteTargetForLoadedChannel({
|
||||
channel: "mock-threaded",
|
||||
rawTarget: "room-a",
|
||||
});
|
||||
@@ -181,11 +179,11 @@ describe("parseExplicitTargetForChannel", () => {
|
||||
});
|
||||
|
||||
it("compares numeric and string thread ids through the shared route contract", () => {
|
||||
const numericThread = resolveRouteTargetForChannel({
|
||||
const numericThread = resolveRouteTargetForLoadedChannel({
|
||||
channel: "mock-threaded",
|
||||
rawTarget: "threaded:room-a:topic:77",
|
||||
});
|
||||
const stringThread = resolveRouteTargetForChannel({
|
||||
const stringThread = resolveRouteTargetForLoadedChannel({
|
||||
channel: "mock-threaded",
|
||||
rawTarget: "room-a",
|
||||
fallbackThreadId: "77",
|
||||
@@ -200,11 +198,11 @@ describe("parseExplicitTargetForChannel", () => {
|
||||
});
|
||||
|
||||
it("keeps deprecated comparable target helpers as route wrappers", () => {
|
||||
const numericThread = resolveComparableTargetForChannel({
|
||||
const numericThread = resolveComparableTargetForLoadedChannel({
|
||||
channel: "mock-threaded",
|
||||
rawTarget: "threaded:room-a:topic:77",
|
||||
});
|
||||
const stringThread = resolveRouteTargetForChannel({
|
||||
const stringThread = resolveRouteTargetForLoadedChannel({
|
||||
channel: "mock-threaded",
|
||||
rawTarget: "room-a",
|
||||
fallbackThreadId: "77",
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { resolveChannelRouteTargetWithParser } from "../../plugin-sdk/channel-route.js";
|
||||
import { normalizeChatChannelId } from "../registry.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "./index.js";
|
||||
import type {
|
||||
ChannelRouteParsedTarget,
|
||||
ParsedChannelExplicitTarget,
|
||||
} from "./target-parsing-loaded.js";
|
||||
export {
|
||||
comparableChannelTargetsMatch,
|
||||
comparableChannelTargetsShareRoute,
|
||||
parseExplicitTargetForLoadedChannel,
|
||||
resolveComparableTargetForLoadedChannel,
|
||||
resolveRouteTargetForLoadedChannel,
|
||||
} from "./target-parsing-loaded.js";
|
||||
export type {
|
||||
ComparableChannelTarget,
|
||||
ChannelRouteParsedTarget,
|
||||
ParsedChannelExplicitTarget,
|
||||
} from "./target-parsing-loaded.js";
|
||||
|
||||
function parseWithPlugin(
|
||||
getPlugin: (channel: string) => ReturnType<typeof getChannelPlugin>,
|
||||
rawChannel: string,
|
||||
rawTarget: string,
|
||||
): ParsedChannelExplicitTarget | null {
|
||||
const channel = normalizeChatChannelId(rawChannel) ?? normalizeChannelId(rawChannel);
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
return getPlugin(channel)?.messaging?.parseExplicitTarget?.({ raw: rawTarget }) ?? null;
|
||||
}
|
||||
|
||||
export function parseExplicitTargetForChannel(
|
||||
channel: string,
|
||||
rawTarget: string,
|
||||
): ParsedChannelExplicitTarget | null {
|
||||
return parseWithPlugin(getChannelPlugin, channel, rawTarget);
|
||||
}
|
||||
|
||||
export function resolveRouteTargetForChannel(params: {
|
||||
channel: string;
|
||||
rawTarget?: string | null;
|
||||
fallbackThreadId?: string | number | null;
|
||||
}): ChannelRouteParsedTarget | null {
|
||||
return resolveChannelRouteTargetWithParser({
|
||||
...params,
|
||||
parseExplicitTarget: parseExplicitTargetForChannel,
|
||||
});
|
||||
}
|
||||
|
||||
/** @deprecated Use `resolveRouteTargetForChannel`. */
|
||||
export function resolveComparableTargetForChannel(params: {
|
||||
channel: string;
|
||||
rawTarget?: string | null;
|
||||
fallbackThreadId?: string | number | null;
|
||||
}): ChannelRouteParsedTarget | null {
|
||||
return resolveRouteTargetForChannel(params);
|
||||
}
|
||||
@@ -561,6 +561,11 @@ export type ChannelMessagingAdapter = {
|
||||
id: string;
|
||||
threadId?: string | null;
|
||||
}) => string | undefined;
|
||||
/**
|
||||
* @deprecated Use `targetResolver` for target id normalization and
|
||||
* `resolveOutboundSessionRoute` for session/thread identity. This remains for
|
||||
* compatibility with older route parsing helpers.
|
||||
*/
|
||||
parseExplicitTarget?: (params: { raw: string }) => {
|
||||
to: string;
|
||||
threadId?: string | number;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from "../channels/plugins/types.adapters.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { resolveOutboundSendDep } from "../infra/outbound/send-deps.js";
|
||||
import { buildChannelOutboundSessionRoute } from "../plugin-sdk/core.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
|
||||
@@ -164,13 +165,47 @@ export function setupIsolatedAgentTurnMocks(params?: { fast?: boolean }): void {
|
||||
id: "telegram",
|
||||
outbound: telegramOutboundForTest,
|
||||
messaging: {
|
||||
parseExplicitTarget: ({ raw }) => {
|
||||
const target = parseTelegramTargetForTest(raw);
|
||||
return {
|
||||
to: target.chatId,
|
||||
threadId: target.messageThreadId,
|
||||
chatType: target.chatType === "unknown" ? undefined : target.chatType,
|
||||
};
|
||||
inferTargetChatType: ({ to }) => {
|
||||
const target = parseTelegramTargetForTest(to);
|
||||
return target.chatType === "unknown" ? undefined : target.chatType;
|
||||
},
|
||||
targetResolver: {
|
||||
resolveTarget: async ({ input }) => {
|
||||
const parsed = parseTelegramTargetForTest(input);
|
||||
if (!parsed.chatId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to:
|
||||
parsed.messageThreadId == null
|
||||
? parsed.chatId
|
||||
: `${parsed.chatId}:topic:${parsed.messageThreadId}`,
|
||||
kind: parsed.chatType === "direct" ? "user" : "group",
|
||||
source: "normalized",
|
||||
};
|
||||
},
|
||||
},
|
||||
resolveOutboundSessionRoute: ({ cfg, agentId, accountId, target, threadId }) => {
|
||||
const parsed = parseTelegramTargetForTest(target);
|
||||
const resolvedThreadId = parsed.messageThreadId ?? threadId ?? undefined;
|
||||
const chatType = parsed.chatType === "direct" ? "direct" : "group";
|
||||
return buildChannelOutboundSessionRoute({
|
||||
cfg,
|
||||
agentId,
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
peer: {
|
||||
kind: chatType,
|
||||
id:
|
||||
chatType === "group" && resolvedThreadId !== undefined
|
||||
? `${parsed.chatId}:topic:${resolvedThreadId}`
|
||||
: parsed.chatId,
|
||||
},
|
||||
chatType,
|
||||
from: `telegram:${parsed.chatId}`,
|
||||
to: parsed.chatId,
|
||||
...(resolvedThreadId !== undefined ? { threadId: resolvedThreadId } : {}),
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,20 +1,72 @@
|
||||
import type { ParsedChannelExplicitTarget } from "../../channels/plugins/target-parsing-loaded.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.public.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { resolveOutboundChannelPlugin } from "../../infra/outbound/channel-resolution.js";
|
||||
import {
|
||||
resolveOutboundSessionRoute,
|
||||
type OutboundSessionRoute,
|
||||
} from "../../infra/outbound/outbound-session.js";
|
||||
import {
|
||||
resolveChannelTarget,
|
||||
type ResolvedMessagingTarget,
|
||||
} from "../../infra/outbound/target-resolver.js";
|
||||
export { getLoadedChannelPluginForRead } from "../../channels/plugins/registry-loaded-read.js";
|
||||
export { mapAllowFromEntries } from "../../plugin-sdk/channel-config-helpers.js";
|
||||
export { resolveFirstBoundAccountId } from "../../routing/bound-account-read.js";
|
||||
|
||||
export function parseExplicitTargetForDelivery(params: {
|
||||
export async function resolveChannelTargetForDelivery(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel: string;
|
||||
rawTarget: string;
|
||||
}): ParsedChannelExplicitTarget | null {
|
||||
return (
|
||||
channel: ChannelId;
|
||||
input: string;
|
||||
accountId?: string | null;
|
||||
}): Promise<{ ok: true; target: ResolvedMessagingTarget } | { ok: false; error: Error }> {
|
||||
resolveOutboundChannelPlugin({
|
||||
channel: params.channel,
|
||||
cfg: params.cfg,
|
||||
allowBootstrap: true,
|
||||
});
|
||||
try {
|
||||
return await resolveChannelTarget({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
input: params.input,
|
||||
accountId: params.accountId,
|
||||
unknownTargetMode: "normalized",
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveOutboundSessionRouteForDelivery(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel: ChannelId;
|
||||
agentId: string;
|
||||
accountId?: string | null;
|
||||
target: string;
|
||||
resolvedTarget?: ResolvedMessagingTarget;
|
||||
threadId?: string | number | null;
|
||||
currentSessionKey?: string;
|
||||
}): Promise<OutboundSessionRoute | null> {
|
||||
resolveOutboundChannelPlugin({
|
||||
channel: params.channel,
|
||||
cfg: params.cfg,
|
||||
allowBootstrap: true,
|
||||
});
|
||||
return await resolveOutboundSessionRoute(params);
|
||||
}
|
||||
|
||||
export function channelCanResolveOutboundSessionRoute(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel: ChannelId;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
resolveOutboundChannelPlugin({
|
||||
channel: params.channel,
|
||||
cfg: params.cfg,
|
||||
allowBootstrap: true,
|
||||
})?.messaging?.parseExplicitTarget?.({ raw: params.rawTarget }) ?? null
|
||||
})?.messaging?.resolveOutboundSessionRoute,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
forumMessagingForTest,
|
||||
parseTelegramTargetForTest,
|
||||
telegramMessagingForTest,
|
||||
} from "../../infra/outbound/targets.test-helpers.js";
|
||||
import { buildChannelOutboundSessionRoute } from "../../plugin-sdk/core.js";
|
||||
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
|
||||
@@ -101,14 +103,14 @@ function createAllowlistAwareStubOutbound(label: string): ChannelOutboundAdapter
|
||||
}
|
||||
|
||||
const normalizeTelegramTargetForDeliveryTest = vi.fn((raw: string): string | undefined => {
|
||||
const target = telegramMessagingForTest.parseExplicitTarget?.({ raw });
|
||||
if (!target?.to) {
|
||||
const target = parseTelegramTargetForTest(raw);
|
||||
if (!target.chatId) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedTo = target.to.toLowerCase();
|
||||
return target.threadId == null
|
||||
const normalizedTo = target.chatId.toLowerCase();
|
||||
return target.messageThreadId == null
|
||||
? `telegram:${normalizedTo}`
|
||||
: `telegram:${normalizedTo}:topic:${target.threadId}`;
|
||||
: `telegram:${normalizedTo}:topic:${target.messageThreadId}`;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -144,6 +146,40 @@ beforeEach(() => {
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "signal",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "signal",
|
||||
outbound: createStubOutbound("Signal"),
|
||||
messaging: {
|
||||
targetPrefixes: ["signal"],
|
||||
inferTargetChatType: ({ to }) =>
|
||||
to
|
||||
.replace(/^signal:/i, "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.startsWith("group:")
|
||||
? "group"
|
||||
: "direct",
|
||||
resolveOutboundSessionRoute: ({ cfg, agentId, accountId, target }) => {
|
||||
const stripped = target.replace(/^signal:/i, "").trim();
|
||||
const isGroup = stripped.toLowerCase().startsWith("group:");
|
||||
const peerId = isGroup ? stripped.slice("group:".length).trim() : stripped;
|
||||
return buildChannelOutboundSessionRoute({
|
||||
cfg,
|
||||
agentId,
|
||||
channel: "signal",
|
||||
accountId,
|
||||
peer: { kind: isGroup ? "group" : "direct", id: peerId },
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
from: isGroup ? `group:${peerId}` : `signal:${peerId}`,
|
||||
to: isGroup ? `group:${peerId}` : `signal:${peerId}`,
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "alpha",
|
||||
plugin: {
|
||||
@@ -430,7 +466,313 @@ describe("resolveDeliveryTarget", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("skips id-like target normalization for dry-run delivery previews", async () => {
|
||||
it("fails ambiguous directory targets instead of picking a best match", async () => {
|
||||
setMainSessionEntry(undefined);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "alpha",
|
||||
source: "test",
|
||||
plugin: {
|
||||
...createOutboundTestPlugin({
|
||||
id: "alpha",
|
||||
outbound: createStubOutbound("Alpha"),
|
||||
messaging: { targetPrefixes: ["alpha"] },
|
||||
}),
|
||||
directory: {
|
||||
listGroups: async () => [
|
||||
{ id: "channel:ops-a", name: "ops", rank: 1 },
|
||||
{ id: "channel:ops-b", name: "ops", rank: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
|
||||
channel: "alpha",
|
||||
to: "ops",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("expected ambiguous target error");
|
||||
}
|
||||
expect(result.error.message).toContain("Ambiguous");
|
||||
});
|
||||
|
||||
it("surfaces target resolver exceptions instead of treating raw names as resolved", async () => {
|
||||
setMainSessionEntry(undefined);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "alpha",
|
||||
source: "test",
|
||||
plugin: {
|
||||
...createOutboundTestPlugin({
|
||||
id: "alpha",
|
||||
outbound: createStubOutbound("Alpha"),
|
||||
messaging: { targetPrefixes: ["alpha"] },
|
||||
}),
|
||||
directory: {
|
||||
listGroups: async () => {
|
||||
throw new Error("directory auth failed");
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
|
||||
channel: "alpha",
|
||||
to: "ops",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("expected target resolver error");
|
||||
}
|
||||
expect(result.error.message).toContain("directory auth failed");
|
||||
});
|
||||
|
||||
it("keeps parser-derived explicit thread ids for parser-only cron targets", async () => {
|
||||
setMainSessionEntry(undefined);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "alpha",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "alpha",
|
||||
outbound: createStubOutbound("Alpha"),
|
||||
messaging: {
|
||||
targetPrefixes: ["alpha"],
|
||||
parseExplicitTarget: ({ raw }) =>
|
||||
raw === "alpha:room-a:topic:77"
|
||||
? { to: "room-a", threadId: 77, chatType: "group" as const }
|
||||
: null,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
|
||||
channel: "alpha",
|
||||
to: "alpha:room-a:topic:77",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.to).toBe("room-a");
|
||||
expect(result.threadId).toBe(77);
|
||||
});
|
||||
|
||||
it("does not treat parser-only target normalization as a parser thread id", async () => {
|
||||
setLastSessionEntry({
|
||||
sessionId: "sess-parser-stale-thread",
|
||||
lastChannel: "alpha",
|
||||
lastTo: "room-a",
|
||||
lastThreadId: "stale-thread",
|
||||
});
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "alpha",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "alpha",
|
||||
outbound: createStubOutbound("Alpha"),
|
||||
messaging: {
|
||||
targetPrefixes: ["alpha"],
|
||||
parseExplicitTarget: ({ raw }) =>
|
||||
raw === "alpha:room-b" ? { to: "room-b", chatType: "group" as const } : null,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
|
||||
channel: "alpha",
|
||||
to: "alpha:room-b",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.to).toBe("room-b");
|
||||
expect(result.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses canonical route targets even when the route has no thread", async () => {
|
||||
setMainSessionEntry(undefined);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "alpha",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "alpha",
|
||||
outbound: createStubOutbound("Alpha"),
|
||||
messaging: {
|
||||
targetPrefixes: ["alpha"],
|
||||
inferTargetChatType: ({ to }) => (to.startsWith("group:") ? "group" : "direct"),
|
||||
resolveOutboundSessionRoute: ({ cfg, agentId, accountId, target }) => {
|
||||
const stripped = target.replace(/^alpha:/i, "");
|
||||
return buildChannelOutboundSessionRoute({
|
||||
cfg,
|
||||
agentId,
|
||||
channel: "alpha",
|
||||
accountId,
|
||||
peer: { kind: "group", id: stripped.replace(/^group:/i, "") },
|
||||
chatType: "group",
|
||||
from: `alpha:${stripped}`,
|
||||
to: stripped.replace(/^group:/i, ""),
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
|
||||
channel: "alpha",
|
||||
to: "alpha:group:room-a",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.to).toBe("room-a");
|
||||
expect(result.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps provider-qualified normalized targets for provider route parsing", async () => {
|
||||
setMainSessionEntry(undefined);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "telegram",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "telegram",
|
||||
outbound: createStubOutbound("Telegram"),
|
||||
messaging: {
|
||||
targetPrefixes: ["telegram"],
|
||||
normalizeTarget: () => "telegram:group:-100200300:topic:77",
|
||||
resolveOutboundSessionRoute: ({ cfg, agentId, accountId, target }) => {
|
||||
const match = /^telegram:group:(-?\d+):topic:(\d+)$/i.exec(target);
|
||||
const chatId = match?.[1] ?? target;
|
||||
const threadId = match?.[2] ? Number.parseInt(match[2], 10) : undefined;
|
||||
return buildChannelOutboundSessionRoute({
|
||||
cfg,
|
||||
agentId,
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
peer: { kind: "group", id: chatId },
|
||||
chatType: "group",
|
||||
from: `telegram:group:${chatId}`,
|
||||
to: chatId,
|
||||
...(threadId != null ? { threadId } : {}),
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
|
||||
channel: "telegram",
|
||||
to: "telegram:group:-100200300:topic:77",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.to).toBe("-100200300");
|
||||
expect(result.threadId).toBe(77);
|
||||
});
|
||||
|
||||
it("ignores stale previous-route parse failures for explicit cron targets", async () => {
|
||||
setLastSessionEntry({
|
||||
sessionId: "sess-stale-route",
|
||||
lastChannel: "alpha",
|
||||
lastTo: "bad:stored:target",
|
||||
lastThreadId: "old-thread",
|
||||
});
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "alpha",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "alpha",
|
||||
outbound: createStubOutbound("Alpha"),
|
||||
messaging: {
|
||||
targetPrefixes: ["alpha"],
|
||||
resolveOutboundSessionRoute: ({ cfg, agentId, accountId, target }) => {
|
||||
if (target === "bad:stored:target") {
|
||||
throw new Error("stale route parse failed");
|
||||
}
|
||||
const stripped = target.replace(/^alpha:/i, "");
|
||||
return buildChannelOutboundSessionRoute({
|
||||
cfg,
|
||||
agentId,
|
||||
channel: "alpha",
|
||||
accountId,
|
||||
peer: { kind: "group", id: stripped },
|
||||
chatType: "group",
|
||||
from: `alpha:group:${stripped}`,
|
||||
to: stripped,
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
|
||||
channel: "alpha",
|
||||
to: "alpha:room-a",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.to).toBe("room-a");
|
||||
expect(result.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps cron route canonicalization best-effort when explicit route resolution fails", async () => {
|
||||
setMainSessionEntry(undefined);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "alpha",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "alpha",
|
||||
outbound: createStubOutbound("Alpha"),
|
||||
messaging: {
|
||||
targetPrefixes: ["alpha"],
|
||||
inferTargetChatType: () => "group",
|
||||
resolveOutboundSessionRoute: () => {
|
||||
throw new Error("route lookup failed");
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
|
||||
channel: "alpha",
|
||||
to: "room-a",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.to).toBe("room-a");
|
||||
expect(result.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses target resolution for dry-run delivery previews", async () => {
|
||||
setMainSessionEntry(undefined);
|
||||
vi.mocked(maybeResolveIdLikeTarget).mockClear();
|
||||
|
||||
@@ -446,7 +788,12 @@ describe("resolveDeliveryTarget", () => {
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.to).toBe("123456789");
|
||||
expect(maybeResolveIdLikeTarget).not.toHaveBeenCalled();
|
||||
expect(maybeResolveIdLikeTarget).toHaveBeenCalledWith({
|
||||
cfg: makeCfg({ bindings: [] }),
|
||||
channel: "forum",
|
||||
input: "123456789",
|
||||
accountId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the runtime target resolver when the channel plugin is not already loaded", async () => {
|
||||
@@ -625,7 +972,7 @@ describe("resolveDeliveryTarget", () => {
|
||||
expect(result.threadId).toBe("thread-2");
|
||||
});
|
||||
|
||||
it("keeps a session Telegram topic threadId when a bare explicit target matches the topic route", async () => {
|
||||
it("does not carry a Telegram topic threadId to a bare explicit group target", async () => {
|
||||
setLastSessionEntry({
|
||||
sessionId: "sess-telegram-topic",
|
||||
lastChannel: "telegram",
|
||||
@@ -641,12 +988,11 @@ describe("resolveDeliveryTarget", () => {
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.to).toBe("-100200300");
|
||||
expect(result.threadId).toBe(77);
|
||||
expect(result.threadId).toBeUndefined();
|
||||
expect(normalizeTelegramTargetForDeliveryTest).toHaveBeenCalledWith("-100200300");
|
||||
expect(normalizeTelegramTargetForDeliveryTest).toHaveBeenCalledWith("-100200300:topic:77");
|
||||
});
|
||||
|
||||
it("drops carried threadId instead of throwing when target normalization fails", async () => {
|
||||
it("surfaces target normalization failures instead of using a raw fallback", async () => {
|
||||
setLastSessionEntry({
|
||||
sessionId: "sess-telegram-topic-invalid",
|
||||
lastChannel: "telegram",
|
||||
@@ -662,9 +1008,11 @@ describe("resolveDeliveryTarget", () => {
|
||||
to: "-100200300",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.to).toBe("-100200300");
|
||||
expect(result.threadId).toBeUndefined();
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("expected target normalization error");
|
||||
}
|
||||
expect(result.error.message).toContain("target normalizer exploded");
|
||||
});
|
||||
|
||||
it("drops a session Telegram topic threadId when a bare explicit target names a different chat", async () => {
|
||||
@@ -711,6 +1059,22 @@ describe("resolveDeliveryTarget", () => {
|
||||
expect(result.to).toBe("1234567890");
|
||||
});
|
||||
|
||||
it("rejects provider-prefixed explicit targets without a recipient", async () => {
|
||||
setMainSessionEntry(undefined);
|
||||
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
|
||||
channel: "last",
|
||||
to: "telegram:",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.channel).toBe("telegram");
|
||||
expect(result.to).toBeUndefined();
|
||||
if (result.ok) {
|
||||
throw new Error("expected missing target error");
|
||||
}
|
||||
expect(result.error.message).toContain("Target is required");
|
||||
});
|
||||
|
||||
it("returns an error when channel selection is ambiguous", async () => {
|
||||
setMainSessionEntry(undefined);
|
||||
vi.mocked(resolveMessageChannelSelection).mockRejectedValueOnce(
|
||||
@@ -872,6 +1236,20 @@ describe("resolveDeliveryTarget", () => {
|
||||
expect(result.threadId).toBe(1008013);
|
||||
});
|
||||
|
||||
it("keeps semantic group prefixes for provider route resolution", async () => {
|
||||
setMainSessionEntry(undefined);
|
||||
|
||||
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
|
||||
channel: "signal",
|
||||
to: "signal:group:ops",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.channel).toBe("signal");
|
||||
expect(result.to).toBe("group:ops");
|
||||
expect(result.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps explicit delivery threadId on first run without session history", async () => {
|
||||
setMainSessionEntry(undefined);
|
||||
|
||||
@@ -930,6 +1308,42 @@ describe("resolveDeliveryTarget", () => {
|
||||
expect(result.threadId).toBe(77);
|
||||
});
|
||||
|
||||
it("resolves plugin default targets through the modern target route", async () => {
|
||||
setMainSessionEntry(undefined);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "telegram",
|
||||
plugin: {
|
||||
...createOutboundTestPlugin({
|
||||
id: "telegram",
|
||||
outbound: createStubOutbound("Telegram"),
|
||||
messaging: {
|
||||
...telegramMessagingForTest,
|
||||
normalizeTarget: normalizeTelegramTargetForDeliveryTest,
|
||||
},
|
||||
}),
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({}),
|
||||
resolveDefaultTo: () => "-100200300:77",
|
||||
},
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
|
||||
channel: "telegram",
|
||||
to: undefined,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.to).toBe("-100200300");
|
||||
expect(result.threadId).toBe(77);
|
||||
});
|
||||
|
||||
it("prefers explicit telegram :topic: targets over session-derived threadId", async () => {
|
||||
setLastSessionEntry({
|
||||
sessionId: "sess-telegram-topic",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveExplicitDeliveryTargetCompat } from "../../channels/plugins/target-parsing-loaded.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.public.js";
|
||||
import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js";
|
||||
import { resolveStorePath } from "../../config/sessions/paths.js";
|
||||
@@ -5,16 +6,15 @@ import { readSessionEntry } from "../../config/sessions/store-load.js";
|
||||
import type { SessionEntry } from "../../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-id-resolution.js";
|
||||
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
|
||||
import { stripTargetProviderPrefix } from "../../infra/outbound/channel-target-prefix.js";
|
||||
import type { OutboundSessionRoute } from "../../infra/outbound/outbound-session.js";
|
||||
import type { ResolvedMessagingTarget } from "../../infra/outbound/target-resolver.js";
|
||||
import { tryResolveLoadedOutboundTarget } from "../../infra/outbound/targets-loaded.js";
|
||||
import {
|
||||
resolveSessionDeliveryTarget,
|
||||
type ExplicitTargetParser,
|
||||
} from "../../infra/outbound/targets-session.js";
|
||||
import { resolveSessionDeliveryTarget } from "../../infra/outbound/targets-session.js";
|
||||
import type { OutboundChannel } from "../../infra/outbound/targets.js";
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
|
||||
import { normalizeOptionalThreadValue } from "../../shared/string-coerce.js";
|
||||
import { resolveCronStoredDeliveryContext } from "../delivery-context.js";
|
||||
import { resolveCronAgentSessionKey } from "./session-key.js";
|
||||
|
||||
@@ -63,53 +63,6 @@ async function resolveOutboundTargetWithRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTargetForThreadCarry(
|
||||
channel: Exclude<OutboundChannel, "none"> | undefined,
|
||||
to: string | undefined,
|
||||
parseExplicitTarget: ExplicitTargetParser,
|
||||
): string | undefined {
|
||||
if (!channel || !to) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const normalized = normalizeTargetForProvider(channel, to);
|
||||
const comparable = normalized ?? to.trim();
|
||||
if (!comparable) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = parseExplicitTarget(channel, comparable);
|
||||
const base = parsed?.to ?? comparable;
|
||||
return normalizeTargetForProvider(channel, base) ?? base;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function deliveryTargetsShareThreadRoute(params: {
|
||||
channel: Exclude<OutboundChannel, "none"> | undefined;
|
||||
to: string | undefined;
|
||||
lastTo: string | undefined;
|
||||
parseExplicitTarget: ExplicitTargetParser;
|
||||
}): boolean {
|
||||
if (!params.to || !params.lastTo) {
|
||||
return false;
|
||||
}
|
||||
if (params.to === params.lastTo) {
|
||||
return true;
|
||||
}
|
||||
const normalizedTo = normalizeTargetForThreadCarry(
|
||||
params.channel,
|
||||
params.to,
|
||||
params.parseExplicitTarget,
|
||||
);
|
||||
const normalizedLastTo = normalizeTargetForThreadCarry(
|
||||
params.channel,
|
||||
params.lastTo,
|
||||
params.parseExplicitTarget,
|
||||
);
|
||||
return Boolean(normalizedTo && normalizedLastTo && normalizedTo === normalizedLastTo);
|
||||
}
|
||||
|
||||
const channelSelectionRuntimeLoader = createLazyImportLoader(
|
||||
() => import("../../infra/outbound/channel-selection.runtime.js"),
|
||||
);
|
||||
@@ -124,6 +77,50 @@ async function loadChannelSelectionRuntime() {
|
||||
async function loadDeliveryTargetRuntime() {
|
||||
return await deliveryTargetRuntimeLoader.load();
|
||||
}
|
||||
|
||||
function isNonEmptyThreadId(value: string | number | undefined | null): value is string | number {
|
||||
return value != null && value !== "";
|
||||
}
|
||||
|
||||
function routesSharePeer(left?: OutboundSessionRoute | null, right?: OutboundSessionRoute | null) {
|
||||
return Boolean(
|
||||
left &&
|
||||
right &&
|
||||
left.baseSessionKey === right.baseSessionKey &&
|
||||
left.peer.kind === right.peer.kind &&
|
||||
left.peer.id === right.peer.id,
|
||||
);
|
||||
}
|
||||
|
||||
function shouldCarrySessionThread(params: {
|
||||
resolved: ReturnType<typeof resolveSessionDeliveryTarget>;
|
||||
explicitTo?: string;
|
||||
route?: OutboundSessionRoute | null;
|
||||
lastRoute?: OutboundSessionRoute | null;
|
||||
}) {
|
||||
if (!isNonEmptyThreadId(params.resolved.threadId)) {
|
||||
return false;
|
||||
}
|
||||
if (!params.explicitTo) {
|
||||
return (
|
||||
params.resolved.channel === params.resolved.lastChannel &&
|
||||
params.resolved.to === params.resolved.lastTo
|
||||
);
|
||||
}
|
||||
return routesSharePeer(params.route, params.lastRoute);
|
||||
}
|
||||
|
||||
function stripSelectedProviderPrefix(params: {
|
||||
channel: Exclude<OutboundChannel, "none">;
|
||||
to?: string;
|
||||
}): string | undefined {
|
||||
const trimmed = params.to?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const stripped = stripTargetProviderPrefix(trimmed, params.channel).trim();
|
||||
return stripped || undefined;
|
||||
}
|
||||
export async function resolveDeliveryTarget(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
@@ -141,12 +138,6 @@ export async function resolveDeliveryTarget(
|
||||
const explicitTo = typeof jobPayload.to === "string" ? jobPayload.to : undefined;
|
||||
const allowMismatchedLastTo = requestedChannel === "last";
|
||||
const deliveryTargetRuntime = await loadDeliveryTargetRuntime();
|
||||
const parseExplicitTarget: ExplicitTargetParser = (channel, rawTarget) =>
|
||||
deliveryTargetRuntime.parseExplicitTargetForDelivery({
|
||||
cfg,
|
||||
channel,
|
||||
rawTarget,
|
||||
});
|
||||
|
||||
const sessionCfg = cfg.session;
|
||||
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
|
||||
@@ -186,7 +177,6 @@ export async function resolveDeliveryTarget(
|
||||
explicitTo,
|
||||
explicitThreadId: jobPayload.threadId,
|
||||
allowMismatchedLastTo,
|
||||
parseExplicitTarget,
|
||||
});
|
||||
|
||||
let fallbackChannel: Exclude<OutboundChannel, "none"> | undefined;
|
||||
@@ -217,7 +207,6 @@ export async function resolveDeliveryTarget(
|
||||
fallbackChannel,
|
||||
allowMismatchedLastTo,
|
||||
mode: preliminary.mode,
|
||||
parseExplicitTarget,
|
||||
})
|
||||
: preliminary;
|
||||
|
||||
@@ -246,29 +235,13 @@ export async function resolveDeliveryTarget(
|
||||
accountId = jobPayload.accountId;
|
||||
}
|
||||
|
||||
// Carry threadId when it was explicitly set (from :topic: parsing or config)
|
||||
// or when delivering to the same recipient as the session's last conversation.
|
||||
// Session-derived threadIds are dropped when the target differs to prevent
|
||||
// stale thread IDs from leaking to a different chat.
|
||||
let threadId =
|
||||
resolved.threadId &&
|
||||
(resolved.threadIdExplicit ||
|
||||
deliveryTargetsShareThreadRoute({
|
||||
channel,
|
||||
to: resolved.to,
|
||||
lastTo: resolved.lastTo,
|
||||
parseExplicitTarget,
|
||||
}))
|
||||
? resolved.threadId
|
||||
: undefined;
|
||||
|
||||
if (!channel) {
|
||||
return {
|
||||
ok: false,
|
||||
channel: undefined,
|
||||
to: undefined,
|
||||
accountId,
|
||||
threadId,
|
||||
threadId: undefined,
|
||||
mode,
|
||||
error:
|
||||
channelResolutionError ??
|
||||
@@ -276,6 +249,10 @@ export async function resolveDeliveryTarget(
|
||||
};
|
||||
}
|
||||
|
||||
const explicitThreadId = isNonEmptyThreadId(jobPayload.threadId)
|
||||
? jobPayload.threadId
|
||||
: undefined;
|
||||
|
||||
let effectiveAllowFrom: string[] | undefined;
|
||||
if (mode === "implicit") {
|
||||
const { getLoadedChannelPluginForRead, mapAllowFromEntries } = deliveryTargetRuntime;
|
||||
@@ -306,6 +283,7 @@ export async function resolveDeliveryTarget(
|
||||
}
|
||||
}
|
||||
|
||||
const preResolvedRouteTargetCandidate = toCandidate;
|
||||
const docked = await resolveOutboundTargetWithRuntime({
|
||||
channel,
|
||||
to: toCandidate,
|
||||
@@ -320,31 +298,148 @@ export async function resolveDeliveryTarget(
|
||||
channel,
|
||||
to: undefined,
|
||||
accountId,
|
||||
threadId,
|
||||
threadId: explicitThreadId,
|
||||
mode,
|
||||
error: docked.error,
|
||||
};
|
||||
}
|
||||
toCandidate = docked.to;
|
||||
|
||||
let resolvedTarget: ResolvedMessagingTarget | undefined;
|
||||
const targetResolution = await deliveryTargetRuntime.resolveChannelTargetForDelivery({
|
||||
cfg,
|
||||
channel,
|
||||
input: toCandidate,
|
||||
accountId,
|
||||
});
|
||||
if (!targetResolution.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
channel,
|
||||
to: undefined,
|
||||
accountId,
|
||||
threadId: explicitThreadId,
|
||||
mode,
|
||||
error: targetResolution.error,
|
||||
};
|
||||
}
|
||||
resolvedTarget = targetResolution.target;
|
||||
const routeTargetCandidate =
|
||||
resolvedTarget.source === "directory"
|
||||
? resolvedTarget.to
|
||||
: (preResolvedRouteTargetCandidate ?? toCandidate);
|
||||
const selectedTarget = stripSelectedProviderPrefix({
|
||||
channel,
|
||||
to: resolvedTarget.to,
|
||||
});
|
||||
if (!selectedTarget) {
|
||||
return {
|
||||
ok: false,
|
||||
channel,
|
||||
to: undefined,
|
||||
accountId,
|
||||
threadId: explicitThreadId,
|
||||
mode,
|
||||
error: new Error("Target is required"),
|
||||
};
|
||||
}
|
||||
toCandidate = selectedTarget;
|
||||
|
||||
const route = await (async () => {
|
||||
try {
|
||||
return await deliveryTargetRuntime.resolveOutboundSessionRouteForDelivery({
|
||||
cfg,
|
||||
channel,
|
||||
agentId,
|
||||
accountId,
|
||||
target: routeTargetCandidate,
|
||||
resolvedTarget,
|
||||
threadId: explicitThreadId,
|
||||
currentSessionKey: threadSessionKey ?? mainSessionKey,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const routeCanCanonicalizeTarget = deliveryTargetRuntime.channelCanResolveOutboundSessionRoute({
|
||||
cfg,
|
||||
channel,
|
||||
});
|
||||
const routeShouldCanonicalizeTarget =
|
||||
route && (route.threadId !== undefined || route.to !== routeTargetCandidate);
|
||||
if (route && routeCanCanonicalizeTarget && routeShouldCanonicalizeTarget) {
|
||||
const routeTo = stripSelectedProviderPrefix({
|
||||
channel,
|
||||
to: route.to,
|
||||
});
|
||||
if (!routeTo) {
|
||||
return {
|
||||
ok: false,
|
||||
channel,
|
||||
to: undefined,
|
||||
accountId,
|
||||
threadId: explicitThreadId,
|
||||
mode,
|
||||
error: new Error("Target is required"),
|
||||
};
|
||||
}
|
||||
toCandidate = routeTo;
|
||||
}
|
||||
const lastTo = resolved.lastTo;
|
||||
const lastRoute =
|
||||
lastTo && resolved.lastChannel === channel
|
||||
? await (async () => {
|
||||
try {
|
||||
return await deliveryTargetRuntime.resolveOutboundSessionRouteForDelivery({
|
||||
cfg,
|
||||
channel,
|
||||
agentId,
|
||||
accountId: resolved.lastAccountId ?? accountId,
|
||||
target: lastTo,
|
||||
threadId: resolved.lastThreadId,
|
||||
currentSessionKey: threadSessionKey ?? mainSessionKey,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
|
||||
const parserExplicitThreadId =
|
||||
explicitThreadId == null && explicitTo
|
||||
? normalizeOptionalThreadValue(
|
||||
resolveExplicitDeliveryTargetCompat({
|
||||
channel,
|
||||
rawTarget: explicitTo,
|
||||
})?.threadId,
|
||||
)
|
||||
: undefined;
|
||||
const threadId =
|
||||
explicitThreadId ??
|
||||
route?.threadId ??
|
||||
parserExplicitThreadId ??
|
||||
(shouldCarrySessionThread({
|
||||
resolved,
|
||||
explicitTo,
|
||||
route,
|
||||
lastRoute,
|
||||
})
|
||||
? resolved.threadId
|
||||
: undefined);
|
||||
if (options?.dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
channel,
|
||||
to: docked.to,
|
||||
to: toCandidate,
|
||||
accountId,
|
||||
threadId,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
const idLikeTarget = await maybeResolveIdLikeTarget({
|
||||
cfg,
|
||||
channel,
|
||||
input: docked.to,
|
||||
accountId,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
channel,
|
||||
to: idLikeTarget?.to ?? docked.to,
|
||||
to: toCandidate,
|
||||
accountId,
|
||||
threadId,
|
||||
mode,
|
||||
|
||||
@@ -56,7 +56,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
import { formatUncaughtError } from "../../infra/errors.js";
|
||||
import {
|
||||
resolveAgentDeliveryPlan,
|
||||
resolveAgentDeliveryPlanWithSessionRoute,
|
||||
resolveAgentOutboundTarget,
|
||||
} from "../../infra/outbound/agent-delivery.js";
|
||||
import { shouldDowngradeDeliveryToSessionOnly } from "../../infra/outbound/best-effort-delivery.js";
|
||||
@@ -1550,7 +1550,12 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
const turnSourceChannel = normalizeOptionalString(request.channel);
|
||||
const turnSourceTo = normalizeOptionalString(request.to);
|
||||
const turnSourceAccountId = normalizeOptionalString(request.accountId);
|
||||
const deliveryPlan = resolveAgentDeliveryPlan({
|
||||
const deliveryPlan = await resolveAgentDeliveryPlanWithSessionRoute({
|
||||
cfg: cfgForAgent ?? cfg,
|
||||
agentId: resolvedSessionKey
|
||||
? resolveAgentIdFromSessionKey(resolvedSessionKey)
|
||||
: (agentId ?? resolveDefaultAgentId(cfgForAgent ?? cfg)),
|
||||
currentSessionKey: resolvedSessionKey,
|
||||
sessionEntry,
|
||||
requestedChannel: request.replyChannel ?? request.channel,
|
||||
explicitTo,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "./heartbeat-runner.js";
|
||||
import {
|
||||
resolveHeartbeatDeliveryTarget,
|
||||
resolveHeartbeatDeliveryTargetWithSessionRoute,
|
||||
resolveHeartbeatSenderContext,
|
||||
} from "./outbound/targets.js";
|
||||
import { telegramMessagingForTest } from "./outbound/targets.test-helpers.js";
|
||||
@@ -581,8 +582,8 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
{ name: "topic suffix", to: "-100111:topic:42", expectedTo: "-100111", expectedThreadId: 42 },
|
||||
{ name: "plain chat id", to: "-100111", expectedTo: "-100111", expectedThreadId: undefined },
|
||||
])(
|
||||
"parses optional telegram :topic: threadId suffix: $name",
|
||||
({ to, expectedTo, expectedThreadId }) => {
|
||||
"parses optional telegram :topic: threadId suffix through session route: $name",
|
||||
async ({ to, expectedTo, expectedThreadId }) => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -590,7 +591,11 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry });
|
||||
const result = await resolveHeartbeatDeliveryTargetWithSessionRoute({
|
||||
cfg,
|
||||
agentId: "heartbeat-agent",
|
||||
entry: baseEntry,
|
||||
});
|
||||
expect(result.channel).toBe("telegram");
|
||||
expect(result.to).toBe(expectedTo);
|
||||
expect(result.threadId).toBe(expectedThreadId);
|
||||
|
||||
@@ -131,7 +131,7 @@ import {
|
||||
import type { OutboundSendDeps } from "./outbound/deliver.js";
|
||||
import { buildOutboundSessionContext } from "./outbound/session-context.js";
|
||||
import {
|
||||
resolveHeartbeatDeliveryTarget,
|
||||
resolveHeartbeatDeliveryTargetWithSessionRoute,
|
||||
resolveHeartbeatSenderContext,
|
||||
} from "./outbound/targets.js";
|
||||
import {
|
||||
@@ -1438,10 +1438,12 @@ export async function runHeartbeatOnce(opts: {
|
||||
const heartbeatForDelivery = commitmentDeliveryContext
|
||||
? { ...heartbeat, target: "last", to: undefined, accountId: undefined }
|
||||
: heartbeat;
|
||||
const delivery = resolveHeartbeatDeliveryTarget({
|
||||
const delivery = await resolveHeartbeatDeliveryTargetWithSessionRoute({
|
||||
cfg,
|
||||
agentId,
|
||||
entry,
|
||||
heartbeat: heartbeatForDelivery,
|
||||
currentSessionKey: sessionKey,
|
||||
// Isolated heartbeat runs drain system events from their dedicated
|
||||
// `:heartbeat` session, not from the base session we peek during preflight.
|
||||
// Reusing base-session turnSource routing here can pin later isolated runs
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveOutboundTarget: vi.fn(() => ({ ok: true as const, to: "+1999" })),
|
||||
resolveOutboundChannelPlugin: vi.fn<() => unknown>(() => null),
|
||||
resolveOutboundTarget: vi.fn<() => { ok: true; to: string } | { ok: false; error: Error }>(
|
||||
() => ({ ok: true, to: "+1999" }),
|
||||
),
|
||||
resolveOutboundSessionRoute: vi.fn<() => Promise<unknown>>(async () => null),
|
||||
resolveSessionDeliveryTarget: vi.fn(
|
||||
(params: {
|
||||
entry?: {
|
||||
@@ -53,7 +57,6 @@ const mocks = vi.hoisted(() => ({
|
||||
threadId:
|
||||
params.explicitThreadId ??
|
||||
(channel && channel === lastChannel ? lastThreadId : undefined),
|
||||
threadIdExplicit: params.explicitThreadId != null,
|
||||
mode,
|
||||
lastChannel,
|
||||
lastTo,
|
||||
@@ -69,6 +72,14 @@ vi.mock("./targets.js", () => ({
|
||||
resolveSessionDeliveryTarget: mocks.resolveSessionDeliveryTarget,
|
||||
}));
|
||||
|
||||
vi.mock("./channel-resolution.js", () => ({
|
||||
resolveOutboundChannelPlugin: mocks.resolveOutboundChannelPlugin,
|
||||
}));
|
||||
|
||||
vi.mock("./outbound-session.js", () => ({
|
||||
resolveOutboundSessionRoute: mocks.resolveOutboundSessionRoute,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/message-channel.js", () => ({
|
||||
INTERNAL_MESSAGE_CHANNEL: "webchat",
|
||||
isDeliverableMessageChannel: (channel: string) => ["directchat", "workspace"].includes(channel),
|
||||
@@ -79,14 +90,23 @@ vi.mock("../../utils/message-channel.js", () => ({
|
||||
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
let resolveAgentDeliveryPlan: typeof import("./agent-delivery.js").resolveAgentDeliveryPlan;
|
||||
let resolveAgentDeliveryPlanWithSessionRoute: typeof import("./agent-delivery.js").resolveAgentDeliveryPlanWithSessionRoute;
|
||||
let resolveAgentOutboundTarget: typeof import("./agent-delivery.js").resolveAgentOutboundTarget;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ resolveAgentDeliveryPlan, resolveAgentOutboundTarget } = await import("./agent-delivery.js"));
|
||||
({
|
||||
resolveAgentDeliveryPlan,
|
||||
resolveAgentDeliveryPlanWithSessionRoute,
|
||||
resolveAgentOutboundTarget,
|
||||
} = await import("./agent-delivery.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.resolveOutboundChannelPlugin.mockReset();
|
||||
mocks.resolveOutboundChannelPlugin.mockReturnValue(null);
|
||||
mocks.resolveOutboundTarget.mockClear();
|
||||
mocks.resolveOutboundSessionRoute.mockReset();
|
||||
mocks.resolveOutboundSessionRoute.mockResolvedValue(null);
|
||||
mocks.resolveSessionDeliveryTarget.mockClear();
|
||||
});
|
||||
|
||||
@@ -220,4 +240,162 @@ describe("agent delivery helpers", () => {
|
||||
expect(mocks.resolveOutboundTarget).not.toHaveBeenCalled();
|
||||
expect(resolved.resolvedTo).toBe("+1555");
|
||||
});
|
||||
|
||||
it("resolves explicit delivery targets through plugin session routing", async () => {
|
||||
const pluginRouteResolver = vi.fn();
|
||||
mocks.resolveOutboundChannelPlugin.mockReturnValue({
|
||||
messaging: { resolveOutboundSessionRoute: pluginRouteResolver },
|
||||
});
|
||||
mocks.resolveOutboundTarget.mockReturnValueOnce({
|
||||
ok: true,
|
||||
to: "channel:C123",
|
||||
});
|
||||
mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({
|
||||
sessionKey: "agent:workspace:channel:C123",
|
||||
baseSessionKey: "agent:workspace:channel:C123",
|
||||
peer: { kind: "channel", id: "C123" },
|
||||
chatType: "channel",
|
||||
from: "workspace:channel:C123",
|
||||
to: "channel:C123",
|
||||
threadId: "1700000000.000100",
|
||||
});
|
||||
|
||||
const plan = await resolveAgentDeliveryPlanWithSessionRoute({
|
||||
cfg: {} as OpenClawConfig,
|
||||
agentId: "agent",
|
||||
currentSessionKey: "agent:main",
|
||||
sessionEntry: {
|
||||
sessionId: "s4",
|
||||
updatedAt: 4,
|
||||
deliveryContext: { channel: "workspace", to: "channel:C999" },
|
||||
},
|
||||
requestedChannel: "workspace",
|
||||
explicitTo: "workspace:channel:C123:thread:1700000000.000100",
|
||||
accountId: "work",
|
||||
wantsDelivery: true,
|
||||
});
|
||||
|
||||
expect(mocks.resolveOutboundSessionRoute).toHaveBeenCalledWith({
|
||||
cfg: {},
|
||||
channel: "workspace",
|
||||
agentId: "agent",
|
||||
accountId: "work",
|
||||
target: "channel:C123",
|
||||
currentSessionKey: "agent:main",
|
||||
threadId: undefined,
|
||||
});
|
||||
expect(plan.resolvedTo).toBe("channel:C123");
|
||||
expect(plan.resolvedThreadId).toBe("1700000000.000100");
|
||||
});
|
||||
|
||||
it("does not session-route explicit targets before outbound normalization succeeds", async () => {
|
||||
mocks.resolveOutboundChannelPlugin.mockReturnValue({
|
||||
messaging: { resolveOutboundSessionRoute: vi.fn() },
|
||||
});
|
||||
mocks.resolveOutboundTarget.mockReturnValueOnce({
|
||||
ok: false,
|
||||
error: new Error("ambiguous target"),
|
||||
});
|
||||
|
||||
const plan = await resolveAgentDeliveryPlanWithSessionRoute({
|
||||
cfg: {} as OpenClawConfig,
|
||||
agentId: "agent",
|
||||
sessionEntry: undefined,
|
||||
requestedChannel: "workspace",
|
||||
explicitTo: "1470130713209602050",
|
||||
accountId: undefined,
|
||||
wantsDelivery: true,
|
||||
});
|
||||
|
||||
expect(mocks.resolveOutboundSessionRoute).not.toHaveBeenCalled();
|
||||
expect(plan.resolvedTo).toBe("1470130713209602050");
|
||||
});
|
||||
|
||||
it("falls back to the original plan when session-route canonicalization fails", async () => {
|
||||
mocks.resolveOutboundChannelPlugin.mockReturnValue({
|
||||
messaging: { resolveOutboundSessionRoute: vi.fn() },
|
||||
});
|
||||
mocks.resolveOutboundTarget.mockReturnValueOnce({
|
||||
ok: true,
|
||||
to: "channel:C123",
|
||||
});
|
||||
mocks.resolveOutboundSessionRoute.mockRejectedValueOnce(new Error("route lookup failed"));
|
||||
|
||||
const plan = await resolveAgentDeliveryPlanWithSessionRoute({
|
||||
cfg: {} as OpenClawConfig,
|
||||
agentId: "agent",
|
||||
sessionEntry: undefined,
|
||||
requestedChannel: "workspace",
|
||||
explicitTo: "channel:C123",
|
||||
accountId: undefined,
|
||||
wantsDelivery: true,
|
||||
});
|
||||
|
||||
expect(plan.resolvedTo).toBe("channel:C123");
|
||||
expect(plan.resolvedThreadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not session-route targets when delivery is disabled", async () => {
|
||||
mocks.resolveOutboundChannelPlugin.mockReturnValue({
|
||||
messaging: { resolveOutboundSessionRoute: vi.fn() },
|
||||
});
|
||||
|
||||
const plan = await resolveAgentDeliveryPlanWithSessionRoute({
|
||||
cfg: {} as OpenClawConfig,
|
||||
agentId: "agent",
|
||||
sessionEntry: undefined,
|
||||
requestedChannel: "workspace",
|
||||
explicitTo: "channel:C123",
|
||||
accountId: undefined,
|
||||
wantsDelivery: false,
|
||||
});
|
||||
|
||||
expect(mocks.resolveOutboundTarget).not.toHaveBeenCalled();
|
||||
expect(mocks.resolveOutboundSessionRoute).not.toHaveBeenCalled();
|
||||
expect(plan.resolvedTo).toBe("channel:C123");
|
||||
});
|
||||
|
||||
it("does not pass inherited session threads into explicit retarget routing", async () => {
|
||||
mocks.resolveOutboundChannelPlugin.mockReturnValue({
|
||||
messaging: { resolveOutboundSessionRoute: vi.fn() },
|
||||
});
|
||||
mocks.resolveOutboundTarget.mockReturnValueOnce({
|
||||
ok: true,
|
||||
to: "channel:C123",
|
||||
});
|
||||
mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({
|
||||
sessionKey: "agent:workspace:channel:C123",
|
||||
baseSessionKey: "agent:workspace:channel:C123",
|
||||
peer: { kind: "channel", id: "C123" },
|
||||
chatType: "channel",
|
||||
from: "workspace:channel:C123",
|
||||
to: "channel:C123",
|
||||
});
|
||||
|
||||
const plan = await resolveAgentDeliveryPlanWithSessionRoute({
|
||||
cfg: {} as OpenClawConfig,
|
||||
agentId: "agent",
|
||||
sessionEntry: {
|
||||
sessionId: "s-thread",
|
||||
updatedAt: 5,
|
||||
deliveryContext: {
|
||||
channel: "workspace",
|
||||
to: "channel:C999",
|
||||
threadId: "old-thread",
|
||||
},
|
||||
},
|
||||
requestedChannel: "workspace",
|
||||
explicitTo: "channel:C123",
|
||||
accountId: undefined,
|
||||
wantsDelivery: true,
|
||||
});
|
||||
|
||||
expect(mocks.resolveOutboundSessionRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: "channel:C123",
|
||||
threadId: undefined,
|
||||
}),
|
||||
);
|
||||
expect(plan.resolvedThreadId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.public.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.public.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
normalizeMessageChannel,
|
||||
type GatewayMessageChannel,
|
||||
} from "../../utils/message-channel.js";
|
||||
import { resolveOutboundChannelPlugin } from "./channel-resolution.js";
|
||||
import { resolveOutboundSessionRoute } from "./outbound-session.js";
|
||||
import type { OutboundTargetResolution } from "./targets.js";
|
||||
import {
|
||||
resolveOutboundTarget,
|
||||
@@ -130,6 +133,67 @@ export function resolveAgentDeliveryPlan(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveAgentDeliveryPlanWithSessionRoute(
|
||||
params: Parameters<typeof resolveAgentDeliveryPlan>[0] & {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
currentSessionKey?: string;
|
||||
},
|
||||
): Promise<AgentDeliveryPlan> {
|
||||
const plan = resolveAgentDeliveryPlan(params);
|
||||
if (
|
||||
!params.wantsDelivery ||
|
||||
!plan.resolvedTo ||
|
||||
!isDeliverableMessageChannel(plan.resolvedChannel) ||
|
||||
!resolveOutboundChannelPlugin({
|
||||
channel: plan.resolvedChannel,
|
||||
cfg: params.cfg,
|
||||
allowBootstrap: true,
|
||||
})?.messaging?.resolveOutboundSessionRoute
|
||||
) {
|
||||
return plan;
|
||||
}
|
||||
const normalizedTarget = resolveOutboundTarget({
|
||||
channel: plan.resolvedChannel,
|
||||
to: plan.resolvedTo,
|
||||
cfg: params.cfg,
|
||||
accountId: plan.resolvedAccountId,
|
||||
mode: plan.deliveryTargetMode ?? "explicit",
|
||||
});
|
||||
if (!normalizedTarget.ok) {
|
||||
return plan;
|
||||
}
|
||||
const explicitThreadId =
|
||||
params.explicitThreadId != null && params.explicitThreadId !== ""
|
||||
? params.explicitThreadId
|
||||
: undefined;
|
||||
const route = await (async () => {
|
||||
try {
|
||||
return await resolveOutboundSessionRoute({
|
||||
cfg: params.cfg,
|
||||
channel: plan.resolvedChannel as ChannelId,
|
||||
agentId: params.agentId,
|
||||
accountId: plan.resolvedAccountId,
|
||||
target: normalizedTarget.to,
|
||||
currentSessionKey: params.currentSessionKey,
|
||||
threadId: plan.deliveryTargetMode === "explicit" ? explicitThreadId : plan.resolvedThreadId,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (!route) {
|
||||
return plan;
|
||||
}
|
||||
return {
|
||||
...plan,
|
||||
resolvedTo: route.to,
|
||||
resolvedThreadId:
|
||||
route.threadId ??
|
||||
(plan.deliveryTargetMode === "explicit" ? explicitThreadId : plan.resolvedThreadId),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAgentOutboundTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
plan: AgentDeliveryPlan;
|
||||
|
||||
19
src/infra/outbound/channel-target-prefix.test.ts
Normal file
19
src/infra/outbound/channel-target-prefix.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stripTargetTopicSuffix } from "./channel-target-prefix.js";
|
||||
|
||||
describe("stripTargetTopicSuffix", () => {
|
||||
it("strips explicit topic suffixes", () => {
|
||||
expect(stripTargetTopicSuffix("room-a:topic:77")).toBe("room-a");
|
||||
});
|
||||
|
||||
it("strips Telegram numeric topic shorthand only when requested", () => {
|
||||
expect(stripTargetTopicSuffix("-100200300:77", { allowNumericShorthand: true })).toBe(
|
||||
"-100200300",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps generic colon targets intact", () => {
|
||||
expect(stripTargetTopicSuffix("room:123")).toBe("room:123");
|
||||
expect(stripTargetTopicSuffix("room-a:child")).toBe("room-a:child");
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,41 @@ const TARGET_KIND_PREFIXES = new Set([
|
||||
"user",
|
||||
]);
|
||||
|
||||
export function stripTargetProviderPrefix(raw: string, ...providers: string[]): string {
|
||||
const trimmed = raw.trim();
|
||||
const lower = normalizeOptionalLowercaseString(trimmed) ?? "";
|
||||
for (const provider of providers) {
|
||||
const normalizedProvider = normalizeOptionalLowercaseString(provider);
|
||||
if (normalizedProvider && lower.startsWith(`${normalizedProvider}:`)) {
|
||||
return trimmed.slice(normalizedProvider.length + 1).trim();
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function stripTargetKindPrefix(
|
||||
raw: string,
|
||||
kinds: readonly string[] = ["channel", "conversation", "dm", "group", "room", "thread", "user"],
|
||||
): string {
|
||||
const kindPattern = kinds
|
||||
.map((kind) => normalizeOptionalLowercaseString(kind))
|
||||
.filter((kind): kind is string => Boolean(kind))
|
||||
.join("|");
|
||||
return kindPattern ? raw.replace(new RegExp(`^(${kindPattern}):`, "i"), "").trim() : raw.trim();
|
||||
}
|
||||
|
||||
export function stripTargetTopicSuffix(
|
||||
raw: string,
|
||||
options: { allowNumericShorthand?: boolean } = {},
|
||||
): string {
|
||||
const trimmed = raw.trim();
|
||||
const numericTopicMatch = options.allowNumericShorthand ? /^(-?\d+):(\d+)$/.exec(trimmed) : null;
|
||||
if (numericTopicMatch?.[1]) {
|
||||
return numericTopicMatch[1];
|
||||
}
|
||||
return trimmed.replace(/:topic:.*$/i, "").trim();
|
||||
}
|
||||
|
||||
export type ChannelTargetProviderPrefix = {
|
||||
prefix: string;
|
||||
channel: string;
|
||||
|
||||
@@ -585,11 +585,21 @@ export function setMinimalOutboundSessionPluginRegistryForTests(): void {
|
||||
capabilities: { chatTypes: ["direct", "group", "channel"] },
|
||||
}),
|
||||
messaging: {
|
||||
parseExplicitTarget: ({ raw }) =>
|
||||
raw.startsWith("spaces/") ? { to: raw, chatType: "group" } : null,
|
||||
inferTargetChatType: ({ to }) => (to.startsWith("spaces/") ? "group" : undefined),
|
||||
targetPrefixes: ["fallbackchat"],
|
||||
},
|
||||
},
|
||||
{
|
||||
...createChannelTestPluginBase({
|
||||
id: "legacyparser",
|
||||
label: "Legacy Parser",
|
||||
capabilities: { chatTypes: ["direct", "group", "channel"] },
|
||||
}),
|
||||
messaging: {
|
||||
parseExplicitTarget: ({ raw }) =>
|
||||
raw === "team-ops" ? { to: raw, chatType: "group" } : null,
|
||||
},
|
||||
},
|
||||
];
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry(
|
||||
|
||||
@@ -398,6 +398,18 @@ describe("resolveOutboundSessionRoute", () => {
|
||||
chatType: "channel",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Legacy parser-only plugin chat type fallback",
|
||||
cfg: baseConfig,
|
||||
channel: "legacyparser",
|
||||
target: "team-ops",
|
||||
expected: {
|
||||
sessionKey: "agent:main:legacyparser:group:team-ops",
|
||||
from: "legacyparser:group:team-ops",
|
||||
to: "channel:team-ops",
|
||||
chatType: "group",
|
||||
},
|
||||
},
|
||||
] satisfies NamedRouteCase[])("$name", async ({ name: _name, ...params }) => {
|
||||
await expectResolvedRoute(params);
|
||||
});
|
||||
|
||||
@@ -69,8 +69,7 @@ function inferPeerKindFromPlugin(params: {
|
||||
}): ChatType | undefined {
|
||||
for (const target of params.targets) {
|
||||
const inferred = normalizeInferredPeerKind(
|
||||
params.plugin?.messaging?.parseExplicitTarget?.({ raw: target })?.chatType ??
|
||||
params.plugin?.messaging?.inferTargetChatType?.({ to: target }),
|
||||
params.plugin?.messaging?.inferTargetChatType?.({ to: target }),
|
||||
);
|
||||
if (inferred) {
|
||||
return inferred;
|
||||
@@ -79,6 +78,20 @@ function inferPeerKindFromPlugin(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function inferPeerKindFromLegacyParser(params: {
|
||||
plugin: ReturnType<typeof resolveOutboundChannelPlugin>;
|
||||
targets: readonly string[];
|
||||
}): ChatType | undefined {
|
||||
for (const target of params.targets) {
|
||||
const parsed = params.plugin?.messaging?.parseExplicitTarget?.({ raw: target });
|
||||
const inferred = normalizeInferredPeerKind(parsed?.chatType);
|
||||
if (inferred) {
|
||||
return inferred;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function inferPeerKindFromFallbackPrefixes(targets: readonly string[]): ChatType | undefined {
|
||||
for (const target of targets) {
|
||||
for (const fallback of FALLBACK_TARGET_KIND_PREFIXES) {
|
||||
@@ -120,6 +133,7 @@ function inferPeerKind(params: {
|
||||
);
|
||||
return (
|
||||
inferPeerKindFromPlugin({ plugin, targets }) ??
|
||||
inferPeerKindFromLegacyParser({ plugin, targets }) ??
|
||||
inferPeerKindFromFallbackPrefixes(targets) ??
|
||||
"direct"
|
||||
);
|
||||
|
||||
@@ -49,6 +49,8 @@ export async function resolveChannelTarget(params: {
|
||||
accountId?: string | null;
|
||||
preferredKind?: TargetResolveKind;
|
||||
runtime?: RuntimeEnv;
|
||||
resolveAmbiguous?: ResolveAmbiguousMode;
|
||||
unknownTargetMode?: "error" | "normalized";
|
||||
}): Promise<ResolveMessagingTargetResult> {
|
||||
return resolveMessagingTarget(params);
|
||||
}
|
||||
@@ -343,6 +345,7 @@ export async function resolveMessagingTarget(params: {
|
||||
preferredKind?: TargetResolveKind;
|
||||
runtime?: RuntimeEnv;
|
||||
resolveAmbiguous?: ResolveAmbiguousMode;
|
||||
unknownTargetMode?: "error" | "normalized";
|
||||
}): Promise<ResolveMessagingTargetResult> {
|
||||
const raw = normalizeChannelTargetInput(params.input);
|
||||
if (!raw) {
|
||||
@@ -441,6 +444,13 @@ export async function resolveMessagingTarget(params: {
|
||||
};
|
||||
}
|
||||
|
||||
if (params.unknownTargetMode === "normalized") {
|
||||
return buildNormalizedResolveResult({
|
||||
normalized,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: unknownTargetError(providerLabel, raw, hint),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { parseExplicitTargetForLoadedChannel } from "../../channels/plugins/target-parsing-loaded.js";
|
||||
import { resolveExplicitDeliveryTargetCompat } from "../../channels/plugins/target-parsing-loaded.js";
|
||||
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.public.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { channelRouteTargetsShareConversation } from "../../plugin-sdk/channel-route.js";
|
||||
import {
|
||||
type ChannelRouteExplicitTargetParser,
|
||||
channelRouteTargetsShareConversation,
|
||||
resolveChannelRouteTargetWithParser,
|
||||
} from "../../plugin-sdk/channel-route.js";
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
normalizeOptionalThreadValue,
|
||||
} from "../../shared/string-coerce.js";
|
||||
import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js";
|
||||
import {
|
||||
isDeliverableMessageChannel,
|
||||
@@ -22,8 +23,6 @@ export type SessionDeliveryTarget = {
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
threadId?: string | number;
|
||||
/** Whether threadId came from an explicit source (config/param/:topic: parsing) vs session history. */
|
||||
threadIdExplicit?: boolean;
|
||||
mode: ChannelOutboundTargetMode;
|
||||
lastChannel?: DeliverableMessageChannel;
|
||||
lastTo?: string;
|
||||
@@ -31,35 +30,29 @@ export type SessionDeliveryTarget = {
|
||||
lastThreadId?: string | number;
|
||||
};
|
||||
|
||||
export type ExplicitTargetParser = ChannelRouteExplicitTargetParser;
|
||||
|
||||
function resolveParsedRouteTarget(params: {
|
||||
channel: string;
|
||||
rawTarget?: string | null;
|
||||
fallbackThreadId?: string | number | null;
|
||||
parseExplicitTarget?: ExplicitTargetParser;
|
||||
}) {
|
||||
return resolveChannelRouteTargetWithParser({
|
||||
...params,
|
||||
parseExplicitTarget: params.parseExplicitTarget ?? parseExplicitTargetForLoadedChannel,
|
||||
const channel = normalizeLowercaseStringOrEmpty(params.channel);
|
||||
const rawTo = normalizeOptionalString(params.rawTarget);
|
||||
if (!channel || !rawTo) {
|
||||
return null;
|
||||
}
|
||||
const parsed = resolveExplicitDeliveryTargetCompat({
|
||||
channel,
|
||||
rawTarget: rawTo,
|
||||
fallbackThreadId: params.fallbackThreadId,
|
||||
});
|
||||
}
|
||||
|
||||
function parseExplicitDeliveryTarget(params: {
|
||||
channel?: DeliverableMessageChannel;
|
||||
fallbackChannel?: DeliverableMessageChannel;
|
||||
raw?: string;
|
||||
parseExplicitTarget?: ExplicitTargetParser;
|
||||
}) {
|
||||
const raw = params.raw?.trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const provider = params.channel ?? params.fallbackChannel;
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
return (params.parseExplicitTarget ?? parseExplicitTargetForLoadedChannel)(provider, raw);
|
||||
const threadId = normalizeOptionalThreadValue(parsed?.threadId ?? params.fallbackThreadId);
|
||||
return {
|
||||
channel,
|
||||
rawTo,
|
||||
to: parsed?.to ?? rawTo,
|
||||
...(threadId != null ? { threadId } : {}),
|
||||
chatType: parsed?.chatType,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSessionDeliveryTarget(params: {
|
||||
@@ -80,7 +73,6 @@ export function resolveSessionDeliveryTarget(params: {
|
||||
turnSourceTo?: string;
|
||||
turnSourceAccountId?: string;
|
||||
turnSourceThreadId?: string | number;
|
||||
parseExplicitTarget?: ExplicitTargetParser;
|
||||
}): SessionDeliveryTarget {
|
||||
const context = deliveryContextFromSession(params.entry);
|
||||
const sessionLastChannel =
|
||||
@@ -90,7 +82,6 @@ export function resolveSessionDeliveryTarget(params: {
|
||||
channel: sessionLastChannel,
|
||||
rawTarget: context?.to,
|
||||
fallbackThreadId: context?.threadId,
|
||||
parseExplicitTarget: params.parseExplicitTarget,
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -101,12 +92,13 @@ export function resolveSessionDeliveryTarget(params: {
|
||||
channel: params.turnSourceChannel,
|
||||
rawTarget: params.turnSourceTo,
|
||||
fallbackThreadId: params.turnSourceThreadId,
|
||||
parseExplicitTarget: params.parseExplicitTarget,
|
||||
})
|
||||
: null;
|
||||
const hasTurnSourceThreadId = parsedTurnSourceTarget?.threadId != null;
|
||||
const lastChannel = hasTurnSourceChannel ? params.turnSourceChannel : sessionLastChannel;
|
||||
const lastTo = hasTurnSourceChannel ? params.turnSourceTo : context?.to;
|
||||
const lastTo = hasTurnSourceChannel
|
||||
? (parsedTurnSourceTarget?.to ?? params.turnSourceTo)
|
||||
: (parsedSessionTarget?.to ?? context?.to);
|
||||
const lastAccountId = hasTurnSourceChannel ? params.turnSourceAccountId : context?.accountId;
|
||||
const turnToMatchesSession =
|
||||
!params.turnSourceTo ||
|
||||
@@ -149,20 +141,18 @@ export function resolveSessionDeliveryTarget(params: {
|
||||
channel = params.fallbackChannel;
|
||||
}
|
||||
|
||||
let explicitTo = rawExplicitTo;
|
||||
const parsedExplicitTarget = parseExplicitDeliveryTarget({
|
||||
channel,
|
||||
fallbackChannel: !channel ? lastChannel : undefined,
|
||||
raw: rawExplicitTo,
|
||||
parseExplicitTarget: params.parseExplicitTarget,
|
||||
});
|
||||
if (parsedExplicitTarget?.to) {
|
||||
explicitTo = parsedExplicitTarget.to;
|
||||
}
|
||||
const explicitThreadId =
|
||||
params.explicitThreadId != null && params.explicitThreadId !== ""
|
||||
? params.explicitThreadId
|
||||
: parsedExplicitTarget?.threadId;
|
||||
const parsedExplicitTarget =
|
||||
channel && rawExplicitTo
|
||||
? resolveExplicitDeliveryTargetCompat({
|
||||
channel,
|
||||
rawTarget: rawExplicitTo,
|
||||
fallbackThreadId: params.explicitThreadId,
|
||||
})
|
||||
: null;
|
||||
const explicitTo = parsedExplicitTarget?.to ?? rawExplicitTo;
|
||||
const explicitThreadId = normalizeOptionalThreadValue(
|
||||
parsedExplicitTarget?.threadId ?? params.explicitThreadId,
|
||||
);
|
||||
|
||||
let to = explicitTo;
|
||||
if (!to && lastTo) {
|
||||
@@ -190,7 +180,6 @@ export function resolveSessionDeliveryTarget(params: {
|
||||
to,
|
||||
accountId,
|
||||
threadId: resolvedThreadId,
|
||||
threadIdExplicit: resolvedThreadId != null && explicitThreadId != null,
|
||||
mode,
|
||||
lastChannel,
|
||||
lastTo,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
ChannelPlugin,
|
||||
} from "../../channels/plugins/types.public.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { buildChannelOutboundSessionRoute } from "../../plugin-sdk/core.js";
|
||||
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
|
||||
function readTestDefaultTo(cfg: OpenClawConfig, channelId: string): string | undefined {
|
||||
@@ -16,7 +17,7 @@ function stripTestPrefix(raw: string, channelId: string): string {
|
||||
return raw.replace(new RegExp(`^${channelId}:`, "i"), "").trim();
|
||||
}
|
||||
|
||||
function parseForumTargetForTest(raw: string): {
|
||||
export function parseForumTargetForTest(raw: string): {
|
||||
roomId: string;
|
||||
threadId?: number;
|
||||
chatType: "direct" | "group" | "unknown";
|
||||
@@ -54,7 +55,7 @@ function createGenericResolveTarget(
|
||||
};
|
||||
}
|
||||
|
||||
function parseTelegramTargetForTest(raw: string): {
|
||||
export function parseTelegramTargetForTest(raw: string): {
|
||||
chatId: string;
|
||||
messageThreadId?: number;
|
||||
chatType: "direct" | "group" | "unknown";
|
||||
@@ -81,30 +82,39 @@ function parseTelegramTargetForTest(raw: string): {
|
||||
|
||||
export const telegramMessagingForTest: ChannelMessagingAdapter = {
|
||||
targetPrefixes: ["telegram", "tg"],
|
||||
parseExplicitTarget: ({ raw }) => {
|
||||
const target = parseTelegramTargetForTest(raw);
|
||||
return {
|
||||
to: target.chatId,
|
||||
threadId: target.messageThreadId,
|
||||
chatType: target.chatType === "unknown" ? undefined : target.chatType,
|
||||
};
|
||||
},
|
||||
inferTargetChatType: ({ to }) => {
|
||||
const target = parseTelegramTargetForTest(to);
|
||||
return target.chatType === "unknown" ? undefined : target.chatType;
|
||||
},
|
||||
resolveOutboundSessionRoute: ({ cfg, agentId, accountId, target, resolvedTarget, threadId }) => {
|
||||
const parsed = parseTelegramTargetForTest(target);
|
||||
const resolvedThreadId = parsed.messageThreadId ?? threadId ?? undefined;
|
||||
const isGroup =
|
||||
parsed.chatType === "group" ||
|
||||
(parsed.chatType === "unknown" &&
|
||||
resolvedTarget?.kind !== undefined &&
|
||||
resolvedTarget.kind !== "user");
|
||||
const peerId =
|
||||
isGroup && resolvedThreadId ? `${parsed.chatId}:topic:${resolvedThreadId}` : parsed.chatId;
|
||||
return buildChannelOutboundSessionRoute({
|
||||
cfg,
|
||||
agentId,
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
},
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
from: isGroup ? `telegram:group:${peerId}` : `telegram:${parsed.chatId}`,
|
||||
to: parsed.chatId,
|
||||
...(resolvedThreadId !== undefined ? { threadId: resolvedThreadId } : {}),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const forumMessagingForTest: ChannelMessagingAdapter = {
|
||||
targetPrefixes: ["forum"],
|
||||
parseExplicitTarget: ({ raw }) => {
|
||||
const target = parseForumTargetForTest(raw);
|
||||
return {
|
||||
to: target.roomId,
|
||||
threadId: target.threadId,
|
||||
chatType: target.chatType === "unknown" ? undefined : target.chatType,
|
||||
};
|
||||
},
|
||||
inferTargetChatType: ({ to }) => {
|
||||
const target = parseForumTargetForTest(to);
|
||||
return target.chatType === "unknown" ? undefined : target.chatType;
|
||||
@@ -112,6 +122,25 @@ export const forumMessagingForTest: ChannelMessagingAdapter = {
|
||||
targetResolver: {
|
||||
hint: "<room|dm target>",
|
||||
},
|
||||
resolveOutboundSessionRoute: ({ cfg, agentId, accountId, target, threadId }) => {
|
||||
const parsed = parseForumTargetForTest(target);
|
||||
const resolvedThreadId = parsed.threadId ?? threadId ?? undefined;
|
||||
const chatType = parsed.chatType === "direct" ? "direct" : "group";
|
||||
return buildChannelOutboundSessionRoute({
|
||||
cfg,
|
||||
agentId,
|
||||
channel: "forum",
|
||||
accountId,
|
||||
peer: {
|
||||
kind: chatType,
|
||||
id: parsed.roomId,
|
||||
},
|
||||
chatType,
|
||||
from: chatType === "direct" ? `forum:${parsed.roomId}` : `forum:group:${parsed.roomId}`,
|
||||
to: parsed.roomId,
|
||||
...(resolvedThreadId !== undefined ? { threadId: resolvedThreadId } : {}),
|
||||
});
|
||||
},
|
||||
preserveHeartbeatThreadIdForGroupRoute: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SessionEntry } from "../../config/sessions/types.js";
|
||||
import { getActivePluginRegistry, setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import {
|
||||
resolveHeartbeatDeliveryTarget,
|
||||
resolveHeartbeatDeliveryTargetWithSessionRoute,
|
||||
resolveOutboundTarget,
|
||||
resolveSessionDeliveryTarget,
|
||||
} from "./targets.js";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
import {
|
||||
createForumTargetTestPlugin,
|
||||
createGenericTargetTestPlugin,
|
||||
createTestChannelPlugin,
|
||||
createTargetsTestRegistry,
|
||||
} from "./targets.test-helpers.js";
|
||||
|
||||
@@ -34,7 +36,9 @@ beforeEach(() => {
|
||||
mocks.normalizeDeliverableOutboundChannel.mockReset();
|
||||
mocks.normalizeDeliverableOutboundChannel.mockImplementation((value?: string | null) => {
|
||||
const normalized = typeof value === "string" ? value.trim().toLowerCase() : undefined;
|
||||
return ["alpha", "beta", "forum"].includes(String(normalized)) ? normalized : undefined;
|
||||
return ["alpha", "beta", "forum", "telegram"].includes(String(normalized))
|
||||
? normalized
|
||||
: undefined;
|
||||
});
|
||||
mocks.resolveOutboundChannelPlugin.mockReset();
|
||||
mocks.resolveOutboundChannelPlugin.mockImplementation(
|
||||
@@ -160,7 +164,6 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
to: params.to,
|
||||
accountId: undefined,
|
||||
threadId: undefined,
|
||||
threadIdExplicit: false,
|
||||
mode: "implicit",
|
||||
lastChannel: params.lastChannel,
|
||||
lastTo: params.lastTo,
|
||||
@@ -169,7 +172,7 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
});
|
||||
};
|
||||
|
||||
const expectTopicParsedFromExplicitTo = (
|
||||
const expectTopicTargetKeptRaw = (
|
||||
entry: Parameters<typeof resolveSessionDeliveryTarget>[0]["entry"],
|
||||
) => {
|
||||
const resolved = resolveSessionDeliveryTarget({
|
||||
@@ -177,8 +180,8 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
requestedChannel: "last",
|
||||
explicitTo: "room:ops:topic:1008013",
|
||||
});
|
||||
expect(resolved.to).toBe("room:ops");
|
||||
expect(resolved.threadId).toBe(1008013);
|
||||
expect(resolved.to).toBe("room:ops:topic:1008013");
|
||||
expect(resolved.threadId).toBeUndefined();
|
||||
};
|
||||
|
||||
it("derives implicit delivery from the last route", () => {
|
||||
@@ -198,7 +201,6 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
to: "Room One",
|
||||
accountId: "acct-1",
|
||||
threadId: undefined,
|
||||
threadIdExplicit: false,
|
||||
mode: "implicit",
|
||||
lastChannel: "alpha",
|
||||
lastTo: "Room One",
|
||||
@@ -226,6 +228,63 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps parser-only explicit target compatibility during the migration window", () => {
|
||||
const alpha = createGenericTargetTestPlugin("alpha", "Alpha");
|
||||
setActivePluginRegistry(
|
||||
createTargetsTestRegistry([
|
||||
{
|
||||
...alpha,
|
||||
messaging: {
|
||||
targetPrefixes: ["alpha"],
|
||||
parseExplicitTarget: ({ raw }) =>
|
||||
raw === "alpha:room-a:topic:77"
|
||||
? { to: "room-a", threadId: 77, chatType: "group" as const }
|
||||
: null,
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const resolved = resolveSessionDeliveryTarget({
|
||||
requestedChannel: "alpha",
|
||||
explicitTo: "alpha:room-a:topic:77",
|
||||
});
|
||||
|
||||
expect(resolved.to).toBe("room-a");
|
||||
expect(resolved.threadId).toBe(77);
|
||||
});
|
||||
|
||||
it("keeps parser-only session target thread compatibility during the migration window", () => {
|
||||
const alpha = createGenericTargetTestPlugin("alpha", "Alpha");
|
||||
setActivePluginRegistry(
|
||||
createTargetsTestRegistry([
|
||||
{
|
||||
...alpha,
|
||||
messaging: {
|
||||
targetPrefixes: ["alpha"],
|
||||
parseExplicitTarget: ({ raw }) =>
|
||||
raw === "alpha:room-a:topic:77"
|
||||
? { to: "room-a", threadId: 77, chatType: "group" as const }
|
||||
: null,
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const resolved = resolveSessionDeliveryTarget({
|
||||
entry: {
|
||||
sessionId: "sess-parser",
|
||||
updatedAt: 1,
|
||||
lastChannel: "alpha",
|
||||
lastTo: "alpha:room-a:topic:77",
|
||||
},
|
||||
requestedChannel: "last",
|
||||
});
|
||||
|
||||
expect(resolved.to).toBe("room-a");
|
||||
expect(resolved.threadId).toBe(77);
|
||||
});
|
||||
|
||||
it("uses an explicit provider-prefixed target before last-session channel fallback", () => {
|
||||
const resolved = resolveSessionDeliveryTarget({
|
||||
entry: {
|
||||
@@ -348,8 +407,8 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses plugin-owned explicit targets into threadId", () => {
|
||||
expectTopicParsedFromExplicitTo({
|
||||
it("keeps plugin-owned explicit targets raw for route resolution", () => {
|
||||
expectTopicTargetKeptRaw({
|
||||
sessionId: "sess-topic",
|
||||
updatedAt: 1,
|
||||
lastChannel: "forum",
|
||||
@@ -357,8 +416,8 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses plugin-owned explicit targets even when lastTo is absent", () => {
|
||||
expectTopicParsedFromExplicitTo({
|
||||
it("keeps plugin-owned explicit targets raw when lastTo is absent", () => {
|
||||
expectTopicTargetKeptRaw({
|
||||
sessionId: "sess-no-last",
|
||||
updatedAt: 1,
|
||||
lastChannel: "forum",
|
||||
@@ -429,7 +488,7 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
});
|
||||
|
||||
expect(resolved.threadId).toBe(42);
|
||||
expect(resolved.to).toBe("room:ops");
|
||||
expect(resolved.to).toBe("room:ops:topic:1008013");
|
||||
});
|
||||
|
||||
const resolveHeartbeatTarget = (entry: SessionEntry, directPolicy?: "allow" | "block") =>
|
||||
@@ -639,10 +698,9 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
expect(resolved.channel).toBe("forum");
|
||||
expect(resolved.to).toBe("room:ops");
|
||||
expect(resolved.threadId).toBe(42);
|
||||
expect(resolved.threadIdExplicit).toBe(true);
|
||||
});
|
||||
|
||||
it("parses explicit heartbeat plugin targets into threadId", () => {
|
||||
it("keeps explicit heartbeat plugin targets raw for modern route resolution", () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const resolved = resolveHeartbeatDeliveryTarget({
|
||||
cfg,
|
||||
@@ -652,11 +710,250 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.channel).toBe("forum");
|
||||
expect(resolved.to).toBe("room:ops:topic:1008013");
|
||||
expect(resolved.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves explicit heartbeat plugin targets through the outbound session route", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
heartbeat: {
|
||||
target: "forum",
|
||||
to: "room:ops:topic:1008013",
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.channel).toBe("forum");
|
||||
expect(resolved.to).toBe("room:ops");
|
||||
expect(resolved.threadId).toBe(1008013);
|
||||
});
|
||||
|
||||
it("blocks heartbeat targets that route to direct chats after canonicalization", async () => {
|
||||
const alpha = createGenericTargetTestPlugin("alpha", "Alpha");
|
||||
setActivePluginRegistry(
|
||||
createTargetsTestRegistry([
|
||||
{
|
||||
...alpha,
|
||||
messaging: {
|
||||
...alpha.messaging,
|
||||
resolveOutboundSessionRoute: () => ({
|
||||
sessionKey: "main:alpha:user:u123",
|
||||
baseSessionKey: "main:alpha:user:u123",
|
||||
peer: { kind: "direct", id: "u123" },
|
||||
chatType: "direct",
|
||||
from: "alpha:u123",
|
||||
to: "user:u123",
|
||||
}),
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
|
||||
cfg: {},
|
||||
agentId: "main",
|
||||
entry: {
|
||||
sessionId: "sess-heartbeat-routed-direct",
|
||||
updatedAt: 1,
|
||||
lastChannel: "alpha",
|
||||
lastTo: "channel:D123",
|
||||
},
|
||||
heartbeat: {
|
||||
target: "last",
|
||||
directPolicy: "block",
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.channel).toBe("none");
|
||||
expect(resolved.reason).toBe("dm-blocked");
|
||||
});
|
||||
|
||||
it("uses resolved target kind before applying heartbeat directPolicy to routed handles", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTargetsTestRegistry([
|
||||
createTestChannelPlugin({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
resolveTarget: ({ to }) =>
|
||||
to
|
||||
? { ok: true as const, to: to.trim() }
|
||||
: { ok: false as const, error: new Error("target required") },
|
||||
},
|
||||
messaging: {
|
||||
targetPrefixes: ["telegram"],
|
||||
inferTargetChatType: () => "group",
|
||||
targetResolver: {
|
||||
resolveTarget: async ({ normalized }) => ({
|
||||
to: normalized,
|
||||
kind: "group",
|
||||
source: "directory",
|
||||
}),
|
||||
},
|
||||
resolveOutboundSessionRoute: ({ target, resolvedTarget }) => {
|
||||
const isGroup = resolvedTarget?.kind === "group";
|
||||
return {
|
||||
sessionKey: `main:telegram:${isGroup ? "group" : "user"}:${target}`,
|
||||
baseSessionKey: `main:telegram:${isGroup ? "group" : "user"}:${target}`,
|
||||
peer: { kind: isGroup ? "group" : "direct", id: target },
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
from: isGroup ? `telegram:group:${target}` : `telegram:${target}`,
|
||||
to: target,
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
|
||||
cfg: {},
|
||||
agentId: "main",
|
||||
heartbeat: {
|
||||
target: "telegram",
|
||||
to: "@public_group",
|
||||
directPolicy: "block",
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.channel).toBe("telegram");
|
||||
expect(resolved.to).toBe("@public_group");
|
||||
expect(resolved.chatType).toBe("group");
|
||||
});
|
||||
|
||||
it("keeps heartbeat route canonicalization best-effort when target resolution fails", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTargetsTestRegistry([
|
||||
createTestChannelPlugin({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
resolveTarget: ({ to }) =>
|
||||
to
|
||||
? { ok: true as const, to: to.trim() }
|
||||
: { ok: false as const, error: new Error("target required") },
|
||||
},
|
||||
messaging: {
|
||||
targetPrefixes: ["telegram"],
|
||||
inferTargetChatType: () => "group",
|
||||
targetResolver: {
|
||||
resolveTarget: async () => {
|
||||
throw new Error("directory unavailable");
|
||||
},
|
||||
},
|
||||
resolveOutboundSessionRoute: ({ target }) => ({
|
||||
sessionKey: `main:telegram:group:${target}`,
|
||||
baseSessionKey: `main:telegram:group:${target}`,
|
||||
peer: { kind: "group", id: target },
|
||||
chatType: "group",
|
||||
from: `telegram:group:${target}`,
|
||||
to: target,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
|
||||
cfg: {},
|
||||
agentId: "main",
|
||||
heartbeat: {
|
||||
target: "telegram",
|
||||
to: "@public_group",
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.channel).toBe("telegram");
|
||||
expect(resolved.to).toBe("@public_group");
|
||||
expect(resolved.chatType).toBe("group");
|
||||
});
|
||||
|
||||
it("keeps heartbeat route canonicalization best-effort when route resolution fails", async () => {
|
||||
const alpha = createGenericTargetTestPlugin("alpha", "Alpha");
|
||||
setActivePluginRegistry(
|
||||
createTargetsTestRegistry([
|
||||
{
|
||||
...alpha,
|
||||
messaging: {
|
||||
...alpha.messaging,
|
||||
inferTargetChatType: () => "group",
|
||||
resolveOutboundSessionRoute: () => {
|
||||
throw new Error("route lookup failed");
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
|
||||
cfg: {},
|
||||
agentId: "main",
|
||||
entry: {
|
||||
sessionId: "sess-heartbeat-route-failure",
|
||||
updatedAt: 1,
|
||||
lastChannel: "alpha",
|
||||
lastTo: "group:ops",
|
||||
},
|
||||
heartbeat: {
|
||||
target: "last",
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.channel).toBe("alpha");
|
||||
expect(resolved.to).toBe("group:ops");
|
||||
expect(resolved.chatType).toBe("group");
|
||||
});
|
||||
|
||||
it("applies default heartbeat directPolicy after route canonicalization", async () => {
|
||||
const alpha = createGenericTargetTestPlugin("alpha", "Alpha");
|
||||
setActivePluginRegistry(
|
||||
createTargetsTestRegistry([
|
||||
{
|
||||
...alpha,
|
||||
messaging: {
|
||||
...alpha.messaging,
|
||||
resolveOutboundSessionRoute: () => ({
|
||||
sessionKey: "main:alpha:user:u123",
|
||||
baseSessionKey: "main:alpha:user:u123",
|
||||
peer: { kind: "direct", id: "u123" },
|
||||
chatType: "direct",
|
||||
from: "alpha:u123",
|
||||
to: "user:u123",
|
||||
}),
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
target: "last",
|
||||
directPolicy: "block",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
agentId: "main",
|
||||
entry: {
|
||||
sessionId: "sess-heartbeat-default-routed-direct",
|
||||
updatedAt: 1,
|
||||
lastChannel: "alpha",
|
||||
lastTo: "channel:D123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.channel).toBe("none");
|
||||
expect(resolved.reason).toBe("dm-blocked");
|
||||
});
|
||||
|
||||
it("preserves route threadId for heartbeat target=last on plugin-owned group sessions", () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const resolved = resolveHeartbeatDeliveryTarget({
|
||||
@@ -910,7 +1207,7 @@ describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)",
|
||||
expect(resolved.threadId).toBe(1122);
|
||||
});
|
||||
|
||||
it("matches bare stored routes against topic-scoped turn routes via plugin grammar", () => {
|
||||
it("does not use plugin grammar to match bare stored routes against topic-scoped turn routes", () => {
|
||||
const resolved = resolveSessionDeliveryTarget({
|
||||
entry: {
|
||||
sessionId: "sess-forum-topic-mixed-shape",
|
||||
@@ -926,7 +1223,7 @@ describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)",
|
||||
|
||||
expect(resolved.channel).toBe("forum");
|
||||
expect(resolved.to).toBe("forum:room:ops:topic:1122");
|
||||
expect(resolved.threadId).toBe(1122);
|
||||
expect(resolved.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not fall back to session lastThreadId when turnSourceChannel differs from session channel", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { normalizeChatType, type ChatType } from "../../channels/chat-type.js";
|
||||
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.core.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.public.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
@@ -22,6 +23,8 @@ import {
|
||||
normalizeDeliverableOutboundChannel,
|
||||
resolveOutboundChannelPlugin,
|
||||
} from "./channel-resolution.js";
|
||||
import { resolveOutboundSessionRoute } from "./outbound-session.js";
|
||||
import { resolveChannelTarget, type ResolvedMessagingTarget } from "./target-resolver.js";
|
||||
import {
|
||||
resolveOutboundTargetWithPlugin,
|
||||
type OutboundTargetResolution,
|
||||
@@ -263,6 +266,79 @@ function buildNoHeartbeatDeliveryTarget(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
entry?: SessionEntry;
|
||||
heartbeat?: AgentDefaultsConfig["heartbeat"];
|
||||
turnSource?: DeliveryContext;
|
||||
currentSessionKey?: string;
|
||||
}): Promise<OutboundTarget> {
|
||||
const delivery = resolveHeartbeatDeliveryTarget(params);
|
||||
const heartbeat = params.heartbeat ?? params.cfg.agents?.defaults?.heartbeat;
|
||||
if (delivery.channel === "none" || !delivery.to) {
|
||||
return delivery;
|
||||
}
|
||||
const deliveryTo = delivery.to;
|
||||
const plugin = resolveOutboundChannelPlugin({
|
||||
channel: delivery.channel,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
if (!plugin?.messaging?.resolveOutboundSessionRoute) {
|
||||
return delivery;
|
||||
}
|
||||
let routeResolvedTarget: ResolvedMessagingTarget | undefined;
|
||||
const targetResolution = await (async () => {
|
||||
try {
|
||||
return await resolveChannelTarget({
|
||||
cfg: params.cfg,
|
||||
channel: delivery.channel as ChannelId,
|
||||
input: deliveryTo,
|
||||
accountId: delivery.accountId,
|
||||
unknownTargetMode: "normalized",
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (targetResolution?.ok) {
|
||||
routeResolvedTarget = targetResolution.target;
|
||||
}
|
||||
const route = await (async () => {
|
||||
try {
|
||||
return await resolveOutboundSessionRoute({
|
||||
cfg: params.cfg,
|
||||
channel: delivery.channel as ChannelId,
|
||||
agentId: params.agentId,
|
||||
accountId: delivery.accountId,
|
||||
target: routeResolvedTarget?.to ?? deliveryTo,
|
||||
resolvedTarget: routeResolvedTarget,
|
||||
currentSessionKey: params.currentSessionKey,
|
||||
threadId: delivery.threadId,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (!route) {
|
||||
return delivery;
|
||||
}
|
||||
if (route.chatType === "direct" && heartbeat?.directPolicy === "block") {
|
||||
return buildNoHeartbeatDeliveryTarget({
|
||||
reason: "dm-blocked",
|
||||
accountId: delivery.accountId,
|
||||
lastChannel: delivery.lastChannel,
|
||||
lastAccountId: delivery.lastAccountId,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...delivery,
|
||||
to: route.to,
|
||||
chatType: route.chatType,
|
||||
threadId: route.threadId ?? delivery.threadId,
|
||||
};
|
||||
}
|
||||
|
||||
function inferChatTypeFromTarget(params: {
|
||||
channel: DeliverableMessageChannel;
|
||||
to: string;
|
||||
|
||||
@@ -180,7 +180,7 @@ describe("plugin-sdk channel-route", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves parsed route targets through an injected channel grammar", () => {
|
||||
it("keeps deprecated parser wrapper wired for public SDK compatibility", () => {
|
||||
expect(
|
||||
resolveChannelRouteTargetWithParser({
|
||||
channel: "Mock",
|
||||
|
||||
@@ -44,12 +44,14 @@ export type ChannelRouteTargetInput = Pick<
|
||||
|
||||
export type ChannelRouteKeyInput = ChannelRouteRef | ChannelRouteTargetInput;
|
||||
|
||||
/** @deprecated Use `messaging.resolveOutboundSessionRoute` for provider-specific target grammar. */
|
||||
export type ChannelRouteExplicitTarget = {
|
||||
to: string;
|
||||
threadId?: string | number;
|
||||
chatType?: ChannelRouteChatType;
|
||||
};
|
||||
|
||||
/** @deprecated Use `messaging.resolveOutboundSessionRoute` for provider-specific target grammar. */
|
||||
export type ChannelRouteExplicitTargetParser = (
|
||||
channel: string,
|
||||
rawTarget: string,
|
||||
@@ -125,6 +127,7 @@ export type ChannelRouteParsedTarget = ChannelRouteTargetInput & {
|
||||
chatType?: ChannelRouteChatType;
|
||||
};
|
||||
|
||||
/** @deprecated Use `messaging.resolveOutboundSessionRoute` for provider-specific target grammar. */
|
||||
export function resolveChannelRouteTargetWithParser(params: {
|
||||
channel: string;
|
||||
rawTarget?: string | null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** @deprecated Use `openclaw/plugin-sdk/channel-targets`. */
|
||||
export {
|
||||
buildMessagingTarget,
|
||||
ensureTargetId,
|
||||
|
||||
@@ -8,6 +8,23 @@ import {
|
||||
} from "./registry.js";
|
||||
|
||||
const datePattern = /^\d{4}-\d{2}-\d{2}$/u;
|
||||
const sourceRootsForDeprecatedCallGuard = [
|
||||
"src",
|
||||
"extensions",
|
||||
"packages",
|
||||
"test",
|
||||
"scripts",
|
||||
] as const;
|
||||
const deprecatedTargetParserCallPattern =
|
||||
/\.parseExplicitTarget\?\.\s*\(|parseExplicitTargetFor(?:Channel|LoadedChannel)\s*\(|resolveRouteTargetFor(?:Channel|LoadedChannel)\s*\(/u;
|
||||
const deprecatedTargetParserCompatFiles = new Set([
|
||||
"src/auto-reply/reply/group-id.ts",
|
||||
"src/channels/plugins/target-parsing-loaded.ts",
|
||||
"src/channels/plugins/target-parsing.test.ts",
|
||||
"src/infra/outbound/outbound-session.ts",
|
||||
"src/infra/outbound/outbound-session.test-helpers.ts",
|
||||
"src/plugins/compat/registry.test.ts",
|
||||
]);
|
||||
|
||||
const knownDeprecatedSurfaceMarkers = [
|
||||
{
|
||||
@@ -150,6 +167,36 @@ const knownDeprecatedSurfaceMarkers = [
|
||||
file: "src/channels/plugins/target-parsing-loaded.ts",
|
||||
marker: "ComparableChannelTarget",
|
||||
},
|
||||
{
|
||||
code: "channel-explicit-target-parser",
|
||||
file: "src/channels/plugins/types.core.ts",
|
||||
marker: "parseExplicitTarget?:",
|
||||
},
|
||||
{
|
||||
code: "channel-explicit-target-parser",
|
||||
file: "src/plugin-sdk/channel-route.ts",
|
||||
marker: "resolveChannelRouteTargetWithParser",
|
||||
},
|
||||
{
|
||||
code: "channel-explicit-target-parser",
|
||||
file: "src/channels/plugins/target-parsing-loaded.ts",
|
||||
marker: "ParsedChannelExplicitTarget",
|
||||
},
|
||||
{
|
||||
code: "channel-explicit-target-parser",
|
||||
file: "src/channels/plugins/target-parsing-loaded.ts",
|
||||
marker: "parseExplicitTargetForLoadedChannel",
|
||||
},
|
||||
{
|
||||
code: "channel-explicit-target-parser",
|
||||
file: "src/channels/plugins/target-parsing-loaded.ts",
|
||||
marker: "resolveRouteTargetForLoadedChannel",
|
||||
},
|
||||
{
|
||||
code: "channel-messaging-targets-subpath",
|
||||
file: "src/plugin-sdk/messaging-targets.ts",
|
||||
marker: "openclaw/plugin-sdk/channel-targets",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function parseDate(date: string): Date {
|
||||
@@ -169,6 +216,20 @@ function expectNonEmptyStringList(values: readonly string[], label: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function listSourceFiles(dir: string): string[] {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
return entries.flatMap((entry) => {
|
||||
const path = `${dir}/${entry.name}`;
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "dist" || entry.name === "node_modules") {
|
||||
return [];
|
||||
}
|
||||
return listSourceFiles(path);
|
||||
}
|
||||
return /\.(?:ts|tsx|mts|cts)$/u.test(entry.name) ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
describe("plugin compatibility registry", () => {
|
||||
it("keeps compatibility codes unique and lookup-safe", () => {
|
||||
const records = listPluginCompatRecords();
|
||||
@@ -215,4 +276,13 @@ describe("plugin compatibility registry", () => {
|
||||
expect(fs.readFileSync(surface.file, "utf8"), surface.file).toContain(surface.marker);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps deprecated explicit target parser calls inside compatibility shims", () => {
|
||||
const offenders = sourceRootsForDeprecatedCallGuard
|
||||
.flatMap((root) => listSourceFiles(root))
|
||||
.filter((file) => !deprecatedTargetParserCompatFiles.has(file))
|
||||
.filter((file) => deprecatedTargetParserCallPattern.test(fs.readFileSync(file, "utf8")));
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,11 +33,11 @@ export const PLUGIN_COMPAT_RECORDS = [
|
||||
removeAfter: "2026-08-16",
|
||||
replacement: "`gateway_stop` hook",
|
||||
docsPath: "/plugins/hooks#upcoming-deprecations",
|
||||
surfaces: ["api.on(\"deactivate\", ...)", "plugin typed hook registration"],
|
||||
surfaces: ['api.on("deactivate", ...)', "plugin typed hook registration"],
|
||||
diagnostics: ["plugin runtime compatibility warning"],
|
||||
tests: ["src/plugins/loader.test.ts"],
|
||||
releaseNote:
|
||||
"`api.on(\"deactivate\", ...)` remains wired as a deprecated compatibility alias while plugins migrate to `gateway_stop`.",
|
||||
'`api.on("deactivate", ...)` remains wired as a deprecated compatibility alias while plugins migrate to `gateway_stop`.',
|
||||
},
|
||||
{
|
||||
code: "hook-only-plugin-shape",
|
||||
@@ -203,13 +203,13 @@ export const PLUGIN_COMPAT_RECORDS = [
|
||||
warningStarts: "2026-04-28",
|
||||
removeAfter: "2026-07-28",
|
||||
replacement:
|
||||
"`resolveRouteTargetForChannel`, `ChannelRouteParsedTarget`, `channelRouteTargetsMatchExact`, and `channelRouteTargetsShareConversation`",
|
||||
"`ChannelRouteParsedTarget`, `channelRouteTargetsMatchExact`, `channelRouteTargetsShareConversation`, and `messaging.resolveOutboundSessionRoute` for provider-specific target grammar",
|
||||
docsPath: "/plugins/sdk-migration",
|
||||
surfaces: [
|
||||
"src/channels/plugins/target-parsing ComparableChannelTarget",
|
||||
"src/channels/plugins/target-parsing resolveComparableTargetForChannel",
|
||||
"src/channels/plugins/target-parsing comparableChannelTargetsMatch",
|
||||
"src/channels/plugins/target-parsing comparableChannelTargetsShareRoute",
|
||||
"src/channels/plugins/target-parsing-loaded ComparableChannelTarget",
|
||||
"src/channels/plugins/target-parsing-loaded resolveComparableTargetForLoadedChannel",
|
||||
"src/channels/plugins/target-parsing-loaded comparableChannelTargetsMatch",
|
||||
"src/channels/plugins/target-parsing-loaded comparableChannelTargetsShareRoute",
|
||||
],
|
||||
diagnostics: ["plugin SDK compatibility warning"],
|
||||
tests: [
|
||||
@@ -217,6 +217,49 @@ export const PLUGIN_COMPAT_RECORDS = [
|
||||
"src/plugins/contracts/plugin-sdk-subpaths.test.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "channel-explicit-target-parser",
|
||||
status: "deprecated",
|
||||
owner: "sdk",
|
||||
introduced: "2026-04-28",
|
||||
deprecated: "2026-05-23",
|
||||
warningStarts: "2026-05-23",
|
||||
removeAfter: "2026-08-23",
|
||||
replacement:
|
||||
"`messaging.targetResolver` for target normalization and `messaging.resolveOutboundSessionRoute` for session/thread identity",
|
||||
docsPath: "/plugins/sdk-migration",
|
||||
surfaces: [
|
||||
"ChannelMessagingAdapter.parseExplicitTarget",
|
||||
"openclaw/plugin-sdk/channel-route ChannelRouteExplicitTarget",
|
||||
"openclaw/plugin-sdk/channel-route ChannelRouteExplicitTargetParser",
|
||||
"openclaw/plugin-sdk/channel-route resolveChannelRouteTargetWithParser",
|
||||
"src/channels/plugins/target-parsing-loaded ParsedChannelExplicitTarget",
|
||||
"src/channels/plugins/target-parsing-loaded parseExplicitTargetForLoadedChannel",
|
||||
"src/channels/plugins/target-parsing-loaded resolveRouteTargetForLoadedChannel",
|
||||
],
|
||||
diagnostics: ["plugin SDK compatibility warning"],
|
||||
tests: [
|
||||
"src/channels/plugins/contracts/test-helpers/surface-contract-suite.ts",
|
||||
"src/plugins/compat/registry.test.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "channel-messaging-targets-subpath",
|
||||
status: "deprecated",
|
||||
owner: "sdk",
|
||||
introduced: "2026-04-28",
|
||||
deprecated: "2026-05-23",
|
||||
warningStarts: "2026-05-23",
|
||||
removeAfter: "2026-08-23",
|
||||
replacement: "`openclaw/plugin-sdk/channel-targets`",
|
||||
docsPath: "/plugins/sdk-migration",
|
||||
surfaces: ["openclaw/plugin-sdk/messaging-targets"],
|
||||
diagnostics: ["plugin SDK compatibility warning"],
|
||||
tests: [
|
||||
"src/plugins/compat/registry.test.ts",
|
||||
"src/plugins/contracts/plugin-sdk-subpaths.test.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "bundled-plugin-allowlist",
|
||||
status: "active",
|
||||
|
||||
@@ -860,7 +860,6 @@ describe("plugin-sdk subpath exports", () => {
|
||||
pattern:
|
||||
/\b(?:ComparableChannelTarget|resolveComparableTargetFor(?:Channel|LoadedChannel)|comparableChannelTargets(?:Match|ShareRoute))\b/u,
|
||||
exclude: [
|
||||
"src/channels/plugins/target-parsing.ts",
|
||||
"src/channels/plugins/target-parsing-loaded.ts",
|
||||
"src/channels/plugins/target-parsing.test.ts",
|
||||
"src/plugins/compat/registry.ts",
|
||||
|
||||
Reference in New Issue
Block a user