From 0ca1b18517b3da6cb463a52472d58de31b852d82 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 00:09:50 -0700 Subject: [PATCH] fix(core): restore outbound fallbacks and gate checks --- src/infra/bonjour.ts | 1 + src/infra/exec-approval-forwarder.ts | 13 +- .../outbound/built-in-channel-adapters.ts | 127 ++++++++++++++++++ .../outbound/built-in-channel-messaging.ts | 41 ++++++ src/infra/outbound/channel-adapters.ts | 5 +- src/infra/outbound/targets.test.ts | 18 +++ src/infra/outbound/targets.ts | 14 +- ui/src/ui/app-scroll.ts | 10 +- 8 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 src/infra/outbound/built-in-channel-adapters.ts create mode 100644 src/infra/outbound/built-in-channel-messaging.ts diff --git a/src/infra/bonjour.ts b/src/infra/bonjour.ts index 2b500986f33..e7ed5adf7f1 100644 --- a/src/infra/bonjour.ts +++ b/src/infra/bonjour.ts @@ -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"; diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index 0c0c8e30e48..eba329f57ff 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -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, diff --git a/src/infra/outbound/built-in-channel-adapters.ts b/src/infra/outbound/built-in-channel-adapters.ts new file mode 100644 index 00000000000..335457dc48c --- /dev/null +++ b/src/infra/outbound/built-in-channel-adapters.ts @@ -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 = []; + 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; +} diff --git a/src/infra/outbound/built-in-channel-messaging.ts b/src/infra/outbound/built-in-channel-messaging.ts new file mode 100644 index 00000000000..b06b6c41bc3 --- /dev/null +++ b/src/infra/outbound/built-in-channel-messaging.ts @@ -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; +} diff --git a/src/infra/outbound/channel-adapters.ts b/src/infra/outbound/channel-adapters.ts index 0c752854e8d..4f541bf0f84 100644 --- a/src/infra/outbound/channel-adapters.ts +++ b/src/infra/outbound/channel-adapters.ts @@ -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, diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 76bb9a2b3b5..cfe8d06bd25 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -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: { diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 3a584473b8c..313db3cf636 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -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: { diff --git a/ui/src/ui/app-scroll.ts b/ui/src/ui/app-scroll.ts index d8bd1d5b077..c5b75d24a64 100644 --- a/ui/src/ui/app-scroll.ts +++ b/ui/src/ui/app-scroll.ts @@ -15,6 +15,10 @@ type ScrollHost = { topbarObserver: ResizeObserver | null; }; +function queryHost(host: Partial, 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; }