fix(core): restore outbound fallbacks and gate checks

This commit is contained in:
Peter Steinberger
2026-03-16 00:09:50 -07:00
parent e7eb410dd1
commit 0ca1b18517
8 changed files with 218 additions and 11 deletions

View File

@@ -1,3 +1,4 @@
import type { CiaoService, Responder } from "@homebridge/ciao";
import { logDebug, logWarn } from "../logger.js";
import { getLogger } from "../logging.js";
import { ignoreCiaoCancellationRejection } from "./bonjour-ciao.js";

View File

@@ -22,6 +22,7 @@ import type {
ExecApprovalRequest,
ExecApprovalResolved,
} from "./exec-approvals.js";
import { resolveBuiltInExecApprovalAdapter } from "./outbound/built-in-channel-adapters.js";
import { deliverOutboundPayloads } from "./outbound/deliver.js";
const log = createSubsystemLogger("gateway/exec-approvals");
@@ -118,8 +119,10 @@ function shouldSkipForwardingFallback(params: {
if (!channel) {
return false;
}
const adapter =
getChannelPlugin(channel)?.execApprovals ?? resolveBuiltInExecApprovalAdapter(channel);
return (
getChannelPlugin(channel)?.execApprovals?.shouldSuppressForwardingFallback?.({
adapter?.shouldSuppressForwardingFallback?.({
cfg: params.cfg,
target: params.target,
request: params.request,
@@ -275,7 +278,9 @@ function buildRequestPayloadForTarget(
): ReplyPayload {
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
const pluginPayload = channel
? getChannelPlugin(channel)?.execApprovals?.buildPendingPayload?.({
? (
getChannelPlugin(channel)?.execApprovals ?? resolveBuiltInExecApprovalAdapter(channel)
)?.buildPendingPayload?.({
cfg,
request,
target,
@@ -410,7 +415,9 @@ export function createExecApprovalForwarder(
if (!channel) {
return;
}
await getChannelPlugin(channel)?.execApprovals?.beforeDeliverPending?.({
await (
getChannelPlugin(channel)?.execApprovals ?? resolveBuiltInExecApprovalAdapter(channel)
)?.beforeDeliverPending?.({
cfg,
target,
payload,

View File

@@ -0,0 +1,127 @@
import { Separator, TextDisplay } from "@buape/carbon";
import {
listDiscordAccountIds,
resolveDiscordAccount,
} from "../../../extensions/discord/src/accounts.js";
import { isDiscordExecApprovalClientEnabled } from "../../../extensions/discord/src/exec-approvals.js";
import { DiscordUiContainer } from "../../../extensions/discord/src/ui.js";
import { listTelegramAccountIds } from "../../../extensions/telegram/src/accounts.js";
import { buildTelegramExecApprovalButtons } from "../../../extensions/telegram/src/approval-buttons.js";
import {
isTelegramExecApprovalClientEnabled,
resolveTelegramExecApprovalTarget,
} from "../../../extensions/telegram/src/exec-approvals.js";
import type { ChannelExecApprovalAdapter } from "../../channels/plugins/types.adapters.js";
import type { ChannelCrossContextComponentsFactory } from "../../channels/plugins/types.core.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
import { resolveExecApprovalCommandDisplay } from "../exec-approval-command-display.js";
import { buildExecApprovalPendingReplyPayload } from "../exec-approval-reply.js";
const BUILT_IN_DISCORD_CROSS_CONTEXT_COMPONENTS: ChannelCrossContextComponentsFactory = (
params,
) => {
const trimmed = params.message.trim();
const components: Array<TextDisplay | Separator> = [];
if (trimmed) {
components.push(new TextDisplay(params.message));
components.push(new Separator({ divider: true, spacing: "small" }));
}
components.push(new TextDisplay(`*From ${params.originLabel}*`));
return [new DiscordUiContainer({ cfg: params.cfg, accountId: params.accountId, components })];
};
function hasDiscordExecApprovalDmRoute(cfg: OpenClawConfig): boolean {
return listDiscordAccountIds(cfg).some((accountId) => {
const execApprovals = resolveDiscordAccount({ cfg, accountId }).config.execApprovals;
if (!execApprovals?.enabled || (execApprovals.approvers?.length ?? 0) === 0) {
return false;
}
const target = execApprovals.target ?? "dm";
return target === "dm" || target === "both";
});
}
function hasTelegramExecApprovalDmRoute(cfg: OpenClawConfig): boolean {
return listTelegramAccountIds(cfg).some((accountId) => {
if (!isTelegramExecApprovalClientEnabled({ cfg, accountId })) {
return false;
}
const target = resolveTelegramExecApprovalTarget({ cfg, accountId });
return target === "dm" || target === "both";
});
}
const BUILT_IN_DISCORD_EXEC_APPROVALS: ChannelExecApprovalAdapter = {
getInitiatingSurfaceState: ({ cfg, accountId }) =>
isDiscordExecApprovalClientEnabled({ cfg, accountId })
? { kind: "enabled" }
: { kind: "disabled" },
hasConfiguredDmRoute: ({ cfg }) => hasDiscordExecApprovalDmRoute(cfg),
shouldSuppressForwardingFallback: ({ cfg, target }) =>
(normalizeMessageChannel(target.channel) ?? target.channel) === "discord" &&
isDiscordExecApprovalClientEnabled({ cfg, accountId: target.accountId }),
};
const BUILT_IN_TELEGRAM_EXEC_APPROVALS: ChannelExecApprovalAdapter = {
getInitiatingSurfaceState: ({ cfg, accountId }) =>
isTelegramExecApprovalClientEnabled({ cfg, accountId })
? { kind: "enabled" }
: { kind: "disabled" },
hasConfiguredDmRoute: ({ cfg }) => hasTelegramExecApprovalDmRoute(cfg),
shouldSuppressForwardingFallback: ({ cfg, target, request }) => {
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
if (channel !== "telegram") {
return false;
}
const requestChannel = normalizeMessageChannel(request.request.turnSourceChannel ?? "");
if (requestChannel !== "telegram") {
return false;
}
const accountId = target.accountId?.trim() || request.request.turnSourceAccountId?.trim();
return isTelegramExecApprovalClientEnabled({ cfg, accountId });
},
buildPendingPayload: ({ request, nowMs }) => {
const payload = buildExecApprovalPendingReplyPayload({
approvalId: request.id,
approvalSlug: request.id.slice(0, 8),
approvalCommandId: request.id,
command: resolveExecApprovalCommandDisplay(request.request).commandText,
cwd: request.request.cwd ?? undefined,
host: request.request.host === "node" ? "node" : "gateway",
nodeId: request.request.nodeId ?? undefined,
expiresAtMs: request.expiresAtMs,
nowMs,
});
const buttons = buildTelegramExecApprovalButtons(request.id);
if (!buttons) {
return payload;
}
return {
...payload,
channelData: {
...payload.channelData,
telegram: { buttons },
},
};
},
};
export function resolveBuiltInCrossContextComponentsFactory(
channel: ChannelId,
): ChannelCrossContextComponentsFactory | undefined {
return channel === "discord" ? BUILT_IN_DISCORD_CROSS_CONTEXT_COMPONENTS : undefined;
}
export function resolveBuiltInExecApprovalAdapter(
channel: ChannelId,
): ChannelExecApprovalAdapter | undefined {
if (channel === "discord") {
return BUILT_IN_DISCORD_EXEC_APPROVALS;
}
if (channel === "telegram") {
return BUILT_IN_TELEGRAM_EXEC_APPROVALS;
}
return undefined;
}

View File

@@ -0,0 +1,41 @@
import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js";
import type { ChatType } from "../../channels/chat-type.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
export type BuiltInExplicitTarget = {
to: string;
threadId?: string | number;
chatType?: ChatType;
};
export function resolveBuiltInExplicitTarget(
channel: ChannelId,
raw: string,
): BuiltInExplicitTarget | null {
if (channel === "telegram") {
const target = parseTelegramTarget(raw);
return {
to: target.chatId,
threadId: target.messageThreadId,
chatType: target.chatType === "unknown" ? undefined : target.chatType,
};
}
if (channel === "whatsapp") {
const normalized = normalizeWhatsAppTarget(raw);
if (!normalized) {
return null;
}
return {
to: normalized,
chatType: isWhatsAppGroupJid(normalized) ? "group" : "direct",
};
}
return null;
}
export function resolveBuiltInTargetChatType(channel: ChannelId, to: string): ChatType | undefined {
return resolveBuiltInExplicitTarget(channel, to)?.chatType;
}

View File

@@ -2,6 +2,7 @@ import type { TopLevelComponents } from "@buape/carbon";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { resolveBuiltInCrossContextComponentsFactory } from "./built-in-channel-adapters.js";
export type CrossContextComponentsBuilder = (message: string) => TopLevelComponents[];
@@ -22,7 +23,9 @@ const DEFAULT_ADAPTER: ChannelMessageAdapter = {
};
export function getChannelMessageAdapter(channel: ChannelId): ChannelMessageAdapter {
const adapter = getChannelPlugin(channel)?.messaging?.buildCrossContextComponents;
const adapter =
getChannelPlugin(channel)?.messaging?.buildCrossContextComponents ??
resolveBuiltInCrossContextComponentsFactory(channel);
if (adapter) {
return {
supportsComponentsV2: true,

View File

@@ -387,6 +387,24 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.threadId).toBeUndefined();
});
it("keeps :topic: parsing when the telegram plugin registry is unavailable", () => {
setActivePluginRegistry(createTestRegistry([]));
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-no-registry",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "63448508",
},
requestedChannel: "last",
explicitTo: "63448508:topic:1008013",
});
expect(resolved.to).toBe("63448508");
expect(resolved.threadId).toBe(1008013);
});
it("explicitThreadId takes priority over :topic: parsed value", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {

View File

@@ -16,6 +16,10 @@ import {
isDeliverableMessageChannel,
normalizeMessageChannel,
} from "../../utils/message-channel.js";
import {
resolveBuiltInExplicitTarget,
resolveBuiltInTargetChatType,
} from "./built-in-channel-messaging.js";
import {
normalizeDeliverableOutboundChannel,
resolveOutboundChannelPlugin,
@@ -74,7 +78,7 @@ function parseExplicitTargetWithPlugin(params: {
return (
resolveOutboundChannelPlugin({ channel: provider })?.messaging?.parseExplicitTarget?.({
raw,
}) ?? null
}) ?? resolveBuiltInExplicitTarget(provider, raw)
);
}
@@ -415,9 +419,11 @@ function inferChatTypeFromTarget(params: {
if (/^group:/i.test(to)) {
return "group";
}
return resolveOutboundChannelPlugin({
channel: params.channel,
})?.messaging?.inferTargetChatType?.({ to });
return (
resolveOutboundChannelPlugin({
channel: params.channel,
})?.messaging?.inferTargetChatType?.({ to }) ?? resolveBuiltInTargetChatType(params.channel, to)
);
}
function resolveHeartbeatDeliveryChatType(params: {

View File

@@ -15,6 +15,10 @@ type ScrollHost = {
topbarObserver: ResizeObserver | null;
};
function queryHost(host: Partial<ScrollHost>, selectors: string): Element | null {
return typeof host.querySelector === "function" ? host.querySelector(selectors) : null;
}
export function scheduleChatScroll(host: ScrollHost, force = false, smooth = false) {
if (host.chatScrollFrame) {
cancelAnimationFrame(host.chatScrollFrame);
@@ -24,7 +28,7 @@ export function scheduleChatScroll(host: ScrollHost, force = false, smooth = fal
host.chatScrollTimeout = null;
}
const pickScrollTarget = () => {
const container = host.querySelector(".chat-thread") as HTMLElement | null;
const container = queryHost(host, ".chat-thread") as HTMLElement | null;
if (container) {
const overflowY = getComputedStyle(container).overflowY;
const canScroll =
@@ -104,7 +108,7 @@ export function scheduleLogsScroll(host: ScrollHost, force = false) {
void host.updateComplete.then(() => {
host.logsScrollFrame = requestAnimationFrame(() => {
host.logsScrollFrame = null;
const container = host.querySelector(".log-stream") as HTMLElement | null;
const container = queryHost(host, ".log-stream") as HTMLElement | null;
if (!container) {
return;
}
@@ -165,7 +169,7 @@ export function observeTopbar(host: ScrollHost) {
if (typeof ResizeObserver === "undefined") {
return;
}
const topbar = host.querySelector(".topbar");
const topbar = queryHost(host, ".topbar");
if (!topbar) {
return;
}