refactor: unify delivery target resolution

Co-authored-by: adam91holt <adam91holt@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-17 05:28:55 +00:00
parent f4f20c6762
commit ccea3a0615
5 changed files with 280 additions and 67 deletions

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveOutboundTarget } from "./targets.js";
import { resolveOutboundTarget, resolveSessionDeliveryTarget } from "./targets.js";
describe("resolveOutboundTarget", () => {
it("falls back to whatsapp allowFrom via config", () => {
@@ -93,3 +93,96 @@ describe("resolveOutboundTarget", () => {
}
});
});
describe("resolveSessionDeliveryTarget", () => {
it("derives implicit delivery from the last route", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-1",
updatedAt: 1,
lastChannel: " whatsapp ",
lastTo: " +1555 ",
lastAccountId: " acct-1 ",
},
requestedChannel: "last",
});
expect(resolved).toEqual({
channel: "whatsapp",
to: "+1555",
accountId: "acct-1",
mode: "implicit",
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: "acct-1",
});
});
it("prefers explicit targets without reusing lastTo", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-2",
updatedAt: 1,
lastChannel: "whatsapp",
lastTo: "+1555",
},
requestedChannel: "telegram",
});
expect(resolved).toEqual({
channel: "telegram",
to: undefined,
accountId: undefined,
mode: "implicit",
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: undefined,
});
});
it("allows mismatched lastTo when configured", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-3",
updatedAt: 1,
lastChannel: "whatsapp",
lastTo: "+1555",
},
requestedChannel: "telegram",
allowMismatchedLastTo: true,
});
expect(resolved).toEqual({
channel: "telegram",
to: "+1555",
accountId: undefined,
mode: "implicit",
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: undefined,
});
});
it("falls back to a provided channel when requested is unsupported", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-4",
updatedAt: 1,
lastChannel: "whatsapp",
lastTo: "+1555",
},
requestedChannel: "webchat",
fallbackChannel: "slack",
});
expect(resolved).toEqual({
channel: "slack",
to: undefined,
accountId: undefined,
mode: "implicit",
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: undefined,
});
});
});

View File

@@ -3,11 +3,16 @@ import type { ChannelId, ChannelOutboundTargetMode } from "../../channels/plugin
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js";
import { deliveryContextFromSession } from "../../utils/delivery-context.js";
import type {
DeliverableMessageChannel,
GatewayMessageChannel,
} from "../../utils/message-channel.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import {
INTERNAL_MESSAGE_CHANNEL,
isDeliverableMessageChannel,
normalizeMessageChannel,
} from "../../utils/message-channel.js";
export type OutboundChannel = DeliverableMessageChannel | "none";
@@ -17,10 +22,79 @@ export type OutboundTarget = {
channel: OutboundChannel;
to?: string;
reason?: string;
accountId?: string;
lastChannel?: DeliverableMessageChannel;
lastAccountId?: string;
};
export type OutboundTargetResolution = { ok: true; to: string } | { ok: false; error: Error };
export type SessionDeliveryTarget = {
channel?: DeliverableMessageChannel;
to?: string;
accountId?: string;
mode: ChannelOutboundTargetMode;
lastChannel?: DeliverableMessageChannel;
lastTo?: string;
lastAccountId?: string;
};
export function resolveSessionDeliveryTarget(params: {
entry?: SessionEntry;
requestedChannel?: GatewayMessageChannel | "last";
explicitTo?: string;
fallbackChannel?: DeliverableMessageChannel;
allowMismatchedLastTo?: boolean;
mode?: ChannelOutboundTargetMode;
}): SessionDeliveryTarget {
const context = deliveryContextFromSession(params.entry);
const lastChannel =
context?.channel && isDeliverableMessageChannel(context.channel) ? context.channel : undefined;
const lastTo = context?.to;
const lastAccountId = context?.accountId;
const rawRequested = params.requestedChannel ?? "last";
const requested = rawRequested === "last" ? "last" : normalizeMessageChannel(rawRequested);
const requestedChannel =
requested === "last"
? "last"
: requested && isDeliverableMessageChannel(requested)
? requested
: undefined;
const explicitTo =
typeof params.explicitTo === "string" && params.explicitTo.trim()
? params.explicitTo.trim()
: undefined;
let channel = requestedChannel === "last" ? lastChannel : requestedChannel;
if (!channel && params.fallbackChannel && isDeliverableMessageChannel(params.fallbackChannel)) {
channel = params.fallbackChannel;
}
let to = explicitTo;
if (!to && lastTo) {
if (channel && channel === lastChannel) {
to = lastTo;
} else if (params.allowMismatchedLastTo) {
to = lastTo;
}
}
const accountId = channel && channel === lastChannel ? lastAccountId : undefined;
const mode = params.mode ?? (explicitTo ? "explicit" : "implicit");
return {
channel,
to,
accountId,
mode,
lastChannel,
lastTo,
lastAccountId,
};
}
// Channel docking: prefer plugin.outbound.resolveTarget + allowFrom to normalize destinations.
export function resolveOutboundTarget(params: {
channel: GatewayMessageChannel;
@@ -94,48 +168,58 @@ export function resolveHeartbeatDeliveryTarget(params: {
}
if (target === "none") {
return { channel: "none", reason: "target-none" };
const base = resolveSessionDeliveryTarget({ entry });
return {
channel: "none",
reason: "target-none",
accountId: undefined,
lastChannel: base.lastChannel,
lastAccountId: base.lastAccountId,
};
}
const explicitTo =
typeof heartbeat?.to === "string" && heartbeat.to.trim() ? heartbeat.to.trim() : undefined;
const resolvedTarget = resolveSessionDeliveryTarget({
entry,
requestedChannel: target === "last" ? "last" : target,
explicitTo: heartbeat?.to,
mode: "heartbeat",
});
const lastChannel =
entry?.lastChannel && entry.lastChannel !== INTERNAL_MESSAGE_CHANNEL
? normalizeChannelId(entry.lastChannel)
: undefined;
const lastTo = typeof entry?.lastTo === "string" ? entry.lastTo.trim() : "";
const channel = target === "last" ? lastChannel : target;
const to =
explicitTo ||
(channel && lastChannel === channel ? lastTo : undefined) ||
(target === "last" ? lastTo : undefined);
if (!channel || !to) {
return { channel: "none", reason: "no-target" };
if (!resolvedTarget.channel || !resolvedTarget.to) {
return {
channel: "none",
reason: "no-target",
accountId: resolvedTarget.accountId,
lastChannel: resolvedTarget.lastChannel,
lastAccountId: resolvedTarget.lastAccountId,
};
}
const accountId = channel === lastChannel ? entry?.lastAccountId : undefined;
const resolved = resolveOutboundTarget({
channel,
to,
channel: resolvedTarget.channel,
to: resolvedTarget.to,
cfg,
accountId,
accountId: resolvedTarget.accountId,
mode: "heartbeat",
});
if (!resolved.ok) {
return { channel: "none", reason: "no-target" };
return {
channel: "none",
reason: "no-target",
accountId: resolvedTarget.accountId,
lastChannel: resolvedTarget.lastChannel,
lastAccountId: resolvedTarget.lastAccountId,
};
}
let reason: string | undefined;
const plugin = getChannelPlugin(channel as ChannelId);
const plugin = getChannelPlugin(resolvedTarget.channel as ChannelId);
if (plugin?.config.resolveAllowFrom) {
const explicit = resolveOutboundTarget({
channel,
to,
channel: resolvedTarget.channel,
to: resolvedTarget.to,
cfg,
accountId,
accountId: resolvedTarget.accountId,
mode: "explicit",
});
if (explicit.ok && explicit.to !== resolved.to) {
@@ -143,5 +227,12 @@ export function resolveHeartbeatDeliveryTarget(params: {
}
}
return reason ? { channel, to: resolved.to, reason } : { channel, to: resolved.to };
return {
channel: resolvedTarget.channel,
to: resolved.to,
reason,
accountId: resolvedTarget.accountId,
lastChannel: resolvedTarget.lastChannel,
lastAccountId: resolvedTarget.lastAccountId,
};
}