refactor: finish plugin-owned channel runtime seams

This commit is contained in:
Peter Steinberger
2026-03-16 00:24:40 -07:00
parent e90c1d9add
commit 7964563299
29 changed files with 488 additions and 296 deletions

View File

@@ -41,9 +41,17 @@ import {
type OpenClawConfig,
type ResolvedDiscordAccount,
} from "openclaw/plugin-sdk/discord";
import {
buildAgentSessionKey,
resolveThreadSessionKeys,
type RoutePeer,
} from "openclaw/plugin-sdk/routing";
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
import { normalizeMessageChannel } from "../../../src/utils/message-channel.js";
import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js";
import {
isDiscordExecApprovalClientEnabled,
shouldSuppressLocalDiscordExecApprovalPrompt,
} from "./exec-approvals.js";
import type { DiscordProbe } from "./probe.js";
import { resolveDiscordUserAllowlist } from "./resolve-users.js";
import { getDiscordRuntime } from "./runtime.js";
@@ -453,6 +461,12 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
isDiscordExecApprovalClientEnabled({ cfg, accountId })
? { kind: "enabled" }
: { kind: "disabled" },
shouldSuppressLocalPrompt: ({ cfg, accountId, payload }) =>
shouldSuppressLocalDiscordExecApprovalPrompt({
cfg,
accountId,
payload,
}),
hasConfiguredDmRoute: ({ cfg }) => hasDiscordExecApprovalDmRoute(cfg),
shouldSuppressForwardingFallback: ({ cfg, target }) =>
(normalizeMessageChannel(target.channel) ?? target.channel) === "discord" &&

View File

@@ -27,6 +27,7 @@ import {
type ChannelPlugin,
type ResolvedIMessageAccount,
} from "openclaw/plugin-sdk/imessage";
import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing";
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
import { getIMessageRuntime } from "./runtime.js";

View File

@@ -4,7 +4,7 @@ import {
createScopedAccountConfigAccessors,
collectAllowlistProviderRestrictSendersWarnings,
} from "openclaw/plugin-sdk/compat";
import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk/core";
import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing";
import {
buildBaseAccountStatusSnapshot,
buildBaseChannelStatusSummary,

View File

@@ -11,7 +11,7 @@ import {
buildAgentSessionKey,
resolveThreadSessionKeys,
type RoutePeer,
} from "openclaw/plugin-sdk/core";
} from "openclaw/plugin-sdk/routing";
import {
buildComputedAccountStatusSnapshot,
buildChannelConfigSchema,

View File

@@ -11,7 +11,7 @@ import {
buildAgentSessionKey,
resolveThreadSessionKeys,
type RoutePeer,
} from "openclaw/plugin-sdk/core";
} from "openclaw/plugin-sdk/routing";
import {
buildChannelConfigSchema,
buildTokenChannelStatusSummary,

View File

@@ -48,6 +48,10 @@
"types": "./dist/plugin-sdk/compat.d.ts",
"default": "./dist/plugin-sdk/compat.js"
},
"./plugin-sdk/routing": {
"types": "./dist/plugin-sdk/routing.d.ts",
"default": "./dist/plugin-sdk/routing.js"
},
"./plugin-sdk/telegram": {
"types": "./dist/plugin-sdk/telegram.d.ts",
"default": "./dist/plugin-sdk/telegram.js"

View File

@@ -2,6 +2,7 @@
"index",
"core",
"compat",
"routing",
"telegram",
"discord",
"slack",

View File

@@ -1,4 +1,3 @@
import { parseDiscordTarget } from "../../../../extensions/discord/src/targets.js";
import { resolveStoredSubagentCapabilities } from "../../../agents/subagent-capabilities.js";
import type { ResolvedSubagentController } from "../../../agents/subagent-control.js";
import {
@@ -12,6 +11,7 @@ import {
sanitizeTextContent,
stripToolMessages,
} from "../../../agents/tools/sessions-helpers.js";
import { parseExplicitTargetForChannel } from "../../../channels/plugins/target-parsing.js";
import type {
SessionEntry,
loadSessionStore as loadSessionStoreFn,
@@ -335,13 +335,9 @@ export function resolveDiscordChannelIdForFocus(
typeof params.ctx.To === "string" ? params.ctx.To.trim() : "",
].filter(Boolean);
for (const candidate of toCandidates) {
try {
const target = parseDiscordTarget(candidate, { defaultKind: "channel" });
if (target?.kind === "channel" && target.id) {
return target.id;
}
} catch {
// Ignore parse failures and try the next candidate.
const target = parseExplicitTargetForChannel("discord", candidate);
if (target?.chatType === "channel" && target.to) {
return target.to;
}
}
return undefined;

View File

@@ -1,8 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { discordPlugin } from "../../../extensions/discord/src/channel.js";
import { AcpRuntimeError } from "../../acp/runtime/errors.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
import type { PluginTargetedInboundClaimOutcome } from "../../plugins/hooks.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
import type { MsgContext } from "../templating.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
@@ -252,6 +255,9 @@ async function dispatchTwiceWithFreshDispatchers(params: Omit<DispatchReplyArgs,
describe("dispatchReplyFromConfig", () => {
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]),
);
acpManagerTesting.resetAcpSessionManagerForTests();
resetInboundDedupe();
mocks.routeReply.mockReset();
@@ -1295,6 +1301,11 @@ describe("dispatchReplyFromConfig", () => {
commands: {
text: false,
},
session: {
sendPolicy: {
default: "allow",
},
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({

View File

@@ -1,5 +1,5 @@
import { shouldSuppressLocalDiscordExecApprovalPrompt } from "../../../extensions/discord/src/exec-approvals.js";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { shouldSuppressLocalExecApprovalPrompt } from "../../channels/plugins/exec-approval-local.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
loadSessionStore,
@@ -506,8 +506,8 @@ export async function dispatchReplyFromConfig(params: {
const resolveToolDeliveryPayload = (payload: ReplyPayload): ReplyPayload | null => {
if (
normalizeMessageChannel(ctx.Surface ?? ctx.Provider) === "discord" &&
shouldSuppressLocalDiscordExecApprovalPrompt({
shouldSuppressLocalExecApprovalPrompt({
channel: normalizeMessageChannel(ctx.Surface ?? ctx.Provider),
cfg,
accountId: ctx.AccountId,
payload,

View File

@@ -1,7 +1,7 @@
import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js";
import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js";
import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js";
import { normalizeChannelId } from "../../channels/plugins/index.js";
import { parseExplicitTargetForChannel } from "../../channels/plugins/target-parsing.js";
import type { ReplyToMode } from "../../config/types.js";
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js";
@@ -210,15 +210,16 @@ function targetsMatchForSuppression(params: {
return params.targetKey === params.originTarget;
}
const origin = parseTelegramTarget(params.originTarget);
const target = parseTelegramTarget(params.targetKey);
const origin = parseExplicitTargetForChannel("telegram", params.originTarget);
const target = parseExplicitTargetForChannel("telegram", params.targetKey);
if (!origin || !target) {
return params.targetKey === params.originTarget;
}
const explicitTargetThreadId = normalizeThreadIdForComparison(params.targetThreadId);
const targetThreadId =
explicitTargetThreadId ??
(target.messageThreadId != null ? String(target.messageThreadId) : undefined);
const originThreadId =
origin.messageThreadId != null ? String(origin.messageThreadId) : undefined;
if (origin.chatId.trim().toLowerCase() !== target.chatId.trim().toLowerCase()) {
explicitTargetThreadId ?? (target.threadId != null ? String(target.threadId) : undefined);
const originThreadId = origin.threadId != null ? String(origin.threadId) : undefined;
if (origin.to.trim().toLowerCase() !== target.to.trim().toLowerCase()) {
return false;
}
if (originThreadId && targetThreadId != null) {

View File

@@ -1,6 +1,15 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { resolveTelegramConversationId } from "./telegram-context.js";
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]),
);
});
describe("resolveTelegramConversationId", () => {
it("builds canonical topic ids from chat target and message thread id", () => {
const conversationId = resolveTelegramConversationId({

View File

@@ -1,4 +1,4 @@
import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js";
import { parseExplicitTargetForChannel } from "../../channels/plugins/target-parsing.js";
type TelegramConversationParams = {
ctx: {
@@ -25,7 +25,7 @@ export function resolveTelegramConversationId(
.map((value) => value.trim())
.filter(Boolean);
const chatId = toCandidates
.map((candidate) => parseTelegramTarget(candidate).chatId.trim())
.map((candidate) => parseExplicitTargetForChannel("telegram", candidate)?.to.trim() ?? "")
.find((candidate) => candidate.length > 0);
if (!chatId) {
return undefined;

View File

@@ -1,12 +1,13 @@
import { inspectDiscordAccount } from "../../../extensions/discord/src/account-inspect.js";
import { inspectSlackAccount } from "../../../extensions/slack/src/account-inspect.js";
import { inspectTelegramAccount } from "../../../extensions/telegram/src/account-inspect.js";
import { resolveWhatsAppAccount } from "../../../extensions/whatsapp/src/accounts.js";
import type { OpenClawConfig } from "../../config/types.js";
import { mapAllowFromEntries } from "../../plugin-sdk/channel-config-helpers.js";
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
import type { InspectedDiscordAccount } from "../read-only-account-inspect.discord.runtime.js";
import { inspectReadOnlyChannelAccount } from "../read-only-account-inspect.js";
import type { InspectedSlackAccount } from "../read-only-account-inspect.slack.runtime.js";
import type { InspectedTelegramAccount } from "../read-only-account-inspect.telegram.runtime.js";
import { applyDirectoryQueryAndLimit, toDirectoryEntries } from "./directory-config-helpers.js";
import { normalizeSlackMessagingTarget } from "./normalize/slack.js";
import { getChannelPlugin } from "./registry.js";
import type { ChannelDirectoryEntry } from "./types.js";
export type DirectoryConfigParams = {
@@ -58,7 +59,14 @@ function normalizeTrimmedSet(
export async function listSlackDirectoryPeersFromConfig(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const account = inspectSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const account = (await inspectReadOnlyChannelAccount({
channelId: "slack",
cfg: params.cfg,
accountId: params.accountId,
})) as InspectedSlackAccount | null;
if (!account || !("config" in account)) {
return [];
}
const ids = new Set<string>();
addAllowFromAndDmsIds(ids, account.config.allowFrom ?? account.dm?.allowFrom, account.config.dms);
@@ -81,7 +89,14 @@ export async function listSlackDirectoryPeersFromConfig(
export async function listSlackDirectoryGroupsFromConfig(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const account = inspectSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const account = (await inspectReadOnlyChannelAccount({
channelId: "slack",
cfg: params.cfg,
accountId: params.accountId,
})) as InspectedSlackAccount | null;
if (!account || !("config" in account)) {
return [];
}
const ids = Object.keys(account.config.channels ?? {})
.map((raw) => raw.trim())
.filter(Boolean)
@@ -93,7 +108,14 @@ export async function listSlackDirectoryGroupsFromConfig(
export async function listDiscordDirectoryPeersFromConfig(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
const account = (await inspectReadOnlyChannelAccount({
channelId: "discord",
cfg: params.cfg,
accountId: params.accountId,
})) as InspectedDiscordAccount | null;
if (!account || !("config" in account)) {
return [];
}
const ids = new Set<string>();
addAllowFromAndDmsIds(
@@ -122,7 +144,14 @@ export async function listDiscordDirectoryPeersFromConfig(
export async function listDiscordDirectoryGroupsFromConfig(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
const account = (await inspectReadOnlyChannelAccount({
channelId: "discord",
cfg: params.cfg,
accountId: params.accountId,
})) as InspectedDiscordAccount | null;
if (!account || !("config" in account)) {
return [];
}
const ids = new Set<string>();
for (const guild of Object.values(account.config.guilds ?? {})) {
addTrimmedEntries(ids, Object.keys(guild.channels ?? {}));
@@ -142,7 +171,14 @@ export async function listDiscordDirectoryGroupsFromConfig(
export async function listTelegramDirectoryPeersFromConfig(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const account = inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
const account = (await inspectReadOnlyChannelAccount({
channelId: "telegram",
cfg: params.cfg,
accountId: params.accountId,
})) as InspectedTelegramAccount | null;
if (!account || !("config" in account)) {
return [];
}
const raw = [
...mapAllowFromEntries(account.config.allowFrom),
...Object.keys(account.config.dms ?? {}),
@@ -173,7 +209,14 @@ export async function listTelegramDirectoryPeersFromConfig(
export async function listTelegramDirectoryGroupsFromConfig(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const account = inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
const account = (await inspectReadOnlyChannelAccount({
channelId: "telegram",
cfg: params.cfg,
accountId: params.accountId,
})) as InspectedTelegramAccount | null;
if (!account || !("config" in account)) {
return [];
}
const ids = Object.keys(account.config.groups ?? {})
.map((id) => id.trim())
.filter((id) => Boolean(id) && id !== "*");
@@ -183,9 +226,15 @@ export async function listTelegramDirectoryGroupsFromConfig(
export async function listWhatsAppDirectoryPeersFromConfig(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId });
const account = getChannelPlugin("whatsapp")?.config.resolveAccount(
params.cfg,
params.accountId,
) as { allowFrom?: unknown[] } | null | undefined;
if (!account || typeof account !== "object") {
return [];
}
const ids = (account.allowFrom ?? [])
.map((entry) => String(entry).trim())
.map((entry: unknown) => String(entry).trim())
.filter((entry) => Boolean(entry) && entry !== "*")
.map((entry) => normalizeWhatsAppTarget(entry) ?? "")
.filter(Boolean)
@@ -196,7 +245,13 @@ export async function listWhatsAppDirectoryPeersFromConfig(
export async function listWhatsAppDirectoryGroupsFromConfig(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId });
const account = getChannelPlugin("whatsapp")?.config.resolveAccount(
params.cfg,
params.accountId,
) as { groups?: Record<string, unknown> } | null | undefined;
if (!account || typeof account !== "object") {
return [];
}
const ids = Object.keys(account.groups ?? {})
.map((id) => id.trim())
.filter((id) => Boolean(id) && id !== "*");

View File

@@ -0,0 +1,22 @@
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { getChannelPlugin, normalizeChannelId } from "./registry.js";
export function shouldSuppressLocalExecApprovalPrompt(params: {
channel?: string | null;
cfg: OpenClawConfig;
accountId?: string | null;
payload: ReplyPayload;
}): boolean {
const channel = params.channel ? normalizeChannelId(params.channel) : null;
if (!channel) {
return false;
}
return (
getChannelPlugin(channel)?.execApprovals?.shouldSuppressLocalPrompt?.({
cfg: params.cfg,
accountId: params.accountId,
payload: params.payload,
}) ?? false
);
}

View File

@@ -1,93 +1,4 @@
import {
getActivePluginRegistryVersion,
requireActivePluginRegistry,
} from "../../plugins/runtime.js";
import { CHAT_CHANNEL_ORDER, type ChatChannelId, normalizeAnyChannelId } from "../registry.js";
import type { ChannelId, ChannelPlugin } from "./types.js";
// Channel plugins registry (runtime).
//
// This module is intentionally "heavy" (plugins may import channel monitors, web login, etc).
// Shared code paths should prefer narrower adapters and helpers instead of reaching into
// channel-specific runtime modules directly.
//
function dedupeChannels(channels: ChannelPlugin[]): ChannelPlugin[] {
const seen = new Set<string>();
const resolved: ChannelPlugin[] = [];
for (const plugin of channels) {
const id = String(plugin.id).trim();
if (!id || seen.has(id)) {
continue;
}
seen.add(id);
resolved.push(plugin);
}
return resolved;
}
type CachedChannelPlugins = {
registryVersion: number;
sorted: ChannelPlugin[];
byId: Map<string, ChannelPlugin>;
};
const EMPTY_CHANNEL_PLUGIN_CACHE: CachedChannelPlugins = {
registryVersion: -1,
sorted: [],
byId: new Map(),
};
let cachedChannelPlugins = EMPTY_CHANNEL_PLUGIN_CACHE;
function resolveCachedChannelPlugins(): CachedChannelPlugins {
const registry = requireActivePluginRegistry();
const registryVersion = getActivePluginRegistryVersion();
const cached = cachedChannelPlugins;
if (cached.registryVersion === registryVersion) {
return cached;
}
const sorted = dedupeChannels(registry.channels.map((entry) => entry.plugin)).toSorted((a, b) => {
const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId);
const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId);
const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA);
const orderB = b.meta.order ?? (indexB === -1 ? 999 : indexB);
if (orderA !== orderB) {
return orderA - orderB;
}
return a.id.localeCompare(b.id);
});
const byId = new Map<string, ChannelPlugin>();
for (const plugin of sorted) {
byId.set(plugin.id, plugin);
}
const next: CachedChannelPlugins = {
registryVersion,
sorted,
byId,
};
cachedChannelPlugins = next;
return next;
}
export function listChannelPlugins(): ChannelPlugin[] {
return resolveCachedChannelPlugins().sorted.slice();
}
export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
const resolvedId = String(id).trim();
if (!resolvedId) {
return undefined;
}
return resolveCachedChannelPlugins().byId.get(resolvedId);
}
export function normalizeChannelId(raw?: string | null): ChannelId | null {
// Channel docking: keep input normalization centralized in src/channels/registry.ts.
// Plugin registry must be initialized before calling.
return normalizeAnyChannelId(raw);
}
export { getChannelPlugin, listChannelPlugins, normalizeChannelId } from "./registry.js";
export {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,

View File

@@ -0,0 +1,82 @@
import {
getActivePluginRegistryVersion,
requireActivePluginRegistry,
} from "../../plugins/runtime.js";
import { CHAT_CHANNEL_ORDER, type ChatChannelId, normalizeAnyChannelId } from "../registry.js";
import type { ChannelId, ChannelPlugin } from "./types.js";
function dedupeChannels(channels: ChannelPlugin[]): ChannelPlugin[] {
const seen = new Set<string>();
const resolved: ChannelPlugin[] = [];
for (const plugin of channels) {
const id = String(plugin.id).trim();
if (!id || seen.has(id)) {
continue;
}
seen.add(id);
resolved.push(plugin);
}
return resolved;
}
type CachedChannelPlugins = {
registryVersion: number;
sorted: ChannelPlugin[];
byId: Map<string, ChannelPlugin>;
};
const EMPTY_CHANNEL_PLUGIN_CACHE: CachedChannelPlugins = {
registryVersion: -1,
sorted: [],
byId: new Map(),
};
let cachedChannelPlugins = EMPTY_CHANNEL_PLUGIN_CACHE;
function resolveCachedChannelPlugins(): CachedChannelPlugins {
const registry = requireActivePluginRegistry();
const registryVersion = getActivePluginRegistryVersion();
const cached = cachedChannelPlugins;
if (cached.registryVersion === registryVersion) {
return cached;
}
const sorted = dedupeChannels(registry.channels.map((entry) => entry.plugin)).toSorted((a, b) => {
const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId);
const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId);
const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA);
const orderB = b.meta.order ?? (indexB === -1 ? 999 : indexB);
if (orderA !== orderB) {
return orderA - orderB;
}
return a.id.localeCompare(b.id);
});
const byId = new Map<string, ChannelPlugin>();
for (const plugin of sorted) {
byId.set(plugin.id, plugin);
}
const next: CachedChannelPlugins = {
registryVersion,
sorted,
byId,
};
cachedChannelPlugins = next;
return next;
}
export function listChannelPlugins(): ChannelPlugin[] {
return resolveCachedChannelPlugins().sorted.slice();
}
export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
const resolvedId = String(id).trim();
if (!resolvedId) {
return undefined;
}
return resolveCachedChannelPlugins().byId.get(resolvedId);
}
export function normalizeChannelId(raw?: string | null): ChannelId | null {
return normalizeAnyChannelId(raw);
}

View File

@@ -0,0 +1,26 @@
import type { ChatType } from "../chat-type.js";
import { getChannelPlugin, normalizeChannelId } from "./registry.js";
export type ParsedChannelExplicitTarget = {
to: string;
threadId?: string | number;
chatType?: ChatType;
};
function parseWithPlugin(
rawChannel: string,
rawTarget: string,
): ParsedChannelExplicitTarget | null {
const channel = normalizeChannelId(rawChannel);
if (!channel) {
return null;
}
return getChannelPlugin(channel)?.messaging?.parseExplicitTarget?.({ raw: rawTarget }) ?? null;
}
export function parseExplicitTargetForChannel(
channel: string,
rawTarget: string,
): ParsedChannelExplicitTarget | null {
return parseWithPlugin(channel, rawTarget);
}

View File

@@ -456,6 +456,11 @@ export type ChannelExecApprovalAdapter = {
cfg: OpenClawConfig;
accountId?: string | null;
}) => ChannelExecApprovalInitiatingSurfaceState;
shouldSuppressLocalPrompt?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
payload: ReplyPayload;
}) => boolean;
hasConfiguredDmRoute?: (params: { cfg: OpenClawConfig }) => boolean;
shouldSuppressForwardingFallback?: (params: {
cfg: OpenClawConfig;

View File

@@ -1,11 +1,10 @@
import { resolveIMessageAccount } from "../../extensions/imessage/src/accounts.js";
import { resolveWhatsAppAccount } from "../../extensions/whatsapp/src/accounts.js";
import {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
} from "../channels/plugins/config-helpers.js";
import { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js";
import { normalizeWhatsAppAllowFromEntries } from "../channels/plugins/normalize/whatsapp.js";
import { getChannelPlugin } from "../channels/plugins/registry.js";
import type { ChannelConfigAdapter } from "../channels/plugins/types.adapters.js";
import type { OpenClawConfig } from "../config/config.js";
import { normalizeAccountId } from "../routing/session-key.js";
@@ -148,7 +147,10 @@ export function resolveWhatsAppConfigAllowFrom(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
return resolveWhatsAppAccount(params).allowFrom ?? [];
const account = getChannelPlugin("whatsapp")?.config.resolveAccount(params.cfg, params.accountId);
return account && typeof account === "object" && Array.isArray(account.allowFrom)
? account.allowFrom.map(String)
: [];
}
export function formatWhatsAppConfigAllowFromEntries(allowFrom: Array<string | number>): string[] {
@@ -169,12 +171,20 @@ export function resolveIMessageConfigAllowFrom(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
return mapAllowFromEntries(resolveIMessageAccount(params).config.allowFrom);
const account = getChannelPlugin("imessage")?.config.resolveAccount(params.cfg, params.accountId);
if (!account || typeof account !== "object" || !("config" in account)) {
return [];
}
return mapAllowFromEntries(account.config.allowFrom);
}
export function resolveIMessageConfigDefaultTo(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string | undefined {
return resolveOptionalConfigString(resolveIMessageAccount(params).config.defaultTo);
const account = getChannelPlugin("imessage")?.config.resolveAccount(params.cfg, params.accountId);
if (!account || typeof account !== "object" || !("config" in account)) {
return undefined;
}
return resolveOptionalConfigString(account.config.defaultTo);
}

View File

@@ -0,0 +1,6 @@
export {
buildAgentSessionKey,
type RoutePeer,
type RoutePeerKind,
} from "../routing/resolve-route.js";
export { resolveThreadSessionKeys } from "../routing/session-key.js";

View File

@@ -1,4 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { discordPlugin } from "../../extensions/discord/src/channel.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import {
__testing,
clearPluginCommands,
@@ -7,6 +9,13 @@ import {
listPluginCommands,
registerPluginCommand,
} from "./commands.js";
import { setActivePluginRegistry } from "./runtime.js";
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]),
);
});
afterEach(() => {
clearPluginCommands();

View File

@@ -5,8 +5,7 @@
* These commands are processed before built-in commands and before agent invocation.
*/
import { parseDiscordTarget } from "../../extensions/discord/src/targets.js";
import { parseTelegramTarget } from "../../extensions/telegram/src/targets.js";
import { parseExplicitTargetForChannel } from "../channels/plugins/target-parsing.js";
import type { OpenClawConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
import {
@@ -286,12 +285,15 @@ function resolveBindingConversationFromCommand(params: {
if (!rawTarget) {
return null;
}
const target = parseTelegramTarget(rawTarget);
const target = parseExplicitTargetForChannel("telegram", rawTarget);
if (!target) {
return null;
}
return {
channel: "telegram",
accountId,
conversationId: target.chatId,
threadId: params.messageThreadId ?? target.messageThreadId,
conversationId: target.to,
threadId: params.messageThreadId ?? target.threadId,
};
}
if (params.channel === "discord") {
@@ -304,14 +306,14 @@ function resolveBindingConversationFromCommand(params: {
if (!rawTarget || rawTarget.startsWith("slash:")) {
return null;
}
const target = parseDiscordTarget(rawTarget, { defaultKind: "channel" });
const target = parseExplicitTargetForChannel("discord", rawTarget);
if (!target) {
return null;
}
return {
channel: "discord",
accountId,
conversationId: `${target.kind}:${target.id}`,
conversationId: `${target.chatType === "direct" ? "user" : "channel"}:${target.to}`,
};
}
return null;

View File

@@ -1,59 +1,4 @@
import { auditDiscordChannelPermissions } from "../../../extensions/discord/src/audit.js";
import {
listDiscordDirectoryGroupsLive,
listDiscordDirectoryPeersLive,
} from "../../../extensions/discord/src/directory-live.js";
import { monitorDiscordProvider } from "../../../extensions/discord/src/monitor.js";
import { probeDiscord } from "../../../extensions/discord/src/probe.js";
import { resolveDiscordChannelAllowlist } from "../../../extensions/discord/src/resolve-channels.js";
import { resolveDiscordUserAllowlist } from "../../../extensions/discord/src/resolve-users.js";
import {
createThreadDiscord,
deleteMessageDiscord,
editChannelDiscord,
editMessageDiscord,
pinMessageDiscord,
sendDiscordComponentMessage,
sendMessageDiscord,
sendPollDiscord,
sendTypingDiscord,
unpinMessageDiscord,
} from "../../../extensions/discord/src/send.js";
import { monitorIMessageProvider } from "../../../extensions/imessage/src/monitor.js";
import { probeIMessage } from "../../../extensions/imessage/src/probe.js";
import { sendMessageIMessage } from "../../../extensions/imessage/src/send.js";
import { monitorSignalProvider } from "../../../extensions/signal/src/index.js";
import { probeSignal } from "../../../extensions/signal/src/probe.js";
import { sendMessageSignal } from "../../../extensions/signal/src/send.js";
import {
listSlackDirectoryGroupsLive,
listSlackDirectoryPeersLive,
} from "../../../extensions/slack/src/directory-live.js";
import { monitorSlackProvider } from "../../../extensions/slack/src/index.js";
import { probeSlack } from "../../../extensions/slack/src/probe.js";
import { resolveSlackChannelAllowlist } from "../../../extensions/slack/src/resolve-channels.js";
import { resolveSlackUserAllowlist } from "../../../extensions/slack/src/resolve-users.js";
import { sendMessageSlack } from "../../../extensions/slack/src/send.js";
import {
auditTelegramGroupMembership,
collectTelegramUnmentionedGroupIds,
} from "../../../extensions/telegram/src/audit.js";
import { monitorTelegramProvider } from "../../../extensions/telegram/src/monitor.js";
import { probeTelegram } from "../../../extensions/telegram/src/probe.js";
import {
deleteMessageTelegram,
editMessageReplyMarkupTelegram,
editMessageTelegram,
pinMessageTelegram,
renameForumTopicTelegram,
sendMessageTelegram,
sendPollTelegram,
sendTypingTelegram,
unpinMessageTelegram,
} from "../../../extensions/telegram/src/send.js";
import { resolveTelegramToken } from "../../../extensions/telegram/src/token.js";
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
import {
chunkByNewline,
chunkMarkdownText,
@@ -90,9 +35,6 @@ import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/ack-reactions.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { discordMessageActions } from "../../channels/plugins/actions/discord.js";
import { signalMessageActions } from "../../channels/plugins/actions/signal.js";
import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js";
import { recordInboundSession } from "../../channels/session.js";
import {
resolveChannelGroupPolicy,
@@ -134,8 +76,11 @@ import {
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { buildAgentSessionKey, resolveAgentRoute } from "../../routing/resolve-route.js";
import { createDiscordTypingLease } from "./runtime-discord-typing.js";
import { createTelegramTypingLease } from "./runtime-telegram-typing.js";
import { createRuntimeDiscord } from "./runtime-discord.js";
import { createRuntimeIMessage } from "./runtime-imessage.js";
import { createRuntimeSignal } from "./runtime-signal.js";
import { createRuntimeSlack } from "./runtime-slack.js";
import { createRuntimeTelegram } from "./runtime-telegram.js";
import { createRuntimeWhatsApp } from "./runtime-whatsapp.js";
import type { PluginRuntime } from "./types.js";
@@ -222,100 +167,11 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
shouldComputeCommandAuthorized,
shouldHandleTextCommands,
},
discord: {
messageActions: discordMessageActions,
auditChannelPermissions: auditDiscordChannelPermissions,
listDirectoryGroupsLive: listDiscordDirectoryGroupsLive,
listDirectoryPeersLive: listDiscordDirectoryPeersLive,
probeDiscord,
resolveChannelAllowlist: resolveDiscordChannelAllowlist,
resolveUserAllowlist: resolveDiscordUserAllowlist,
sendComponentMessage: sendDiscordComponentMessage,
sendMessageDiscord,
sendPollDiscord,
monitorDiscordProvider,
typing: {
pulse: sendTypingDiscord,
start: async ({ channelId, accountId, cfg, intervalMs }) =>
await createDiscordTypingLease({
channelId,
accountId,
cfg,
intervalMs,
pulse: async ({ channelId, accountId, cfg }) =>
void (await sendTypingDiscord(channelId, {
accountId,
cfg,
})),
}),
},
conversationActions: {
editMessage: editMessageDiscord,
deleteMessage: deleteMessageDiscord,
pinMessage: pinMessageDiscord,
unpinMessage: unpinMessageDiscord,
createThread: createThreadDiscord,
editChannel: editChannelDiscord,
},
},
slack: {
listDirectoryGroupsLive: listSlackDirectoryGroupsLive,
listDirectoryPeersLive: listSlackDirectoryPeersLive,
probeSlack,
resolveChannelAllowlist: resolveSlackChannelAllowlist,
resolveUserAllowlist: resolveSlackUserAllowlist,
sendMessageSlack,
monitorSlackProvider,
handleSlackAction,
},
telegram: {
auditGroupMembership: auditTelegramGroupMembership,
collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds,
probeTelegram,
resolveTelegramToken,
sendMessageTelegram,
sendPollTelegram,
monitorTelegramProvider,
messageActions: telegramMessageActions,
typing: {
pulse: sendTypingTelegram,
start: async ({ to, accountId, cfg, intervalMs, messageThreadId }) =>
await createTelegramTypingLease({
to,
accountId,
cfg,
intervalMs,
messageThreadId,
pulse: async ({ to, accountId, cfg, messageThreadId }) =>
await sendTypingTelegram(to, {
accountId,
cfg,
messageThreadId,
}),
}),
},
conversationActions: {
editMessage: editMessageTelegram,
editReplyMarkup: editMessageReplyMarkupTelegram,
clearReplyMarkup: async (chatIdInput, messageIdInput, opts = {}) =>
await editMessageReplyMarkupTelegram(chatIdInput, messageIdInput, [], opts),
deleteMessage: deleteMessageTelegram,
renameTopic: renameForumTopicTelegram,
pinMessage: pinMessageTelegram,
unpinMessage: unpinMessageTelegram,
},
},
signal: {
probeSignal,
sendMessageSignal,
monitorSignalProvider,
messageActions: signalMessageActions,
},
imessage: {
monitorIMessageProvider,
probeIMessage,
sendMessageIMessage,
},
discord: createRuntimeDiscord(),
slack: createRuntimeSlack(),
telegram: createRuntimeTelegram(),
signal: createRuntimeSignal(),
imessage: createRuntimeIMessage(),
whatsapp: createRuntimeWhatsApp(),
line: {
listLineAccountIds,

View File

@@ -0,0 +1,60 @@
import { auditDiscordChannelPermissions } from "../../../extensions/discord/src/audit.js";
import {
listDiscordDirectoryGroupsLive,
listDiscordDirectoryPeersLive,
} from "../../../extensions/discord/src/directory-live.js";
import { monitorDiscordProvider } from "../../../extensions/discord/src/monitor.js";
import { probeDiscord } from "../../../extensions/discord/src/probe.js";
import { resolveDiscordChannelAllowlist } from "../../../extensions/discord/src/resolve-channels.js";
import { resolveDiscordUserAllowlist } from "../../../extensions/discord/src/resolve-users.js";
import {
createThreadDiscord,
deleteMessageDiscord,
editChannelDiscord,
editMessageDiscord,
pinMessageDiscord,
sendDiscordComponentMessage,
sendMessageDiscord,
sendPollDiscord,
sendTypingDiscord,
unpinMessageDiscord,
} from "../../../extensions/discord/src/send.js";
import { discordMessageActions } from "../../channels/plugins/actions/discord.js";
import { createDiscordTypingLease } from "./runtime-discord-typing.js";
import type { PluginRuntimeChannel } from "./types-channel.js";
export function createRuntimeDiscord(): PluginRuntimeChannel["discord"] {
return {
messageActions: discordMessageActions,
auditChannelPermissions: auditDiscordChannelPermissions,
listDirectoryGroupsLive: listDiscordDirectoryGroupsLive,
listDirectoryPeersLive: listDiscordDirectoryPeersLive,
probeDiscord,
resolveChannelAllowlist: resolveDiscordChannelAllowlist,
resolveUserAllowlist: resolveDiscordUserAllowlist,
sendComponentMessage: sendDiscordComponentMessage,
sendMessageDiscord,
sendPollDiscord,
monitorDiscordProvider,
typing: {
pulse: sendTypingDiscord,
start: async ({ channelId, accountId, cfg, intervalMs }) =>
await createDiscordTypingLease({
channelId,
accountId,
cfg,
intervalMs,
pulse: async ({ channelId, accountId, cfg }) =>
void (await sendTypingDiscord(channelId, { accountId, cfg })),
}),
},
conversationActions: {
editMessage: editMessageDiscord,
deleteMessage: deleteMessageDiscord,
pinMessage: pinMessageDiscord,
unpinMessage: unpinMessageDiscord,
createThread: createThreadDiscord,
editChannel: editChannelDiscord,
},
};
}

View File

@@ -0,0 +1,12 @@
import { monitorIMessageProvider } from "../../../extensions/imessage/src/monitor.js";
import { probeIMessage } from "../../../extensions/imessage/src/probe.js";
import { sendMessageIMessage } from "../../../extensions/imessage/src/send.js";
import type { PluginRuntimeChannel } from "./types-channel.js";
export function createRuntimeIMessage(): PluginRuntimeChannel["imessage"] {
return {
monitorIMessageProvider,
probeIMessage,
sendMessageIMessage,
};
}

View File

@@ -0,0 +1,14 @@
import { monitorSignalProvider } from "../../../extensions/signal/src/index.js";
import { probeSignal } from "../../../extensions/signal/src/probe.js";
import { sendMessageSignal } from "../../../extensions/signal/src/send.js";
import { signalMessageActions } from "../../channels/plugins/actions/signal.js";
import type { PluginRuntimeChannel } from "./types-channel.js";
export function createRuntimeSignal(): PluginRuntimeChannel["signal"] {
return {
probeSignal,
sendMessageSignal,
monitorSignalProvider,
messageActions: signalMessageActions,
};
}

View File

@@ -0,0 +1,24 @@
import {
listSlackDirectoryGroupsLive,
listSlackDirectoryPeersLive,
} from "../../../extensions/slack/src/directory-live.js";
import { monitorSlackProvider } from "../../../extensions/slack/src/index.js";
import { probeSlack } from "../../../extensions/slack/src/probe.js";
import { resolveSlackChannelAllowlist } from "../../../extensions/slack/src/resolve-channels.js";
import { resolveSlackUserAllowlist } from "../../../extensions/slack/src/resolve-users.js";
import { sendMessageSlack } from "../../../extensions/slack/src/send.js";
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
import type { PluginRuntimeChannel } from "./types-channel.js";
export function createRuntimeSlack(): PluginRuntimeChannel["slack"] {
return {
listDirectoryGroupsLive: listSlackDirectoryGroupsLive,
listDirectoryPeersLive: listSlackDirectoryPeersLive,
probeSlack,
resolveChannelAllowlist: resolveSlackChannelAllowlist,
resolveUserAllowlist: resolveSlackUserAllowlist,
sendMessageSlack,
monitorSlackProvider,
handleSlackAction,
};
}

View File

@@ -0,0 +1,61 @@
import {
auditTelegramGroupMembership,
collectTelegramUnmentionedGroupIds,
} from "../../../extensions/telegram/src/audit.js";
import { monitorTelegramProvider } from "../../../extensions/telegram/src/monitor.js";
import { probeTelegram } from "../../../extensions/telegram/src/probe.js";
import {
deleteMessageTelegram,
editMessageReplyMarkupTelegram,
editMessageTelegram,
pinMessageTelegram,
renameForumTopicTelegram,
sendMessageTelegram,
sendPollTelegram,
sendTypingTelegram,
unpinMessageTelegram,
} from "../../../extensions/telegram/src/send.js";
import { resolveTelegramToken } from "../../../extensions/telegram/src/token.js";
import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js";
import { createTelegramTypingLease } from "./runtime-telegram-typing.js";
import type { PluginRuntimeChannel } from "./types-channel.js";
export function createRuntimeTelegram(): PluginRuntimeChannel["telegram"] {
return {
auditGroupMembership: auditTelegramGroupMembership,
collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds,
probeTelegram,
resolveTelegramToken,
sendMessageTelegram,
sendPollTelegram,
monitorTelegramProvider,
messageActions: telegramMessageActions,
typing: {
pulse: sendTypingTelegram,
start: async ({ to, accountId, cfg, intervalMs, messageThreadId }) =>
await createTelegramTypingLease({
to,
accountId,
cfg,
intervalMs,
messageThreadId,
pulse: async ({ to, accountId, cfg, messageThreadId }) =>
await sendTypingTelegram(to, {
accountId,
cfg,
messageThreadId,
}),
}),
},
conversationActions: {
editMessage: editMessageTelegram,
editReplyMarkup: editMessageReplyMarkupTelegram,
clearReplyMarkup: async (chatIdInput, messageIdInput, opts = {}) =>
await editMessageReplyMarkupTelegram(chatIdInput, messageIdInput, [], opts),
deleteMessage: deleteMessageTelegram,
renameTopic: renameForumTopicTelegram,
pinMessage: pinMessageTelegram,
unpinMessage: unpinMessageTelegram,
},
};
}