refactor(channels): consolidate manifest descriptors (#110985)

This commit is contained in:
Peter Steinberger
2026-07-18 23:18:28 +01:00
committed by GitHub
parent ee37c840f3
commit 264a7b0d12
24 changed files with 152 additions and 136 deletions
+1
View File
@@ -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. |
+1
View File
@@ -39,6 +39,7 @@
},
"channel": {
"id": "discord",
"approvalFlags": ["native"],
"label": "Discord",
"selectionLabel": "Discord (Bot API)",
"detailLabel": "Discord Bot",
+1
View File
@@ -30,6 +30,7 @@
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "googlechat",
"approvalFlags": ["native"],
"label": "Google Chat",
"selectionLabel": "Google Chat (Chat API)",
"detailLabel": "Google Chat",
+1
View File
@@ -20,6 +20,7 @@
},
"channel": {
"id": "imessage",
"approvalFlags": ["native"],
"label": "iMessage",
"selectionLabel": "iMessage (imsg)",
"detailLabel": "iMessage",
+1
View File
@@ -39,6 +39,7 @@
},
"channel": {
"id": "matrix",
"approvalFlags": ["native"],
"label": "Matrix",
"selectionLabel": "Matrix (plugin)",
"docsPath": "/channels/matrix",
+1
View File
@@ -37,6 +37,7 @@
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "qqbot",
"approvalFlags": ["native"],
"label": "QQ Bot",
"selectionLabel": "QQ Bot (Official API)",
"detailLabel": "QQ Bot",
+1
View File
@@ -16,6 +16,7 @@
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "signal",
"approvalFlags": ["native"],
"label": "Signal",
"selectionLabel": "Signal (signal-cli)",
"detailLabel": "Signal REST",
+1
View File
@@ -35,6 +35,7 @@
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "slack",
"approvalFlags": ["native"],
"label": "Slack",
"selectionLabel": "Slack (Socket Mode)",
"detailLabel": "Slack Bot",
+1 -1
View File
@@ -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
+1
View File
@@ -25,6 +25,7 @@
},
"channel": {
"id": "telegram",
"approvalFlags": ["native"],
"label": "Telegram",
"selectionLabel": "Telegram (Bot API)",
"detailLabel": "Telegram Bot",
+1
View File
@@ -35,6 +35,7 @@
},
"channel": {
"id": "whatsapp",
"approvalFlags": ["native"],
"label": "WhatsApp",
"selectionLabel": "WhatsApp (QR link)",
"detailLabel": "WhatsApp Web",
+1 -13
View File
@@ -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");
+3 -2
View File
@@ -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,
};
}
+7 -11
View File
@@ -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();
+36 -43
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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)}` };
+7
View File
@@ -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;
};
+4
View File
@@ -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;
-30
View File
@@ -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);
}
+28 -22
View File
@@ -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", () => {
+13 -1
View File
@@ -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);