mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
refactor(channels): consolidate manifest descriptors (#110985)
This commit is contained in:
@@ -1162,6 +1162,7 @@ Important examples:
|
||||
| `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding, deferred channel startup, and read-only channel status/SecretRef discovery. Must stay inside the plugin package directory. |
|
||||
| `openclaw.runtimeSetupEntry` | Declares the built JavaScript setup entrypoint for installed packages. Requires `setupEntry`, must exist, and must stay inside the plugin package directory. |
|
||||
| `openclaw.channel` | Cheap channel catalog metadata like labels, docs paths, aliases, and selection copy. |
|
||||
| `openclaw.channel.approvalFlags` | Closed approval behavior flags available before runtime load. `native` means the channel owns native approval UI and same-turn resolution. |
|
||||
| `openclaw.channel.commands` | Static native command and native skill auto-default metadata used by config, audit, and command-list surfaces before channel runtime loads. |
|
||||
| `openclaw.channel.configuredState` | Lightweight configured-state checker metadata that can answer "does env-only setup already exist?" without loading the full channel runtime. |
|
||||
| `openclaw.channel.persistedAuthState` | Lightweight persisted-auth checker metadata that can answer "is anything already signed in?" without loading the full channel runtime. |
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
},
|
||||
"channel": {
|
||||
"id": "discord",
|
||||
"approvalFlags": ["native"],
|
||||
"label": "Discord",
|
||||
"selectionLabel": "Discord (Bot API)",
|
||||
"detailLabel": "Discord Bot",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "googlechat",
|
||||
"approvalFlags": ["native"],
|
||||
"label": "Google Chat",
|
||||
"selectionLabel": "Google Chat (Chat API)",
|
||||
"detailLabel": "Google Chat",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
},
|
||||
"channel": {
|
||||
"id": "imessage",
|
||||
"approvalFlags": ["native"],
|
||||
"label": "iMessage",
|
||||
"selectionLabel": "iMessage (imsg)",
|
||||
"detailLabel": "iMessage",
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
},
|
||||
"channel": {
|
||||
"id": "matrix",
|
||||
"approvalFlags": ["native"],
|
||||
"label": "Matrix",
|
||||
"selectionLabel": "Matrix (plugin)",
|
||||
"docsPath": "/channels/matrix",
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "qqbot",
|
||||
"approvalFlags": ["native"],
|
||||
"label": "QQ Bot",
|
||||
"selectionLabel": "QQ Bot (Official API)",
|
||||
"detailLabel": "QQ Bot",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "signal",
|
||||
"approvalFlags": ["native"],
|
||||
"label": "Signal",
|
||||
"selectionLabel": "Signal (signal-cli)",
|
||||
"detailLabel": "Signal REST",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "slack",
|
||||
"approvalFlags": ["native"],
|
||||
"label": "Slack",
|
||||
"selectionLabel": "Slack (Socket Mode)",
|
||||
"detailLabel": "Slack Bot",
|
||||
|
||||
@@ -687,7 +687,7 @@ export async function registerSlackMonitorSlashCommands(params: {
|
||||
sessionPrefix: slashCommand.sessionPrefix,
|
||||
userId: command.user_id,
|
||||
targetSessionKey: route.sessionKey,
|
||||
lowercaseSessionKey: true,
|
||||
sessionKeyCase: "lowercase",
|
||||
});
|
||||
const slashReplyTarget =
|
||||
!slashCommand.ephemeral && isRoomish
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
},
|
||||
"channel": {
|
||||
"id": "telegram",
|
||||
"approvalFlags": ["native"],
|
||||
"label": "Telegram",
|
||||
"selectionLabel": "Telegram (Bot API)",
|
||||
"detailLabel": "Telegram Bot",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
},
|
||||
"channel": {
|
||||
"id": "whatsapp",
|
||||
"approvalFlags": ["native"],
|
||||
"label": "WhatsApp",
|
||||
"selectionLabel": "WhatsApp (QR link)",
|
||||
"detailLabel": "WhatsApp Web",
|
||||
|
||||
@@ -37,18 +37,6 @@ vi.mock("../infra/outbound/message.js", () => ({
|
||||
|
||||
vi.mock("../utils/message-channel.js", () => {
|
||||
const INTERNAL_MESSAGE_CHANNEL = "webchat";
|
||||
const NATIVE_APPROVAL_CHANNELS = new Set([
|
||||
"webchat",
|
||||
"discord",
|
||||
"googlechat",
|
||||
"imessage",
|
||||
"matrix",
|
||||
"qqbot",
|
||||
"signal",
|
||||
"slack",
|
||||
"telegram",
|
||||
"whatsapp",
|
||||
]);
|
||||
const normalizeMessageChannel = (raw?: string | null) => {
|
||||
const normalized = raw?.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
@@ -63,7 +51,7 @@ vi.mock("../utils/message-channel.js", () => {
|
||||
return {
|
||||
INTERNAL_MESSAGE_CHANNEL,
|
||||
isNativeApprovalChannel: (value?: string | null) =>
|
||||
typeof value === "string" && NATIVE_APPROVAL_CHANNELS.has(value),
|
||||
value === INTERNAL_MESSAGE_CHANNEL || value === "discord",
|
||||
isDeliverableMessageChannel: (value: string) => {
|
||||
const channel = normalizeMessageChannel(value);
|
||||
return Boolean(channel && channel !== INTERNAL_MESSAGE_CHANNEL && channel !== "tui");
|
||||
|
||||
@@ -1275,11 +1275,12 @@ describe("buildAgentSystemPrompt", () => {
|
||||
expect(prompt).toContain("Plain /approve only when");
|
||||
});
|
||||
|
||||
it("suppresses plain chat approval commands for native approval channels", () => {
|
||||
it("suppresses plain chat approval commands for native approval runtimes", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/openclaw",
|
||||
runtimeInfo: {
|
||||
channel: "slack",
|
||||
channel: "whatsapp",
|
||||
capabilities: ["nativeApprovals"],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -74,7 +74,14 @@ function seedRoot(prefix: string): string {
|
||||
|
||||
function seedChannelPkg(
|
||||
pkgJsonPath: string,
|
||||
opts: { id: string; docsPath: string; label?: string; blurb?: string; markdownCapable?: boolean },
|
||||
opts: {
|
||||
id: string;
|
||||
docsPath: string;
|
||||
label?: string;
|
||||
blurb?: string;
|
||||
markdownCapable?: boolean;
|
||||
approvalFlags?: readonly ["native"];
|
||||
},
|
||||
): void {
|
||||
const pluginDir = path.dirname(pkgJsonPath);
|
||||
writeJsonFile(pkgJsonPath, {
|
||||
@@ -86,6 +93,7 @@ function seedChannelPkg(
|
||||
docsPath: opts.docsPath,
|
||||
blurb: opts.blurb ?? "test blurb",
|
||||
...(opts.markdownCapable !== undefined ? { markdownCapable: opts.markdownCapable } : {}),
|
||||
...(opts.approvalFlags ? { approvalFlags: opts.approvalFlags } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -109,6 +117,7 @@ describe("listBundledChannelCatalogEntries", () => {
|
||||
id: "telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
label: "Telegram",
|
||||
approvalFlags: ["native"],
|
||||
});
|
||||
seedChannelPkg(path.join(extensionsRoot, "imessage", "package.json"), {
|
||||
id: "imessage",
|
||||
@@ -124,6 +133,7 @@ describe("listBundledChannelCatalogEntries", () => {
|
||||
const telegram = entries.find((entry) => entry.id === "telegram");
|
||||
expect(telegram?.channel.docsPath).toBe("/channels/telegram");
|
||||
expect(telegram?.channel.label).toBe("Telegram");
|
||||
expect(telegram?.channel.approvalFlags).toEqual(["native"]);
|
||||
});
|
||||
|
||||
it("merges the generated official catalog with bundled package metadata", () => {
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("resolveNativeCommandSessionTargets", () => {
|
||||
sessionPrefix: "Slack:Slash",
|
||||
userId: "U123",
|
||||
targetSessionKey: "agent:qwen:slack:channel:c1",
|
||||
lowercaseSessionKey: true,
|
||||
sessionKeyCase: "lowercase",
|
||||
}),
|
||||
).toEqual({
|
||||
sessionKey: "agent:qwen:slack:slash:u123",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Chooses storage and command target session keys for channel-native command events.
|
||||
*/
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { ChannelMessagingAdapter } from "./plugins/types.core.js";
|
||||
|
||||
/**
|
||||
* Inputs for resolving where a native channel command should attach session state.
|
||||
@@ -14,7 +15,7 @@ export type ResolveNativeCommandSessionTargetsParams = {
|
||||
userId: string;
|
||||
targetSessionKey: string;
|
||||
boundSessionKey?: string;
|
||||
lowercaseSessionKey?: boolean;
|
||||
sessionKeyCase?: NonNullable<ChannelMessagingAdapter["targetIdComparison"]>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -28,9 +29,10 @@ export function resolveNativeCommandSessionTargets(
|
||||
return {
|
||||
// Some providers normalize user ids case-insensitively; keep this opt-in so existing
|
||||
// case-sensitive bindings are preserved for channels that need them.
|
||||
sessionKey: params.lowercaseSessionKey
|
||||
? normalizeLowercaseStringOrEmpty(rawSessionKey)
|
||||
: rawSessionKey,
|
||||
sessionKey:
|
||||
params.sessionKeyCase === "lowercase"
|
||||
? normalizeLowercaseStringOrEmpty(rawSessionKey)
|
||||
: rawSessionKey,
|
||||
commandTargetSessionKey: params.boundSessionKey ?? params.targetSessionKey,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Detects loaded or known channels that can render approval prompts natively.
|
||||
*/
|
||||
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { listBundledChannelCatalogEntries } from "../bundled-channel-catalog-read.js";
|
||||
import { resolveChannelApprovalCapability } from "./approvals.js";
|
||||
import type { ChannelPlugin } from "./types.plugin.js";
|
||||
|
||||
@@ -11,16 +12,6 @@ export const NATIVE_APPROVAL_PROMPT_RUNTIME_CAPABILITY = "nativeApprovals";
|
||||
|
||||
const NATIVE_APPROVAL_PROMPT_RUNTIME_CAPABILITY_NORMALIZED = "nativeapprovals";
|
||||
|
||||
const KNOWN_NATIVE_APPROVAL_PROMPT_CHANNELS = new Set([
|
||||
"discord",
|
||||
"googlechat",
|
||||
"matrix",
|
||||
"qqbot",
|
||||
"slack",
|
||||
"telegram",
|
||||
"signal",
|
||||
]);
|
||||
|
||||
export function channelPluginHasNativeApprovalPromptUi(
|
||||
plugin?: Pick<ChannelPlugin, "approvalCapability"> | null,
|
||||
): boolean {
|
||||
@@ -30,7 +21,12 @@ export function channelPluginHasNativeApprovalPromptUi(
|
||||
|
||||
export function isKnownNativeApprovalPromptChannel(channel?: string | null): boolean {
|
||||
const normalized = normalizeOptionalLowercaseString(channel);
|
||||
return Boolean(normalized && KNOWN_NATIVE_APPROVAL_PROMPT_CHANNELS.has(normalized));
|
||||
return Boolean(
|
||||
normalized &&
|
||||
listBundledChannelCatalogEntries().some(
|
||||
(entry) => entry.id === normalized && entry.channel.approvalFlags?.includes("native"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasNativeApprovalPromptRuntimeCapability(
|
||||
|
||||
@@ -154,7 +154,7 @@ describe("plugin session attachments", () => {
|
||||
delete (globalThis as { proofAttachmentLog?: unknown[] }).proofAttachmentLog;
|
||||
});
|
||||
|
||||
it("sends validated files through the session delivery route with channel hints", async () => {
|
||||
it("sends validated files through the session delivery route with portable hints", async () => {
|
||||
await withSessionStore(async ({ storePath, filePath }) => {
|
||||
await writeSessionEntry(storePath, {
|
||||
deliveryContext: {
|
||||
@@ -167,7 +167,7 @@ describe("plugin session attachments", () => {
|
||||
|
||||
const result = await sendBundledSessionAttachment({
|
||||
files: [{ path: filePath }],
|
||||
channelHints: { telegram: { disableNotification: true, parseMode: "HTML" } },
|
||||
channelHints: { silent: true, parseMode: "HTML", threadId: "attachment-thread" },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -181,7 +181,7 @@ describe("plugin session attachments", () => {
|
||||
expect(sendParams.to).toBe("12345");
|
||||
expect(sendParams.channel).toBe("telegram");
|
||||
expect(sendParams.accountId).toBe("default");
|
||||
expect(sendParams.threadId).toBe(42);
|
||||
expect(sendParams.threadId).toBe("attachment-thread");
|
||||
expect(sendParams.mediaUrls).toEqual([filePath]);
|
||||
expect(sendParams.bestEffort).toBe(false);
|
||||
expect(sendParams.silent).toBe(true);
|
||||
@@ -208,7 +208,7 @@ describe("plugin session attachments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("escapes plain Telegram attachment captions before HTML delivery", async () => {
|
||||
it("keeps shipped Telegram hints compatible when escaping plain captions", async () => {
|
||||
await withSessionStore(async ({ storePath, filePath }) => {
|
||||
await writeSessionEntry(storePath);
|
||||
mockSuccessfulAttachmentDelivery();
|
||||
@@ -226,6 +226,23 @@ describe("plugin session attachments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps shipped Slack thread hints compatible", async () => {
|
||||
await withSessionStore(async ({ storePath, filePath }) => {
|
||||
await writeSessionEntry(storePath, {
|
||||
deliveryContext: { channel: "slack", to: "C123" },
|
||||
});
|
||||
mockSuccessfulAttachmentDelivery();
|
||||
|
||||
const result = await sendBundledSessionAttachment({
|
||||
files: [{ path: filePath }],
|
||||
channelHints: { slack: { threadTs: "171234.567" } },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: true, channel: "slack", deliveredTo: "C123" });
|
||||
expect(requireFirstSendMessageParams().threadId).toBe("171234.567");
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves relative attachment paths against the session agent workspace", async () => {
|
||||
await withSessionStore(async ({ storePath, stateDir }) => {
|
||||
const workspaceDir = path.join(stateDir, "workspace");
|
||||
@@ -345,7 +362,7 @@ describe("plugin session attachments", () => {
|
||||
await expect(
|
||||
sendBundledSessionAttachment({
|
||||
files: [{ path: filePath }],
|
||||
channelHints: { telegram: { forceDocumentMime: "application/pdf" } },
|
||||
channelHints: { forceDocumentMime: "application/pdf" },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
@@ -356,7 +373,7 @@ describe("plugin session attachments", () => {
|
||||
await expect(
|
||||
sendBundledSessionAttachment({
|
||||
files: [{ path: fakePdfPath }],
|
||||
channelHints: { telegram: { forceDocumentMime: "application/pdf" } },
|
||||
channelHints: { forceDocumentMime: "application/pdf" },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
@@ -379,7 +396,7 @@ describe("plugin session attachments", () => {
|
||||
const result = await sendBundledSessionAttachment({
|
||||
files: [{ path: pdfPath }],
|
||||
forceDocument: false,
|
||||
channelHints: { telegram: { forceDocumentMime: "application/pdf" } },
|
||||
channelHints: { forceDocumentMime: "application/pdf" },
|
||||
});
|
||||
expectTelegramAttachmentResult(result, 1);
|
||||
const sendParams = requireFirstSendMessageParams();
|
||||
|
||||
@@ -50,9 +50,9 @@ const loadGetChannelPlugin = createLazyRuntimeModule(() =>
|
||||
type ResolvedAttachmentDelivery = {
|
||||
parseMode?: "HTML";
|
||||
escapePlainHtmlCaption?: boolean;
|
||||
disableNotification?: boolean;
|
||||
silent?: boolean;
|
||||
forceDocumentMime?: string;
|
||||
threadTs?: string;
|
||||
threadId?: string | number;
|
||||
};
|
||||
|
||||
function captionFormatToParseMode(
|
||||
@@ -68,6 +68,13 @@ function escapeHtmlText(text: string): string {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function normalizeOptionalThreadId(value: unknown): string | number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
return normalizeOptionalString(value);
|
||||
}
|
||||
|
||||
async function readMimeSniffBuffer(
|
||||
filePath: string,
|
||||
size: number,
|
||||
@@ -88,7 +95,7 @@ async function readMimeSniffBuffer(
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves channel-specific attachment delivery options from caption format and hints. */
|
||||
/** Resolves portable attachment delivery options while honoring shipped channel-specific hints. */
|
||||
function resolveAttachmentDelivery(params: {
|
||||
channel: string;
|
||||
captionFormat?: PluginSessionAttachmentCaptionFormat;
|
||||
@@ -96,33 +103,28 @@ function resolveAttachmentDelivery(params: {
|
||||
}): ResolvedAttachmentDelivery {
|
||||
const fallbackParseMode = captionFormatToParseMode(params.captionFormat);
|
||||
const channel = params.channel.trim().toLowerCase();
|
||||
if (channel === "telegram") {
|
||||
const hint = params.channelHints?.telegram;
|
||||
const parseMode =
|
||||
hint?.parseMode ?? (params.captionFormat === "plain" ? "HTML" : fallbackParseMode);
|
||||
const escapePlainHtmlCaption = params.captionFormat === "plain" && parseMode === "HTML";
|
||||
const forceDocumentMime = normalizeMimeType(hint?.forceDocumentMime);
|
||||
return {
|
||||
...(parseMode ? { parseMode } : {}),
|
||||
...(escapePlainHtmlCaption ? { escapePlainHtmlCaption: true } : {}),
|
||||
...(hint?.disableNotification !== undefined
|
||||
? { disableNotification: hint.disableNotification }
|
||||
: {}),
|
||||
...(forceDocumentMime ? { forceDocumentMime } : {}),
|
||||
};
|
||||
}
|
||||
if (channel === "discord") {
|
||||
return fallbackParseMode ? { parseMode: fallbackParseMode } : {};
|
||||
}
|
||||
if (channel === "slack") {
|
||||
const hint = params.channelHints?.slack;
|
||||
const threadTs = normalizeOptionalString(hint?.threadTs);
|
||||
return {
|
||||
...(fallbackParseMode ? { parseMode: fallbackParseMode } : {}),
|
||||
...(threadTs ? { threadTs } : {}),
|
||||
};
|
||||
}
|
||||
return fallbackParseMode ? { parseMode: fallbackParseMode } : {};
|
||||
const hints = params.channelHints;
|
||||
// These nested fields shipped before attachment hints became transport-neutral.
|
||||
const legacyTelegram = channel === "telegram" ? hints?.telegram : undefined;
|
||||
const legacySlack = channel === "slack" ? hints?.slack : undefined;
|
||||
const parseMode =
|
||||
hints?.parseMode ??
|
||||
legacyTelegram?.parseMode ??
|
||||
(channel === "telegram" && params.captionFormat === "plain" ? "HTML" : fallbackParseMode);
|
||||
const escapePlainHtmlCaption = params.captionFormat === "plain" && parseMode === "HTML";
|
||||
const silent = hints?.silent ?? legacyTelegram?.disableNotification;
|
||||
const forceDocumentMime = normalizeMimeType(
|
||||
hints?.forceDocumentMime ?? legacyTelegram?.forceDocumentMime,
|
||||
);
|
||||
const threadId =
|
||||
normalizeOptionalThreadId(hints?.threadId) ?? normalizeOptionalString(legacySlack?.threadTs);
|
||||
return {
|
||||
...(parseMode ? { parseMode } : {}),
|
||||
...(escapePlainHtmlCaption ? { escapePlainHtmlCaption: true } : {}),
|
||||
...(silent !== undefined ? { silent } : {}),
|
||||
...(forceDocumentMime ? { forceDocumentMime } : {}),
|
||||
...(threadId !== undefined ? { threadId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function validateAttachmentFiles(
|
||||
@@ -205,22 +207,15 @@ function resolveAttachmentFilePath(params: {
|
||||
return resolvePathFromInput(params.filePath, resolveWorkspaceRoot(workspaceDir));
|
||||
}
|
||||
|
||||
function normalizeOptionalThreadId(value: unknown): string | number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
return normalizeOptionalString(value);
|
||||
}
|
||||
|
||||
/** Resolves the thread id used when delivering a plugin session attachment. */
|
||||
function resolveSessionAttachmentThreadId(params: {
|
||||
deliveryThreadId?: unknown;
|
||||
explicitThreadId?: unknown;
|
||||
fallbackThreadId?: unknown;
|
||||
hintThreadTs?: string;
|
||||
hintThreadId?: unknown;
|
||||
}): string | number | undefined {
|
||||
return (
|
||||
params.hintThreadTs ??
|
||||
normalizeOptionalThreadId(params.hintThreadId) ??
|
||||
normalizeOptionalThreadId(params.explicitThreadId) ??
|
||||
normalizeOptionalThreadId(params.fallbackThreadId) ??
|
||||
normalizeOptionalThreadId(params.deliveryThreadId)
|
||||
@@ -289,7 +284,7 @@ export async function sendPluginSessionAttachment(
|
||||
deliveryThreadId: deliveryContext.threadId,
|
||||
explicitThreadId: params.threadId,
|
||||
fallbackThreadId: threadId,
|
||||
hintThreadTs: resolvedDelivery.threadTs,
|
||||
hintThreadId: resolvedDelivery.threadId,
|
||||
});
|
||||
let result: Awaited<ReturnType<SendMessage>>;
|
||||
try {
|
||||
@@ -306,9 +301,7 @@ export async function sendPluginSessionAttachment(
|
||||
bestEffort: false,
|
||||
cfg: params.config,
|
||||
...(resolvedDelivery.parseMode ? { parseMode: resolvedDelivery.parseMode } : {}),
|
||||
...(resolvedDelivery.disableNotification !== undefined
|
||||
? { silent: resolvedDelivery.disableNotification }
|
||||
: {}),
|
||||
...(resolvedDelivery.silent !== undefined ? { silent: resolvedDelivery.silent } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
return { ok: false, error: `attachment delivery failed: ${formatErrorMessage(error)}` };
|
||||
|
||||
@@ -222,6 +222,12 @@ type PluginSessionAttachmentFile = {
|
||||
};
|
||||
|
||||
export type PluginAttachmentChannelHints = {
|
||||
parseMode?: "HTML";
|
||||
silent?: boolean;
|
||||
/** Require host detection to match this MIME before forcing document delivery. */
|
||||
forceDocumentMime?: string;
|
||||
threadId?: string | number;
|
||||
/** @deprecated Put portable attachment hints directly on `channelHints`. */
|
||||
telegram?: {
|
||||
parseMode?: "HTML";
|
||||
disableNotification?: boolean;
|
||||
@@ -231,6 +237,7 @@ export type PluginAttachmentChannelHints = {
|
||||
*/
|
||||
forceDocumentMime?: string;
|
||||
};
|
||||
/** @deprecated Use `channelHints.threadId`. */
|
||||
slack?: {
|
||||
threadTs?: string;
|
||||
};
|
||||
|
||||
@@ -1959,6 +1959,8 @@ export function loadPluginManifest(
|
||||
}
|
||||
|
||||
// package.json "openclaw" metadata (used for setup/catalog)
|
||||
type PluginPackageChannelApprovalFlag = "native";
|
||||
|
||||
export type PluginPackageChannel = {
|
||||
id?: string;
|
||||
label?: string;
|
||||
@@ -1975,6 +1977,8 @@ export type PluginPackageChannel = {
|
||||
selectionDocsOmitLabel?: boolean;
|
||||
selectionExtras?: readonly string[];
|
||||
markdownCapable?: boolean;
|
||||
/** Closed manifest flags for approval behavior available before the channel runtime loads. */
|
||||
approvalFlags?: readonly PluginPackageChannelApprovalFlag[];
|
||||
exposure?: {
|
||||
configured?: boolean;
|
||||
setup?: boolean;
|
||||
|
||||
@@ -29,33 +29,3 @@ export function isInternalNonDeliveryChannel(
|
||||
): value is (typeof INTERNAL_NON_DELIVERY_CHANNELS)[number] {
|
||||
return isStringOption(value, INTERNAL_NON_DELIVERY_CHANNELS);
|
||||
}
|
||||
|
||||
// Channels that ship a native chat exec approval client (in-chat `/approve`
|
||||
// flow backed by an `approval-handler.runtime` adapter). When the originating
|
||||
// turn can be approved in the same chat, the gateway can resolve the approval
|
||||
// in place and the agent can wait inline for the result instead of falling
|
||||
// back to a fire-and-forget followup that loses the agent's session.
|
||||
//
|
||||
// Keep this list aligned with bundled channels whose
|
||||
// `approvalCapability.nativeRuntime` handles exec approvals; webchat is
|
||||
// core-owned. Listing a channel without that runtime re-introduces the
|
||||
// "approval loop" the inline path was added to avoid.
|
||||
const NATIVE_APPROVAL_CHANNELS = [
|
||||
"webchat",
|
||||
"discord",
|
||||
"googlechat",
|
||||
"imessage",
|
||||
"matrix",
|
||||
"qqbot",
|
||||
"signal",
|
||||
"slack",
|
||||
"telegram",
|
||||
"whatsapp",
|
||||
] as const;
|
||||
type NativeApprovalChannel = (typeof NATIVE_APPROVAL_CHANNELS)[number];
|
||||
|
||||
export function isNativeApprovalChannel(
|
||||
value: string | null | undefined,
|
||||
): value is NativeApprovalChannel {
|
||||
return isStringOption(value, NATIVE_APPROVAL_CHANNELS);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
isEphemeralGatewayClient,
|
||||
isInternalNonDeliveryChannel,
|
||||
isMarkdownCapableMessageChannel,
|
||||
isNativeApprovalChannel,
|
||||
isOperatorUiClient,
|
||||
resolveGatewayMessageChannel,
|
||||
} from "./message-channel.js";
|
||||
@@ -103,28 +102,35 @@ describe("message-channel", () => {
|
||||
expect(isInternalNonDeliveryChannel("HEARTBEAT")).toBe(false);
|
||||
});
|
||||
|
||||
it("lists native chat exec approval channels", () => {
|
||||
for (const channel of [
|
||||
"webchat",
|
||||
"discord",
|
||||
"googlechat",
|
||||
"imessage",
|
||||
"matrix",
|
||||
"qqbot",
|
||||
"signal",
|
||||
"slack",
|
||||
"telegram",
|
||||
"whatsapp",
|
||||
]) {
|
||||
expect(isNativeApprovalChannel(channel)).toBe(true);
|
||||
it("reads native approval behavior from bundled channel manifests", async () => {
|
||||
const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
||||
const previousTrust = process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR;
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.resolve("extensions");
|
||||
process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR = "1";
|
||||
vi.resetModules();
|
||||
try {
|
||||
const channelModule = await import("./message-channel.js");
|
||||
const promptModule = await import("../channels/plugins/native-approval-prompt.js");
|
||||
for (const channel of ["webchat", "discord", "imessage", "telegram", "whatsapp"]) {
|
||||
expect(channelModule.isNativeApprovalChannel(channel), channel).toBe(true);
|
||||
}
|
||||
expect(promptModule.isKnownNativeApprovalPromptChannel("whatsapp")).toBe(true);
|
||||
for (const channel of ["feishu", "msteams", "line", "heartbeat", "", "TELEGRAM"]) {
|
||||
expect(channelModule.isNativeApprovalChannel(channel), channel).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
if (previousBundledPluginsDir === undefined) {
|
||||
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = previousBundledPluginsDir;
|
||||
}
|
||||
if (previousTrust === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR = previousTrust;
|
||||
}
|
||||
vi.resetModules();
|
||||
}
|
||||
// Channels without a bundled exec-capable native approval runtime must not claim it.
|
||||
expect(isNativeApprovalChannel("feishu")).toBe(false);
|
||||
expect(isNativeApprovalChannel("msteams")).toBe(false);
|
||||
expect(isNativeApprovalChannel("line")).toBe(false);
|
||||
expect(isNativeApprovalChannel("heartbeat")).toBe(false);
|
||||
expect(isNativeApprovalChannel("")).toBe(false);
|
||||
expect(isNativeApprovalChannel("TELEGRAM")).toBe(false);
|
||||
});
|
||||
|
||||
it("reads markdown capability from channel metadata", () => {
|
||||
|
||||
@@ -23,7 +23,6 @@ export {
|
||||
export {
|
||||
INTERNAL_MESSAGE_CHANNEL,
|
||||
isInternalNonDeliveryChannel,
|
||||
isNativeApprovalChannel,
|
||||
} from "./message-channel-constants.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "./message-channel-constants.js";
|
||||
import { normalizeMessageChannel } from "./message-channel-normalize.js";
|
||||
@@ -92,6 +91,19 @@ export function isInternalMessageChannel(
|
||||
return normalizeMessageChannel(raw) === INTERNAL_MESSAGE_CHANNEL;
|
||||
}
|
||||
|
||||
/** Return whether a channel can resolve exec approvals in the originating chat. */
|
||||
export function isNativeApprovalChannel(value?: string | null): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
if (value === INTERNAL_MESSAGE_CHANNEL) {
|
||||
return true;
|
||||
}
|
||||
return listBundledChannelCatalogEntries().some(
|
||||
(entry) => entry.id === value && entry.channel.approvalFlags?.includes("native"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Return whether a Gateway client is the public webchat surface. */
|
||||
export function isWebchatClient(client?: GatewayClientInfoLike | null): boolean {
|
||||
const mode = normalizeGatewayClientMode(client?.mode);
|
||||
|
||||
Reference in New Issue
Block a user