mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 13:31:25 +00:00
refactor(agents): unify subagent announce delivery pipeline
Co-authored-by: Smith Labs <SmithLabsLLC@users.noreply.github.com> Co-authored-by: Do Cao Hieu <docaohieu2808@users.noreply.github.com>
This commit is contained in:
73
src/infra/outbound/channel-resolution.ts
Normal file
73
src/infra/outbound/channel-resolution.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { getChannelPlugin } from "../../channels/plugins/index.js";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js";
|
||||
import { loadOpenClawPlugins } from "../../plugins/loader.js";
|
||||
import { getActivePluginRegistry, getActivePluginRegistryKey } from "../../plugins/runtime.js";
|
||||
import {
|
||||
isDeliverableMessageChannel,
|
||||
normalizeMessageChannel,
|
||||
type DeliverableMessageChannel,
|
||||
} from "../../utils/message-channel.js";
|
||||
|
||||
const bootstrapAttempts = new Set<string>();
|
||||
|
||||
export function normalizeDeliverableOutboundChannel(
|
||||
raw?: string | null,
|
||||
): DeliverableMessageChannel | undefined {
|
||||
const normalized = normalizeMessageChannel(raw);
|
||||
if (!normalized || !isDeliverableMessageChannel(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function maybeBootstrapChannelPlugin(params: {
|
||||
channel: DeliverableMessageChannel;
|
||||
cfg?: OpenClawConfig;
|
||||
}): void {
|
||||
const cfg = params.cfg;
|
||||
if (!cfg) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeRegistry = getActivePluginRegistry();
|
||||
if ((activeRegistry?.channels?.length ?? 0) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const registryKey = getActivePluginRegistryKey() ?? "<none>";
|
||||
const attemptKey = `${registryKey}:${params.channel}`;
|
||||
if (bootstrapAttempts.has(attemptKey)) {
|
||||
return;
|
||||
}
|
||||
bootstrapAttempts.add(attemptKey);
|
||||
|
||||
const autoEnabled = applyPluginAutoEnable({ config: cfg }).config;
|
||||
const defaultAgentId = resolveDefaultAgentId(autoEnabled);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(autoEnabled, defaultAgentId);
|
||||
loadOpenClawPlugins({
|
||||
config: autoEnabled,
|
||||
workspaceDir,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveOutboundChannelPlugin(params: {
|
||||
channel: string;
|
||||
cfg?: OpenClawConfig;
|
||||
}): ChannelPlugin | undefined {
|
||||
const normalized = normalizeDeliverableOutboundChannel(params.channel);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resolve = () => getChannelPlugin(normalized);
|
||||
const current = resolve();
|
||||
if (current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
maybeBootstrapChannelPlugin({ channel: normalized, cfg: params.cfg });
|
||||
return resolve();
|
||||
}
|
||||
@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({
|
||||
getChannelPlugin: vi.fn(),
|
||||
resolveOutboundTarget: vi.fn(),
|
||||
deliverOutboundPayloads: vi.fn(),
|
||||
loadOpenClawPlugins: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../channels/plugins/index.js", () => ({
|
||||
@@ -11,6 +12,19 @@ vi.mock("../../channels/plugins/index.js", () => ({
|
||||
getChannelPlugin: mocks.getChannelPlugin,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
resolveDefaultAgentId: () => "main",
|
||||
resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace",
|
||||
}));
|
||||
|
||||
vi.mock("../../config/plugin-auto-enable.js", () => ({
|
||||
applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/loader.js", () => ({
|
||||
loadOpenClawPlugins: mocks.loadOpenClawPlugins,
|
||||
}));
|
||||
|
||||
vi.mock("./targets.js", () => ({
|
||||
resolveOutboundTarget: mocks.resolveOutboundTarget,
|
||||
}));
|
||||
@@ -19,13 +33,17 @@ vi.mock("./deliver.js", () => ({
|
||||
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
|
||||
}));
|
||||
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { sendMessage } from "./message.js";
|
||||
|
||||
describe("sendMessage", () => {
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
mocks.getChannelPlugin.mockClear();
|
||||
mocks.resolveOutboundTarget.mockClear();
|
||||
mocks.deliverOutboundPayloads.mockClear();
|
||||
mocks.loadOpenClawPlugins.mockClear();
|
||||
|
||||
mocks.getChannelPlugin.mockReturnValue({
|
||||
outbound: { deliveryMode: "direct" },
|
||||
@@ -37,8 +55,8 @@ describe("sendMessage", () => {
|
||||
it("passes explicit agentId to outbound delivery for scoped media roots", async () => {
|
||||
await sendMessage({
|
||||
cfg: {},
|
||||
channel: "mattermost",
|
||||
to: "channel:town-square",
|
||||
channel: "telegram",
|
||||
to: "123456",
|
||||
content: "hi",
|
||||
agentId: "work",
|
||||
});
|
||||
@@ -46,9 +64,34 @@ describe("sendMessage", () => {
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: "work",
|
||||
channel: "mattermost",
|
||||
to: "channel:town-square",
|
||||
channel: "telegram",
|
||||
to: "123456",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("recovers telegram plugin resolution so message/send does not fail with Unknown channel: telegram", async () => {
|
||||
const telegramPlugin = {
|
||||
outbound: { deliveryMode: "direct" },
|
||||
};
|
||||
mocks.getChannelPlugin
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce(telegramPlugin)
|
||||
.mockReturnValue(telegramPlugin);
|
||||
|
||||
await expect(
|
||||
sendMessage({
|
||||
cfg: { channels: { telegram: { botToken: "test-token" } } },
|
||||
channel: "telegram",
|
||||
to: "123456",
|
||||
content: "hi",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
channel: "telegram",
|
||||
to: "123456",
|
||||
via: "direct",
|
||||
});
|
||||
|
||||
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { callGatewayLeastPrivilege, randomIdempotencyKey } from "../../gateway/call.js";
|
||||
@@ -10,6 +9,10 @@ import {
|
||||
type GatewayClientMode,
|
||||
type GatewayClientName,
|
||||
} from "../../utils/message-channel.js";
|
||||
import {
|
||||
normalizeDeliverableOutboundChannel,
|
||||
resolveOutboundChannelPlugin,
|
||||
} from "./channel-resolution.js";
|
||||
import { resolveMessageChannelSelection } from "./channel-selection.js";
|
||||
import {
|
||||
deliverOutboundPayloads,
|
||||
@@ -107,17 +110,18 @@ async function resolveRequiredChannel(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel?: string;
|
||||
}): Promise<string> {
|
||||
const channel = params.channel?.trim()
|
||||
? normalizeChannelId(params.channel)
|
||||
: (await resolveMessageChannelSelection({ cfg: params.cfg })).channel;
|
||||
if (!channel) {
|
||||
throw new Error(`Unknown channel: ${params.channel}`);
|
||||
if (params.channel?.trim()) {
|
||||
const normalized = normalizeDeliverableOutboundChannel(params.channel);
|
||||
if (!normalized) {
|
||||
throw new Error(`Unknown channel: ${params.channel}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
return channel;
|
||||
return (await resolveMessageChannelSelection({ cfg: params.cfg })).channel;
|
||||
}
|
||||
|
||||
function resolveRequiredPlugin(channel: string) {
|
||||
const plugin = getChannelPlugin(channel);
|
||||
function resolveRequiredPlugin(channel: string, cfg: OpenClawConfig) {
|
||||
const plugin = resolveOutboundChannelPlugin({ channel, cfg });
|
||||
if (!plugin) {
|
||||
throw new Error(`Unknown channel: ${channel}`);
|
||||
}
|
||||
@@ -166,7 +170,7 @@ async function callMessageGateway<T>(params: {
|
||||
export async function sendMessage(params: MessageSendParams): Promise<MessageSendResult> {
|
||||
const cfg = params.cfg ?? loadConfig();
|
||||
const channel = await resolveRequiredChannel({ cfg, channel: params.channel });
|
||||
const plugin = resolveRequiredPlugin(channel);
|
||||
const plugin = resolveRequiredPlugin(channel, cfg);
|
||||
const deliveryMode = plugin.outbound?.deliveryMode ?? "direct";
|
||||
const normalizedPayloads = normalizeReplyPayloadsForDelivery([
|
||||
{
|
||||
@@ -279,7 +283,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
|
||||
durationSeconds: params.durationSeconds,
|
||||
durationHours: params.durationHours,
|
||||
};
|
||||
const plugin = resolveRequiredPlugin(channel);
|
||||
const plugin = resolveRequiredPlugin(channel, cfg);
|
||||
const outbound = plugin?.outbound;
|
||||
if (!outbound?.sendPoll) {
|
||||
throw new Error(`Unsupported poll channel: ${channel}`);
|
||||
|
||||
61
src/infra/outbound/targets.channel-resolution.test.ts
Normal file
61
src/infra/outbound/targets.channel-resolution.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getChannelPlugin: vi.fn(),
|
||||
loadOpenClawPlugins: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../channels/plugins/index.js", () => ({
|
||||
getChannelPlugin: mocks.getChannelPlugin,
|
||||
normalizeChannelId: (channel?: string) => channel?.trim().toLowerCase() ?? undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
resolveDefaultAgentId: () => "main",
|
||||
resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace",
|
||||
}));
|
||||
|
||||
vi.mock("../../config/plugin-auto-enable.js", () => ({
|
||||
applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/loader.js", () => ({
|
||||
loadOpenClawPlugins: mocks.loadOpenClawPlugins,
|
||||
}));
|
||||
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { resolveOutboundTarget } from "./targets.js";
|
||||
|
||||
describe("resolveOutboundTarget channel resolution", () => {
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
mocks.getChannelPlugin.mockReset();
|
||||
mocks.loadOpenClawPlugins.mockReset();
|
||||
});
|
||||
|
||||
it("recovers telegram plugin resolution so announce delivery does not fail with Unsupported channel: telegram", () => {
|
||||
const telegramPlugin = {
|
||||
id: "telegram",
|
||||
meta: { label: "Telegram" },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
};
|
||||
mocks.getChannelPlugin
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce(telegramPlugin)
|
||||
.mockReturnValue(telegramPlugin);
|
||||
|
||||
const result = resolveOutboundTarget({
|
||||
channel: "telegram",
|
||||
to: "123456",
|
||||
cfg: { channels: { telegram: { botToken: "test-token" } } },
|
||||
mode: "explicit",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, to: "123456" });
|
||||
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { normalizeChatType, type ChatType } from "../../channels/chat-type.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
|
||||
import { formatCliCommand } from "../../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
@@ -20,6 +19,10 @@ import {
|
||||
normalizeMessageChannel,
|
||||
} from "../../utils/message-channel.js";
|
||||
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
|
||||
import {
|
||||
normalizeDeliverableOutboundChannel,
|
||||
resolveOutboundChannelPlugin,
|
||||
} from "./channel-resolution.js";
|
||||
import { missingTargetError } from "./target-errors.js";
|
||||
|
||||
export type OutboundChannel = DeliverableMessageChannel | "none";
|
||||
@@ -181,7 +184,10 @@ export function resolveOutboundTarget(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const plugin = getChannelPlugin(params.channel);
|
||||
const plugin = resolveOutboundChannelPlugin({
|
||||
channel: params.channel,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
if (!plugin) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -242,7 +248,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
||||
if (rawTarget === "none" || rawTarget === "last") {
|
||||
target = rawTarget;
|
||||
} else if (typeof rawTarget === "string") {
|
||||
const normalized = normalizeChannelId(rawTarget);
|
||||
const normalized = normalizeDeliverableOutboundChannel(rawTarget);
|
||||
if (normalized) {
|
||||
target = normalized;
|
||||
}
|
||||
@@ -269,7 +275,10 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
||||
let effectiveAccountId = heartbeatAccountId || resolvedTarget.accountId;
|
||||
|
||||
if (heartbeatAccountId && resolvedTarget.channel) {
|
||||
const plugin = getChannelPlugin(resolvedTarget.channel);
|
||||
const plugin = resolveOutboundChannelPlugin({
|
||||
channel: resolvedTarget.channel,
|
||||
cfg,
|
||||
});
|
||||
const listAccountIds = plugin?.config.listAccountIds;
|
||||
const accountIds = listAccountIds ? listAccountIds(cfg) : [];
|
||||
if (accountIds.length > 0) {
|
||||
@@ -331,7 +340,10 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
||||
}
|
||||
|
||||
let reason: string | undefined;
|
||||
const plugin = getChannelPlugin(resolvedTarget.channel);
|
||||
const plugin = resolveOutboundChannelPlugin({
|
||||
channel: resolvedTarget.channel,
|
||||
cfg,
|
||||
});
|
||||
if (plugin?.config.resolveAllowFrom) {
|
||||
const explicit = resolveOutboundTarget({
|
||||
channel: resolvedTarget.channel,
|
||||
@@ -516,7 +528,10 @@ export function resolveHeartbeatSenderContext(params: {
|
||||
params.delivery.accountId ??
|
||||
(provider === params.delivery.lastChannel ? params.delivery.lastAccountId : undefined);
|
||||
const allowFromRaw = provider
|
||||
? (getChannelPlugin(provider)?.config.resolveAllowFrom?.({
|
||||
? (resolveOutboundChannelPlugin({
|
||||
channel: provider,
|
||||
cfg: params.cfg,
|
||||
})?.config.resolveAllowFrom?.({
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
}) ?? [])
|
||||
|
||||
Reference in New Issue
Block a user