Merge branch 'main' into feat/deepseek-provider

This commit is contained in:
07akioni
2026-03-18 11:41:11 +08:00
committed by GitHub
145 changed files with 4494 additions and 2905 deletions

View File

@@ -132,13 +132,15 @@ Docs: https://docs.openclaw.ai
- Telegram/network: unify API and media fetches under the same sticky IPv4 and pinned-IP fallback chain, and re-validate pinned override addresses against SSRF policy. (#49148) Thanks @obviyus.
- Agents/prompt composition: append bootstrap truncation warnings to the current-turn prompt and add regression coverage for stable system-prompt cache invariants. (#49237) Thanks @scoootscooob.
- Gateway/auth: add regression coverage that keeps device-less trusted-proxy Control UI sessions off privileged pairing approval RPCs. Thanks @vincentkoc.
- Plugins/runtime-api: pin extension runtime-api export seams with explicit guardrail coverage so future surface creep becomes a deliberate diff. Thanks @vincentkoc.
- Telegram/security: add regression coverage proving pinned fallback host overrides stay bound to Telegram and delegate non-matching hostnames back to the original lookup path. Thanks @vincentkoc.
### Breaking
- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc.
- Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root.
- Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly.
- Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package.
- Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package. Thanks @gumadeiras.
## 2026.3.13

View File

@@ -242,7 +242,7 @@ http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789#token=<gateway-toke
Notes:
- `gatewayUrl` is stored in localStorage after load and removed from the URL.
- `token` is preferably imported from the URL fragment, stored in sessionStorage for the current browser tab session and selected gateway URL, and stripped from the URL; legacy `?token=` query params are also imported once for compatibility and then removed.
- `token` should be passed via the URL fragment (`#token=...`) whenever possible. Fragments are not sent to the server, which avoids request-log and Referer leakage. Legacy `?token=` query params are still imported once for compatibility, but only as a fallback, and are stripped immediately after bootstrap.
- `password` is kept in memory only.
- When `gatewayUrl` is set, the UI does not fall back to config or environment credentials.
Provide `token` (or `password`) explicitly. Missing explicit credentials is an error.

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { resolveBlueBubblesAccount } from "./accounts.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { normalizeResolvedSecretInputString } from "./secret-input.js";
export type BlueBubblesAccountResolveOpts = {

View File

@@ -1,5 +1,5 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { createAccountListHelpers, type OpenClawConfig } from "./runtime-api.js";
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";

View File

@@ -1,3 +1,6 @@
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
import {
BLUEBUBBLES_ACTION_NAMES,
BLUEBUBBLES_ACTIONS,
@@ -10,10 +13,7 @@ import {
readStringParam,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
} from "openclaw/plugin-sdk/bluebubbles";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
} from "./runtime-api.js";
import { normalizeSecretInputString } from "./secret-input.js";
import {
normalizeBlueBubblesHandle,

View File

@@ -1,6 +1,5 @@
import crypto from "node:crypto";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
import {
@@ -8,6 +7,7 @@ import {
isBlueBubblesPrivateApiStatusEnabled,
} from "./probe.js";
import { resolveRequestUrl } from "./request-url.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { getBlueBubblesRuntime, warnBlueBubbles } from "./runtime.js";
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
import { resolveChatGuidForTarget } from "./send.js";

View File

@@ -1,23 +1,11 @@
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
import type { ChannelAccountSnapshot, ChannelPlugin } from "openclaw/plugin-sdk/bluebubbles";
import {
buildChannelConfigSchema,
buildComputedAccountStatusSnapshot,
buildProbeChannelStatusSummary,
collectBlueBubblesStatusIssues,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
PAIRING_APPROVED_MESSAGE,
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
setAccountEnabledInConfigSection,
} from "openclaw/plugin-sdk/bluebubbles";
import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import {
buildAccountScopedDmSecurityPolicy,
collectOpenGroupPolicyRestrictSendersWarnings,
} from "openclaw/plugin-sdk/channel-policy";
import { collectOpenGroupPolicyRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import {
listBlueBubblesAccountIds,
@@ -28,6 +16,17 @@ import {
import { bluebubblesMessageActions } from "./actions.js";
import type { BlueBubblesProbe } from "./channel.runtime.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "./runtime-api.js";
import {
buildChannelConfigSchema,
buildComputedAccountStatusSnapshot,
buildProbeChannelStatusSummary,
collectBlueBubblesStatusIssues,
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
} from "./runtime-api.js";
import { blueBubblesSetupAdapter } from "./setup-core.js";
import { blueBubblesSetupWizard } from "./setup-surface.js";
import {
@@ -43,6 +42,32 @@ const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport(
"blueBubblesChannelRuntime",
);
const bluebubblesConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveBlueBubblesAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
allowFrom,
normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
}),
});
const bluebubblesConfigBase = createScopedChannelConfigBase<ResolvedBlueBubblesAccount>({
sectionKey: "bluebubbles",
listAccountIds: listBlueBubblesAccountIds,
resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultBlueBubblesAccountId,
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
});
const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver<ResolvedBlueBubblesAccount>({
channelKey: "bluebubbles",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
});
const meta = {
id: "bluebubbles",
label: "BlueBubbles",
@@ -85,24 +110,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
setupWizard: blueBubblesSetupWizard,
config: {
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg: cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg,
sectionKey: "bluebubbles",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg,
sectionKey: "bluebubbles",
accountId,
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
}),
...bluebubblesConfigBase,
isConfigured: (account) => account.configured,
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
@@ -111,28 +119,11 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
configured: account.configured,
baseUrl: account.baseUrl,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
mapAllowFromEntries(resolveBlueBubblesAccount({ cfg: cfg, accountId }).config.allowFrom),
formatAllowFrom: ({ allowFrom }) =>
formatNormalizedAllowFromEntries({
allowFrom,
normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
}),
...bluebubblesConfigAccessors,
},
actions: bluebubblesMessageActions,
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
return buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: "bluebubbles",
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.config.dmPolicy,
allowFrom: account.config.allowFrom ?? [],
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
});
},
resolveDmPolicy: resolveBlueBubblesDmPolicy,
collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "allowlist";
return collectOpenGroupPolicyRestrictSendersWarnings({

View File

@@ -1,9 +1,9 @@
import crypto from "node:crypto";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
export type BlueBubblesChatOpts = {

View File

@@ -1,4 +1,4 @@
import { DEFAULT_ACCOUNT_ID, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { DEFAULT_ACCOUNT_ID, type OpenClawConfig } from "./runtime-api.js";
type BlueBubblesConfigPatch = {
serverUrl?: string;

View File

@@ -1,4 +1,3 @@
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles";
import {
AllowFromListSchema,
buildCatchallMultiAccountChannelSchema,
@@ -6,6 +5,7 @@ import {
GroupPolicySchema,
} from "openclaw/plugin-sdk/channel-config-schema";
import { z } from "zod";
import { MarkdownConfigSchema, ToolPolicySchema } from "./runtime-api.js";
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
const bluebubblesActionSchema = z

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
export type BlueBubblesHistoryEntry = {

View File

@@ -3,10 +3,10 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { sendBlueBubblesAttachment } from "./attachments.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "./runtime-api.js";
import { getBlueBubblesRuntime } from "./runtime.js";
import { sendMessageBlueBubbles } from "./send.js";

View File

@@ -1,6 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import type { NormalizedWebhookMessage } from "./monitor-normalize.js";
import type { BlueBubblesCoreRuntime, WebhookTarget } from "./monitor-shared.js";
import type { OpenClawConfig } from "./runtime-api.js";
/**
* Entry type for debouncing inbound messages.

View File

@@ -1,4 +1,4 @@
import { parseFiniteNumber } from "openclaw/plugin-sdk/bluebubbles";
import { parseFiniteNumber } from "./runtime-api.js";
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
import type { BlueBubblesAttachment } from "./types.js";

View File

@@ -1,22 +1,3 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import {
DM_GROUP_ACCESS_REASON,
createScopedPairingAccess,
createReplyPrefixOptions,
evictOldHistoryKeys,
issuePairingChallenge,
logAckFailure,
logInboundDrop,
logTypingFailure,
mapAllowFromEntries,
readStoreAllowFromForDmPolicy,
recordPendingHistoryEntryIfEnabled,
resolveAckReaction,
resolveDmGroupAccessWithLists,
resolveControlCommandGate,
stripMarkdown,
type HistoryEntry,
} from "openclaw/plugin-sdk/bluebubbles";
import { downloadBlueBubblesAttachment } from "./attachments.js";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { fetchBlueBubblesHistory } from "./history.js";
@@ -49,6 +30,25 @@ import type {
} from "./monitor-shared.js";
import { isBlueBubblesPrivateApiEnabled } from "./probe.js";
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
import type { OpenClawConfig } from "./runtime-api.js";
import {
DM_GROUP_ACCESS_REASON,
createScopedPairingAccess,
createReplyPrefixOptions,
evictOldHistoryKeys,
issuePairingChallenge,
logAckFailure,
logInboundDrop,
logTypingFailure,
mapAllowFromEntries,
readStoreAllowFromForDmPolicy,
recordPendingHistoryEntryIfEnabled,
resolveAckReaction,
resolveDmGroupAccessWithLists,
resolveControlCommandGate,
stripMarkdown,
type HistoryEntry,
} from "./runtime-api.js";
import { normalizeSecretInputString } from "./secret-input.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import {

View File

@@ -1,5 +1,5 @@
import { normalizeWebhookPath, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import { normalizeWebhookPath, type OpenClawConfig } from "./runtime-api.js";
import { getBlueBubblesRuntime } from "./runtime.js";
import type { BlueBubblesAccountConfig } from "./types.js";

View File

@@ -1,12 +1,5 @@
import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import {
createWebhookInFlightLimiter,
registerWebhookTargetWithPluginRoute,
readWebhookBodyOrReject,
resolveWebhookTargetWithAuthOrRejectSync,
withResolvedWebhookRequestPipeline,
} from "openclaw/plugin-sdk/bluebubbles";
import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
@@ -22,6 +15,13 @@ import {
type WebhookTarget,
} from "./monitor-shared.js";
import { fetchBlueBubblesServerInfo } from "./probe.js";
import {
createWebhookInFlightLimiter,
registerWebhookTargetWithPluginRoute,
readWebhookBodyOrReject,
resolveWebhookTargetWithAuthOrRejectSync,
withResolvedWebhookRequestPipeline,
} from "./runtime-api.js";
import { getBlueBubblesRuntime } from "./runtime.js";
const webhookTargets = new Map<string, WebhookTarget[]>();

View File

@@ -1,4 +1,4 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk/bluebubbles";
import type { BaseProbeResult } from "./runtime-api.js";
import { normalizeSecretInputString } from "./secret-input.js";
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";

View File

@@ -1,6 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
export type BlueBubblesReactionOpts = {

View File

@@ -1 +1 @@
export { resolveRequestUrl } from "openclaw/plugin-sdk/bluebubbles";
export { resolveRequestUrl } from "./runtime-api.js";

View File

@@ -0,0 +1 @@
export * from "openclaw/plugin-sdk/bluebubbles";

View File

@@ -1,5 +1,5 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "./runtime-api.js";
const runtimeStore = createPluginRuntimeStore<PluginRuntime>("BlueBubbles runtime not initialized");
type LegacyRuntimeLogShape = { log?: (message: string) => void };

View File

@@ -3,7 +3,7 @@ import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/bluebubbles";
} from "./runtime-api.js";
export {
buildSecretInputSchema,

View File

@@ -1,11 +1,11 @@
import crypto from "node:crypto";
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { stripMarkdown } from "openclaw/plugin-sdk/bluebubbles";
import { resolveBlueBubblesAccount } from "./accounts.js";
import {
getCachedBlueBubblesPrivateApiStatus,
isBlueBubblesPrivateApiStatusEnabled,
} from "./probe.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { stripMarkdown } from "./runtime-api.js";
import { warnBlueBubbles } from "./runtime.js";
import { normalizeSecretInputString } from "./secret-input.js";
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";

View File

@@ -1,8 +1,8 @@
import {
createTopLevelChannelDmPolicySetter,
normalizeAccountId,
patchScopedAccountConfig,
prepareScopedSetupConfig,
setTopLevelChannelDmPolicyWithAllowFrom,
type ChannelSetupAdapter,
type DmPolicy,
type OpenClawConfig,
@@ -10,13 +10,12 @@ import {
import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
const channel = "bluebubbles" as const;
const setBlueBubblesTopLevelDmPolicy = createTopLevelChannelDmPolicySetter({
channel,
});
export function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig {
return setTopLevelChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy,
});
return setBlueBubblesTopLevelDmPolicy(cfg, dmPolicy);
}
export function setBlueBubblesAllowFrom(

View File

@@ -5,7 +5,7 @@ import {
type ParsedChatTarget,
resolveServicePrefixedAllowTarget,
resolveServicePrefixedTarget,
} from "openclaw/plugin-sdk/bluebubbles";
} from "./runtime-api.js";
export type BlueBubblesService = "imessage" | "sms" | "auto";

View File

@@ -1,6 +1,6 @@
import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/bluebubbles";
import type { DmPolicy, GroupPolicy } from "./runtime-api.js";
export type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/bluebubbles";
export type { DmPolicy, GroupPolicy } from "./runtime-api.js";
export type BlueBubblesGroupConfig = {
/** If true, only respond in this group when mentioned. */

View File

@@ -3,6 +3,7 @@ export * from "./src/accounts.js";
export * from "./src/actions/handle-action.guild-admin.js";
export * from "./src/actions/handle-action.js";
export * from "./src/components.js";
export * from "./src/group-policy.js";
export * from "./src/normalize.js";
export * from "./src/pluralkit.js";
export * from "./src/session-key-normalization.js";

View File

@@ -209,3 +209,42 @@ describe("discordPlugin outbound", () => {
expect(runtimeMonitorDiscordProvider).not.toHaveBeenCalled();
});
});
describe("discordPlugin groups", () => {
it("uses plugin-owned group policy resolvers", () => {
const cfg = {
channels: {
discord: {
token: "discord-test",
guilds: {
guild1: {
requireMention: false,
tools: { allow: ["message.guild"] },
channels: {
"123": {
requireMention: true,
tools: { allow: ["message.channel"] },
},
},
},
},
},
},
} as OpenClawConfig;
expect(
discordPlugin.groups?.resolveRequireMention?.({
cfg,
groupSpace: "guild1",
groupId: "123",
}),
).toBe(true);
expect(
discordPlugin.groups?.resolveToolPolicy?.({
cfg,
groupSpace: "guild1",
groupId: "123",
}),
).toEqual({ allow: ["message.channel"] });
});
});

View File

@@ -3,8 +3,8 @@ import {
buildAccountScopedAllowlistConfigEditor,
resolveLegacyDmAllowlistConfigPaths,
} from "openclaw/plugin-sdk/allowlist-config-edit";
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import {
buildAccountScopedDmSecurityPolicy,
collectOpenGroupPolicyConfiguredRouteWarnings,
collectOpenProviderGroupPolicyWarnings,
} from "openclaw/plugin-sdk/channel-config-helpers";
@@ -21,8 +21,6 @@ import {
PAIRING_APPROVED_MESSAGE,
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
type ChannelMessageActionAdapter,
type ChannelPlugin,
type OpenClawConfig,
@@ -38,6 +36,10 @@ import {
isDiscordExecApprovalClientEnabled,
shouldSuppressLocalDiscordExecApprovalPrompt,
} from "./exec-approvals.js";
import {
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
} from "./group-policy.js";
import { monitorDiscordProvider } from "./monitor.js";
import {
looksLikeDiscordTargetId,
@@ -61,6 +63,14 @@ type DiscordSendFn = ReturnType<
const meta = getChatChannelMeta("discord");
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
const resolveDiscordDmPolicy = createScopedDmSecurityResolver<ResolvedDiscordAccount>({
channelKey: "discord",
resolvePolicy: (account) => account.config.dm?.policy,
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
allowFromPathSuffix: "dm.",
normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
});
function formatDiscordIntents(intents?: {
messageContent?: string;
guildMembers?: string;
@@ -300,18 +310,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
}),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
return buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: "discord",
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.config.dm?.policy,
allowFrom: account.config.dm?.allowFrom ?? [],
allowFromPathSuffix: "dm.",
normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
});
},
resolveDmPolicy: resolveDiscordDmPolicy,
collectWarnings: ({ account, cfg }) => {
const guildEntries = account.config.guilds ?? {};
const guildsConfigured = Object.keys(guildEntries).length > 0;

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
} from "./group-policy.js";
describe("discord group policy", () => {
it("prefers channel policy, then guild policy, with sender-specific overrides", () => {
const discordCfg = {
channels: {
discord: {
token: "discord-test",
guilds: {
guild1: {
requireMention: false,
tools: { allow: ["message.guild"] },
toolsBySender: {
"id:user:guild-admin": { allow: ["sessions.list"] },
},
channels: {
"123": {
requireMention: true,
tools: { allow: ["message.channel"] },
toolsBySender: {
"id:user:channel-admin": { deny: ["exec"] },
},
},
},
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(
resolveDiscordGroupRequireMention({ cfg: discordCfg, groupSpace: "guild1", groupId: "123" }),
).toBe(true);
expect(
resolveDiscordGroupRequireMention({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "missing",
}),
).toBe(false);
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "123",
senderId: "user:channel-admin",
}),
).toEqual({ deny: ["exec"] });
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "123",
senderId: "user:someone",
}),
).toEqual({ allow: ["message.channel"] });
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "missing",
senderId: "user:guild-admin",
}),
).toEqual({ allow: ["sessions.list"] });
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "missing",
senderId: "user:someone",
}),
).toEqual({ allow: ["message.guild"] });
});
});

View File

@@ -0,0 +1,111 @@
import {
resolveToolsBySender,
type GroupToolPolicyBySenderConfig,
type GroupToolPolicyConfig,
} from "openclaw/plugin-sdk/channel-policy";
import { type ChannelGroupContext } from "openclaw/plugin-sdk/channel-runtime";
import { normalizeAtHashSlug } from "openclaw/plugin-sdk/core";
import type { DiscordConfig } from "openclaw/plugin-sdk/discord";
function normalizeDiscordSlug(value?: string | null) {
return normalizeAtHashSlug(value);
}
type SenderScopedToolsEntry = {
tools?: GroupToolPolicyConfig;
toolsBySender?: GroupToolPolicyBySenderConfig;
requireMention?: boolean;
};
function resolveDiscordGuildEntry(guilds: DiscordConfig["guilds"], groupSpace?: string | null) {
if (!guilds || Object.keys(guilds).length === 0) {
return null;
}
const space = groupSpace?.trim() ?? "";
if (space && guilds[space]) {
return guilds[space];
}
const normalized = normalizeDiscordSlug(space);
if (normalized && guilds[normalized]) {
return guilds[normalized];
}
if (normalized) {
const match = Object.values(guilds).find(
(entry) => normalizeDiscordSlug(entry?.slug ?? undefined) === normalized,
);
if (match) {
return match;
}
}
return guilds["*"] ?? null;
}
function resolveDiscordChannelEntry<TEntry extends SenderScopedToolsEntry>(
channelEntries: Record<string, TEntry> | undefined,
params: { groupId?: string | null; groupChannel?: string | null },
): TEntry | undefined {
if (!channelEntries || Object.keys(channelEntries).length === 0) {
return undefined;
}
const groupChannel = params.groupChannel;
const channelSlug = normalizeDiscordSlug(groupChannel);
return (
(params.groupId ? channelEntries[params.groupId] : undefined) ??
(channelSlug
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
: undefined) ??
(groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined)
);
}
function resolveSenderToolsEntry(
entry: SenderScopedToolsEntry | undefined | null,
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
if (!entry) {
return undefined;
}
const senderPolicy = resolveToolsBySender({
toolsBySender: entry.toolsBySender,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
return senderPolicy ?? entry.tools;
}
function resolveDiscordPolicyContext(params: ChannelGroupContext) {
const guildEntry = resolveDiscordGuildEntry(
params.cfg.channels?.discord?.guilds,
params.groupSpace,
);
const channelEntries = guildEntry?.channels;
const channelEntry =
channelEntries && Object.keys(channelEntries).length > 0
? resolveDiscordChannelEntry(channelEntries, params)
: undefined;
return { guildEntry, channelEntry };
}
export function resolveDiscordGroupRequireMention(params: ChannelGroupContext): boolean {
const context = resolveDiscordPolicyContext(params);
if (typeof context.channelEntry?.requireMention === "boolean") {
return context.channelEntry.requireMention;
}
if (typeof context.guildEntry?.requireMention === "boolean") {
return context.guildEntry.requireMention;
}
return true;
}
export function resolveDiscordGroupToolPolicy(
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
const context = resolveDiscordPolicyContext(params);
const channelPolicy = resolveSenderToolsEntry(context.channelEntry, params);
if (channelPolicy) {
return channelPolicy;
}
return resolveSenderToolsEntry(context.guildEntry, params);
}

View File

@@ -0,0 +1,754 @@
import {
type ButtonInteraction,
type ChannelSelectMenuInteraction,
type ComponentData,
type MentionableSelectMenuInteraction,
type ModalInteraction,
type RoleSelectMenuInteraction,
type StringSelectMenuInteraction,
type UserSelectMenuInteraction,
} from "@buape/carbon";
import type { APIStringSelectComponent } from "discord-api-types/v10";
import { ChannelType } from "discord-api-types/v10";
import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
import {
issuePairingChallenge,
upsertChannelPairingRequest,
} from "openclaw/plugin-sdk/conversation-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import {
readStoreAllowFromForDmPolicy,
resolvePinnedMainDmOwnerFromAllowlist,
} from "openclaw/plugin-sdk/security-runtime";
import { logError } from "openclaw/plugin-sdk/text-runtime";
import {
createDiscordFormModal,
parseDiscordComponentCustomId,
parseDiscordModalCustomId,
type DiscordComponentEntry,
type DiscordModalEntry,
} from "../components.js";
import {
type DiscordGuildEntryResolved,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveDiscordAllowListMatch,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
resolveDiscordMemberAccessState,
resolveDiscordOwnerAccess,
} from "./allow-list.js";
import { formatDiscordUserTag } from "./format.js";
export const AGENT_BUTTON_KEY = "agent";
export const AGENT_SELECT_KEY = "agentsel";
export type DiscordUser = Parameters<typeof formatDiscordUserTag>[0];
export type AgentComponentMessageInteraction =
| ButtonInteraction
| StringSelectMenuInteraction
| RoleSelectMenuInteraction
| UserSelectMenuInteraction
| MentionableSelectMenuInteraction
| ChannelSelectMenuInteraction;
export type AgentComponentInteraction = AgentComponentMessageInteraction | ModalInteraction;
export type DiscordChannelContext = {
channelName: string | undefined;
channelSlug: string;
channelType: number | undefined;
isThread: boolean;
parentId: string | undefined;
parentName: string | undefined;
parentSlug: string;
};
export type AgentComponentContext = {
cfg: OpenClawConfig;
accountId: string;
discordConfig?: DiscordAccountConfig;
runtime?: import("openclaw/plugin-sdk/runtime-env").RuntimeEnv;
token?: string;
guildEntries?: Record<string, DiscordGuildEntryResolved>;
allowFrom?: string[];
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
};
export type ComponentInteractionContext = NonNullable<
Awaited<ReturnType<typeof resolveComponentInteractionContext>>
>;
function formatUsername(user: { username: string; discriminator?: string | null }): string {
if (user.discriminator && user.discriminator !== "0") {
return `${user.username}#${user.discriminator}`;
}
return user.username;
}
function isThreadChannelType(channelType: number | undefined): boolean {
return (
channelType === ChannelType.PublicThread ||
channelType === ChannelType.PrivateThread ||
channelType === ChannelType.AnnouncementThread
);
}
function readParsedComponentId(data: ComponentData): unknown {
if (!data || typeof data !== "object") {
return undefined;
}
return "cid" in data
? (data as Record<string, unknown>).cid
: (data as Record<string, unknown>).componentId;
}
function normalizeComponentId(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
return undefined;
}
function mapOptionLabels(
options: Array<{ value: string; label: string }> | undefined,
values: string[],
) {
if (!options || options.length === 0) {
return values;
}
const map = new Map(options.map((option) => [option.value, option.label]));
return values.map((value) => map.get(value) ?? value);
}
/**
* The component custom id only carries the logical button id. Channel binding
* comes from Discord's trusted interaction payload.
*/
export function buildAgentButtonCustomId(componentId: string): string {
return `${AGENT_BUTTON_KEY}:componentId=${encodeURIComponent(componentId)}`;
}
export function buildAgentSelectCustomId(componentId: string): string {
return `${AGENT_SELECT_KEY}:componentId=${encodeURIComponent(componentId)}`;
}
export function resolveAgentComponentRoute(params: {
ctx: AgentComponentContext;
rawGuildId: string | undefined;
memberRoleIds: string[];
isDirectMessage: boolean;
userId: string;
channelId: string;
parentId: string | undefined;
}) {
return resolveAgentRoute({
cfg: params.ctx.cfg,
channel: "discord",
accountId: params.ctx.accountId,
guildId: params.rawGuildId,
memberRoleIds: params.memberRoleIds,
peer: {
kind: params.isDirectMessage ? "direct" : "channel",
id: params.isDirectMessage ? params.userId : params.channelId,
},
parentPeer: params.parentId ? { kind: "channel", id: params.parentId } : undefined,
});
}
export async function ackComponentInteraction(params: {
interaction: AgentComponentInteraction;
replyOpts: { ephemeral?: boolean };
label: string;
}) {
try {
await params.interaction.reply({
content: "✓",
...params.replyOpts,
});
} catch (err) {
logError(`${params.label}: failed to acknowledge interaction: ${String(err)}`);
}
}
export function resolveDiscordChannelContext(
interaction: AgentComponentInteraction,
): DiscordChannelContext {
const channel = interaction.channel;
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const channelType = channel && "type" in channel ? (channel.type as number) : undefined;
const isThread = isThreadChannelType(channelType);
let parentId: string | undefined;
let parentName: string | undefined;
let parentSlug = "";
if (isThread && channel && "parentId" in channel) {
parentId = (channel.parentId as string) ?? undefined;
if ("parent" in channel) {
const parent = (channel as { parent?: { name?: string } }).parent;
if (parent?.name) {
parentName = parent.name;
parentSlug = normalizeDiscordSlug(parentName);
}
}
}
return { channelName, channelSlug, channelType, isThread, parentId, parentName, parentSlug };
}
export async function resolveComponentInteractionContext(params: {
interaction: AgentComponentInteraction;
label: string;
defer?: boolean;
}) {
const { interaction, label } = params;
const channelId = interaction.rawData.channel_id;
if (!channelId) {
logError(`${label}: missing channel_id in interaction`);
return null;
}
const user = interaction.user;
if (!user) {
logError(`${label}: missing user in interaction`);
return null;
}
const shouldDefer = params.defer !== false && "defer" in interaction;
let didDefer = false;
if (shouldDefer) {
try {
await (interaction as AgentComponentMessageInteraction).defer({ ephemeral: true });
didDefer = true;
} catch (err) {
logError(`${label}: failed to defer interaction: ${String(err)}`);
}
}
const replyOpts = didDefer ? {} : { ephemeral: true };
const username = formatUsername(user);
const userId = user.id;
const rawGuildId = interaction.rawData.guild_id;
const isDirectMessage = !rawGuildId;
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
: [];
return {
channelId,
user,
username,
userId,
replyOpts,
rawGuildId,
isDirectMessage,
memberRoleIds,
};
}
export async function ensureGuildComponentMemberAllowed(params: {
interaction: AgentComponentInteraction;
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
channelId: string;
rawGuildId: string | undefined;
channelCtx: DiscordChannelContext;
memberRoleIds: string[];
user: DiscordUser;
replyOpts: { ephemeral?: boolean };
componentLabel: string;
unauthorizedReply: string;
allowNameMatching: boolean;
}) {
const {
interaction,
guildInfo,
channelId,
rawGuildId,
channelCtx,
memberRoleIds,
user,
replyOpts,
componentLabel,
unauthorizedReply,
} = params;
if (!rawGuildId) {
return true;
}
const channelConfig = resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId,
channelName: channelCtx.channelName,
channelSlug: channelCtx.channelSlug,
parentId: channelCtx.parentId,
parentName: channelCtx.parentName,
parentSlug: channelCtx.parentSlug,
scope: channelCtx.isThread ? "thread" : "channel",
});
const { memberAllowed } = resolveDiscordMemberAccessState({
channelConfig,
guildInfo,
memberRoleIds,
sender: {
id: user.id,
name: user.username,
tag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
},
allowNameMatching: params.allowNameMatching,
});
if (memberAllowed) {
return true;
}
logVerbose(`agent ${componentLabel}: blocked user ${user.id} (not in users/roles allowlist)`);
try {
await interaction.reply({
content: unauthorizedReply,
...replyOpts,
});
} catch {}
return false;
}
export async function ensureComponentUserAllowed(params: {
entry: DiscordComponentEntry;
interaction: AgentComponentInteraction;
user: DiscordUser;
replyOpts: { ephemeral?: boolean };
componentLabel: string;
unauthorizedReply: string;
allowNameMatching: boolean;
}) {
const allowList = normalizeDiscordAllowList(params.entry.allowedUsers, [
"discord:",
"user:",
"pk:",
]);
if (!allowList) {
return true;
}
const match = resolveDiscordAllowListMatch({
allowList,
candidate: {
id: params.user.id,
name: params.user.username,
tag: formatDiscordUserTag(params.user),
},
allowNameMatching: params.allowNameMatching,
});
if (match.allowed) {
return true;
}
logVerbose(
`discord component ${params.componentLabel}: blocked user ${params.user.id} (not in allowedUsers)`,
);
try {
await params.interaction.reply({
content: params.unauthorizedReply,
...params.replyOpts,
});
} catch {}
return false;
}
export async function ensureAgentComponentInteractionAllowed(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
channelId: string;
rawGuildId: string | undefined;
memberRoleIds: string[];
user: DiscordUser;
replyOpts: { ephemeral?: boolean };
componentLabel: string;
unauthorizedReply: string;
}) {
const guildInfo = resolveDiscordGuildEntry({
guild: params.interaction.guild ?? undefined,
guildId: params.rawGuildId,
guildEntries: params.ctx.guildEntries,
});
const channelCtx = resolveDiscordChannelContext(params.interaction);
const memberAllowed = await ensureGuildComponentMemberAllowed({
interaction: params.interaction,
guildInfo,
channelId: params.channelId,
rawGuildId: params.rawGuildId,
channelCtx,
memberRoleIds: params.memberRoleIds,
user: params.user,
replyOpts: params.replyOpts,
componentLabel: params.componentLabel,
unauthorizedReply: params.unauthorizedReply,
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
});
if (!memberAllowed) {
return null;
}
return { parentId: channelCtx.parentId };
}
export function parseAgentComponentData(data: ComponentData): { componentId: string } | null {
const raw = readParsedComponentId(data);
const decodeSafe = (value: string): string => {
if (!value.includes("%")) {
return value;
}
if (!/%[0-9A-Fa-f]{2}/.test(value)) {
return value;
}
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const componentId =
typeof raw === "string" ? decodeSafe(raw) : typeof raw === "number" ? String(raw) : null;
if (!componentId) {
return null;
}
return { componentId };
}
async function ensureDmComponentAuthorized(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
user: DiscordUser;
componentLabel: string;
replyOpts: { ephemeral?: boolean };
}) {
const { ctx, interaction, user, componentLabel, replyOpts } = params;
const dmPolicy = ctx.dmPolicy ?? "pairing";
if (dmPolicy === "disabled") {
logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`);
try {
await interaction.reply({
content: "DM interactions are disabled.",
...replyOpts,
});
} catch {}
return false;
}
if (dmPolicy === "open") {
return true;
}
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "discord",
accountId: ctx.accountId,
dmPolicy,
});
const effectiveAllowFrom = [...(ctx.allowFrom ?? []), ...storeAllowFrom];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
const allowMatch = allowList
? resolveDiscordAllowListMatch({
allowList,
candidate: {
id: user.id,
name: user.username,
tag: formatDiscordUserTag(user),
},
allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig),
})
: { allowed: false };
if (allowMatch.allowed) {
return true;
}
if (dmPolicy === "pairing") {
const pairingResult = await issuePairingChallenge({
channel: "discord",
senderId: user.id,
senderIdLine: `Your Discord user id: ${user.id}`,
meta: {
tag: formatDiscordUserTag(user),
name: user.username,
},
upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({
channel: "discord",
id,
accountId: ctx.accountId,
meta,
}),
sendPairingReply: async (text) => {
await interaction.reply({
content: text,
...replyOpts,
});
},
});
if (!pairingResult.created) {
try {
await interaction.reply({
content: "Pairing already requested. Ask the bot owner to approve your code.",
...replyOpts,
});
} catch {}
}
return false;
}
logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`);
try {
await interaction.reply({
content: `You are not authorized to use this ${componentLabel}.`,
...replyOpts,
});
} catch {}
return false;
}
export async function resolveInteractionContextWithDmAuth(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
label: string;
componentLabel: string;
defer?: boolean;
}) {
const interactionCtx = await resolveComponentInteractionContext({
interaction: params.interaction,
label: params.label,
defer: params.defer,
});
if (!interactionCtx) {
return null;
}
if (interactionCtx.isDirectMessage) {
const authorized = await ensureDmComponentAuthorized({
ctx: params.ctx,
interaction: params.interaction,
user: interactionCtx.user,
componentLabel: params.componentLabel,
replyOpts: interactionCtx.replyOpts,
});
if (!authorized) {
return null;
}
}
return interactionCtx;
}
export function parseDiscordComponentData(
data: ComponentData,
customId?: string,
): { componentId: string; modalId?: string } | null {
if (!data || typeof data !== "object") {
return null;
}
const rawComponentId = readParsedComponentId(data);
const rawModalId =
"mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId;
let componentId = normalizeComponentId(rawComponentId);
let modalId = normalizeComponentId(rawModalId);
if (!componentId && customId) {
const parsed = parseDiscordComponentCustomId(customId);
if (parsed) {
componentId = parsed.componentId;
modalId = parsed.modalId;
}
}
if (!componentId) {
return null;
}
return { componentId, modalId };
}
export function parseDiscordModalId(data: ComponentData, customId?: string): string | null {
if (data && typeof data === "object") {
const rawModalId =
"mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId;
const modalId = normalizeComponentId(rawModalId);
if (modalId) {
return modalId;
}
}
if (customId) {
return parseDiscordModalCustomId(customId);
}
return null;
}
export function resolveInteractionCustomId(
interaction: AgentComponentInteraction,
): string | undefined {
if (!interaction?.rawData || typeof interaction.rawData !== "object") {
return undefined;
}
if (!("data" in interaction.rawData)) {
return undefined;
}
const data = (interaction.rawData as { data?: { custom_id?: unknown } }).data;
const customId = data?.custom_id;
if (typeof customId !== "string") {
return undefined;
}
const trimmed = customId.trim();
return trimmed ? trimmed : undefined;
}
export function mapSelectValues(entry: DiscordComponentEntry, values: string[]): string[] {
if (entry.selectType === "string") {
return mapOptionLabels(entry.options, values);
}
if (entry.selectType === "user") {
return values.map((value) => `user:${value}`);
}
if (entry.selectType === "role") {
return values.map((value) => `role:${value}`);
}
if (entry.selectType === "mentionable") {
return values.map((value) => `mentionable:${value}`);
}
if (entry.selectType === "channel") {
return values.map((value) => `channel:${value}`);
}
return values;
}
export function resolveModalFieldValues(
field: DiscordModalEntry["fields"][number],
interaction: ModalInteraction,
): string[] {
const fields = interaction.fields;
const optionLabels = field.options?.map((option) => ({
value: option.value,
label: option.label,
}));
const required = field.required === true;
try {
switch (field.type) {
case "text": {
const value = required ? fields.getText(field.id, true) : fields.getText(field.id);
return value ? [value] : [];
}
case "select":
case "checkbox":
case "radio": {
const values = required
? fields.getStringSelect(field.id, true)
: (fields.getStringSelect(field.id) ?? []);
return mapOptionLabels(optionLabels, values);
}
case "role-select": {
try {
const roles = required
? fields.getRoleSelect(field.id, true)
: (fields.getRoleSelect(field.id) ?? []);
return roles.map((role) => role.name ?? role.id);
} catch {
const values = required
? fields.getStringSelect(field.id, true)
: (fields.getStringSelect(field.id) ?? []);
return values;
}
}
case "user-select": {
const users = required
? fields.getUserSelect(field.id, true)
: (fields.getUserSelect(field.id) ?? []);
return users.map((user) => formatDiscordUserTag(user));
}
default:
return [];
}
} catch (err) {
logError(`agent modal: failed to read field ${field.id}: ${String(err)}`);
return [];
}
}
export function formatModalSubmissionText(
entry: DiscordModalEntry,
interaction: ModalInteraction,
): string {
const lines: string[] = [`Form "${entry.title}" submitted.`];
for (const field of entry.fields) {
const values = resolveModalFieldValues(field, interaction);
if (values.length === 0) {
continue;
}
lines.push(`- ${field.label}: ${values.join(", ")}`);
}
if (lines.length === 1) {
lines.push("- (no values)");
}
return lines.join("\n");
}
export function resolveDiscordInteractionId(interaction: AgentComponentInteraction): string {
const rawId =
interaction.rawData && typeof interaction.rawData === "object" && "id" in interaction.rawData
? (interaction.rawData as { id?: unknown }).id
: undefined;
if (typeof rawId === "string" && rawId.trim()) {
return rawId.trim();
}
if (typeof rawId === "number" && Number.isFinite(rawId)) {
return String(rawId);
}
return `discord-interaction:${Date.now()}`;
}
export function resolveComponentCommandAuthorized(params: {
ctx: AgentComponentContext;
interactionCtx: ComponentInteractionContext;
channelConfig: ReturnType<typeof resolveDiscordChannelConfigWithFallback>;
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
allowNameMatching: boolean;
}) {
const { ctx, interactionCtx, channelConfig, guildInfo } = params;
if (interactionCtx.isDirectMessage) {
return true;
}
const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({
allowFrom: ctx.allowFrom,
sender: {
id: interactionCtx.user.id,
name: interactionCtx.user.username,
tag: formatDiscordUserTag(interactionCtx.user),
},
allowNameMatching: params.allowNameMatching,
});
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
channelConfig,
guildInfo,
memberRoleIds: interactionCtx.memberRoleIds,
sender: {
id: interactionCtx.user.id,
name: interactionCtx.user.username,
tag: formatDiscordUserTag(interactionCtx.user),
},
allowNameMatching: params.allowNameMatching,
});
const useAccessGroups = ctx.cfg.commands?.useAccessGroups !== false;
const authorizers = useAccessGroups
? [
{ configured: ownerAllowList != null, allowed: ownerOk },
{ configured: hasAccessRestrictions, allowed: memberAllowed },
]
: [{ configured: hasAccessRestrictions, allowed: memberAllowed }];
return resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers,
modeWhenAccessGroupsOff: "configured",
});
}
export { resolveDiscordGuildEntry, resolvePinnedMainDmOwnerFromAllowlist };

View File

@@ -19,7 +19,6 @@ import {
import type { APIStringSelectComponent } from "discord-api-types/v10";
import { ButtonStyle, ChannelType } from "discord-api-types/v10";
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime";
import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime";
import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
@@ -27,8 +26,6 @@ import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runti
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { issuePairingChallenge } from "openclaw/plugin-sdk/conversation-runtime";
import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
import {
buildPluginBindingResolvedText,
parsePluginBindingApprovalCustomId,
@@ -48,32 +45,51 @@ import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import {
readStoreAllowFromForDmPolicy,
resolvePinnedMainDmOwnerFromAllowlist,
} from "openclaw/plugin-sdk/security-runtime";
import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js";
import {
createDiscordFormModal,
formatDiscordComponentEventText,
parseDiscordComponentCustomId,
parseDiscordComponentCustomIdForCarbon,
parseDiscordModalCustomId,
parseDiscordModalCustomIdForCarbon,
type DiscordComponentEntry,
type DiscordModalEntry,
} from "../components.js";
import {
AGENT_BUTTON_KEY,
AGENT_SELECT_KEY,
ackComponentInteraction,
buildAgentButtonCustomId,
buildAgentSelectCustomId,
type AgentComponentContext,
type AgentComponentInteraction,
type AgentComponentMessageInteraction,
ensureAgentComponentInteractionAllowed,
ensureComponentUserAllowed,
ensureGuildComponentMemberAllowed,
formatModalSubmissionText,
mapSelectValues,
parseAgentComponentData,
parseDiscordComponentData,
parseDiscordModalId,
resolveAgentComponentRoute,
resolveComponentCommandAuthorized,
type ComponentInteractionContext,
resolveDiscordChannelContext,
type DiscordChannelContext,
resolveDiscordInteractionId,
resolveInteractionContextWithDmAuth,
resolveInteractionCustomId,
resolveModalFieldValues,
resolvePinnedMainDmOwnerFromAllowlist,
type DiscordUser,
} from "./agent-components-helpers.js";
import {
type DiscordGuildEntryResolved,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveDiscordAllowListMatch,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
resolveDiscordMemberAccessState,
resolveDiscordOwnerAccess,
} from "./allow-list.js";
import { formatDiscordUserTag } from "./format.js";
import {
@@ -84,714 +100,6 @@ import { buildDirectLabel, buildGuildLabel } from "./reply-context.js";
import { deliverDiscordReply } from "./reply-delivery.js";
import { sendTyping } from "./typing.js";
const AGENT_BUTTON_KEY = "agent";
const AGENT_SELECT_KEY = "agentsel";
type DiscordUser = Parameters<typeof formatDiscordUserTag>[0];
type AgentComponentMessageInteraction =
| ButtonInteraction
| StringSelectMenuInteraction
| RoleSelectMenuInteraction
| UserSelectMenuInteraction
| MentionableSelectMenuInteraction
| ChannelSelectMenuInteraction;
type AgentComponentInteraction = AgentComponentMessageInteraction | ModalInteraction;
type ComponentInteractionContext = NonNullable<
Awaited<ReturnType<typeof resolveComponentInteractionContext>>
>;
type DiscordChannelContext = {
channelName: string | undefined;
channelSlug: string;
channelType: number | undefined;
isThread: boolean;
parentId: string | undefined;
parentName: string | undefined;
parentSlug: string;
};
function resolveAgentComponentRoute(params: {
ctx: AgentComponentContext;
rawGuildId: string | undefined;
memberRoleIds: string[];
isDirectMessage: boolean;
userId: string;
channelId: string;
parentId: string | undefined;
}) {
return resolveAgentRoute({
cfg: params.ctx.cfg,
channel: "discord",
accountId: params.ctx.accountId,
guildId: params.rawGuildId,
memberRoleIds: params.memberRoleIds,
peer: {
kind: params.isDirectMessage ? "direct" : "channel",
id: params.isDirectMessage ? params.userId : params.channelId,
},
parentPeer: params.parentId ? { kind: "channel", id: params.parentId } : undefined,
});
}
async function ackComponentInteraction(params: {
interaction: AgentComponentInteraction;
replyOpts: { ephemeral?: boolean };
label: string;
}) {
try {
await params.interaction.reply({
content: "✓",
...params.replyOpts,
});
} catch (err) {
logError(`${params.label}: failed to acknowledge interaction: ${String(err)}`);
}
}
function resolveDiscordChannelContext(
interaction: AgentComponentInteraction,
): DiscordChannelContext {
const channel = interaction.channel;
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const channelType = channel && "type" in channel ? (channel.type as number) : undefined;
const isThread = isThreadChannelType(channelType);
let parentId: string | undefined;
let parentName: string | undefined;
let parentSlug = "";
if (isThread && channel && "parentId" in channel) {
parentId = (channel.parentId as string) ?? undefined;
if ("parent" in channel) {
const parent = (channel as { parent?: { name?: string } }).parent;
if (parent?.name) {
parentName = parent.name;
parentSlug = normalizeDiscordSlug(parentName);
}
}
}
return { channelName, channelSlug, channelType, isThread, parentId, parentName, parentSlug };
}
async function resolveComponentInteractionContext(params: {
interaction: AgentComponentInteraction;
label: string;
defer?: boolean;
}): Promise<{
channelId: string;
user: DiscordUser;
username: string;
userId: string;
replyOpts: { ephemeral?: boolean };
rawGuildId: string | undefined;
isDirectMessage: boolean;
memberRoleIds: string[];
} | null> {
const { interaction, label } = params;
// Use interaction's actual channel_id (trusted source from Discord)
// This prevents channel spoofing attacks
const channelId = interaction.rawData.channel_id;
if (!channelId) {
logError(`${label}: missing channel_id in interaction`);
return null;
}
const user = interaction.user;
if (!user) {
logError(`${label}: missing user in interaction`);
return null;
}
const shouldDefer = params.defer !== false && "defer" in interaction;
let didDefer = false;
// Defer immediately to satisfy Discord's 3-second interaction ACK requirement.
// We use an ephemeral deferred reply so subsequent interaction.reply() calls
// can safely edit the original deferred response.
if (shouldDefer) {
try {
await (interaction as AgentComponentMessageInteraction).defer({ ephemeral: true });
didDefer = true;
} catch (err) {
logError(`${label}: failed to defer interaction: ${String(err)}`);
}
}
const replyOpts = didDefer ? {} : { ephemeral: true };
const username = formatUsername(user);
const userId = user.id;
// P1 FIX: Use rawData.guild_id as source of truth - interaction.guild can be null
// when guild is not cached even though guild_id is present in rawData
const rawGuildId = interaction.rawData.guild_id;
const isDirectMessage = !rawGuildId;
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
: [];
return {
channelId,
user,
username,
userId,
replyOpts,
rawGuildId,
isDirectMessage,
memberRoleIds,
};
}
async function ensureGuildComponentMemberAllowed(params: {
interaction: AgentComponentInteraction;
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
channelId: string;
rawGuildId: string | undefined;
channelCtx: DiscordChannelContext;
memberRoleIds: string[];
user: DiscordUser;
replyOpts: { ephemeral?: boolean };
componentLabel: string;
unauthorizedReply: string;
allowNameMatching: boolean;
}): Promise<boolean> {
const {
interaction,
guildInfo,
channelId,
rawGuildId,
channelCtx,
memberRoleIds,
user,
replyOpts,
componentLabel,
unauthorizedReply,
} = params;
if (!rawGuildId) {
return true;
}
const channelConfig = resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId,
channelName: channelCtx.channelName,
channelSlug: channelCtx.channelSlug,
parentId: channelCtx.parentId,
parentName: channelCtx.parentName,
parentSlug: channelCtx.parentSlug,
scope: channelCtx.isThread ? "thread" : "channel",
});
const { memberAllowed } = resolveDiscordMemberAccessState({
channelConfig,
guildInfo,
memberRoleIds,
sender: {
id: user.id,
name: user.username,
tag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
},
allowNameMatching: params.allowNameMatching,
});
if (memberAllowed) {
return true;
}
logVerbose(`agent ${componentLabel}: blocked user ${user.id} (not in users/roles allowlist)`);
try {
await interaction.reply({
content: unauthorizedReply,
...replyOpts,
});
} catch {
// Interaction may have expired
}
return false;
}
async function ensureComponentUserAllowed(params: {
entry: DiscordComponentEntry;
interaction: AgentComponentInteraction;
user: DiscordUser;
replyOpts: { ephemeral?: boolean };
componentLabel: string;
unauthorizedReply: string;
allowNameMatching: boolean;
}): Promise<boolean> {
const allowList = normalizeDiscordAllowList(params.entry.allowedUsers, [
"discord:",
"user:",
"pk:",
]);
if (!allowList) {
return true;
}
const match = resolveDiscordAllowListMatch({
allowList,
candidate: {
id: params.user.id,
name: params.user.username,
tag: formatDiscordUserTag(params.user),
},
allowNameMatching: params.allowNameMatching,
});
if (match.allowed) {
return true;
}
logVerbose(
`discord component ${params.componentLabel}: blocked user ${params.user.id} (not in allowedUsers)`,
);
try {
await params.interaction.reply({
content: params.unauthorizedReply,
...params.replyOpts,
});
} catch {
// Interaction may have expired
}
return false;
}
async function ensureAgentComponentInteractionAllowed(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
channelId: string;
rawGuildId: string | undefined;
memberRoleIds: string[];
user: DiscordUser;
replyOpts: { ephemeral?: boolean };
componentLabel: string;
unauthorizedReply: string;
}): Promise<{ parentId: string | undefined } | null> {
const guildInfo = resolveDiscordGuildEntry({
guild: params.interaction.guild ?? undefined,
guildId: params.rawGuildId,
guildEntries: params.ctx.guildEntries,
});
const channelCtx = resolveDiscordChannelContext(params.interaction);
const memberAllowed = await ensureGuildComponentMemberAllowed({
interaction: params.interaction,
guildInfo,
channelId: params.channelId,
rawGuildId: params.rawGuildId,
channelCtx,
memberRoleIds: params.memberRoleIds,
user: params.user,
replyOpts: params.replyOpts,
componentLabel: params.componentLabel,
unauthorizedReply: params.unauthorizedReply,
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
});
if (!memberAllowed) {
return null;
}
return { parentId: channelCtx.parentId };
}
export type AgentComponentContext = {
cfg: OpenClawConfig;
accountId: string;
discordConfig?: DiscordAccountConfig;
runtime?: RuntimeEnv;
token?: string;
guildEntries?: Record<string, DiscordGuildEntryResolved>;
/** DM allowlist (from allowFrom config; legacy: dm.allowFrom) */
allowFrom?: string[];
/** DM policy (default: "pairing") */
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
};
/**
* Build agent button custom ID: agent:componentId=<id>
* The channelId is NOT embedded in customId - we use interaction.rawData.channel_id instead
* to prevent channel spoofing attacks.
*
* Carbon's customIdParser parses "key:arg1=value1;arg2=value2" into { arg1: value1, arg2: value2 }
*/
export function buildAgentButtonCustomId(componentId: string): string {
return `${AGENT_BUTTON_KEY}:componentId=${encodeURIComponent(componentId)}`;
}
/**
* Build agent select menu custom ID: agentsel:componentId=<id>
*/
export function buildAgentSelectCustomId(componentId: string): string {
return `${AGENT_SELECT_KEY}:componentId=${encodeURIComponent(componentId)}`;
}
/**
* Parse agent component data from Carbon's parsed ComponentData
* Supports both legacy { componentId } and Components v2 { cid } payloads.
*/
function readParsedComponentId(data: ComponentData): unknown {
if (!data || typeof data !== "object") {
return undefined;
}
return "cid" in data
? (data as Record<string, unknown>).cid
: (data as Record<string, unknown>).componentId;
}
function parseAgentComponentData(data: ComponentData): {
componentId: string;
} | null {
const raw = readParsedComponentId(data);
const decodeSafe = (value: string): string => {
// `cid` values may be raw (not URI-encoded). Guard against malformed % sequences.
// Only attempt decoding when it looks like it contains percent-encoding.
if (!value.includes("%")) {
return value;
}
// If it has a % but not a valid %XX sequence, skip decode.
if (!/%[0-9A-Fa-f]{2}/.test(value)) {
return value;
}
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const componentId =
typeof raw === "string" ? decodeSafe(raw) : typeof raw === "number" ? String(raw) : null;
if (!componentId) {
return null;
}
return { componentId };
}
function formatUsername(user: { username: string; discriminator?: string | null }): string {
if (user.discriminator && user.discriminator !== "0") {
return `${user.username}#${user.discriminator}`;
}
return user.username;
}
/**
* Check if a channel type is a thread type
*/
function isThreadChannelType(channelType: number | undefined): boolean {
return (
channelType === ChannelType.PublicThread ||
channelType === ChannelType.PrivateThread ||
channelType === ChannelType.AnnouncementThread
);
}
async function ensureDmComponentAuthorized(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
user: DiscordUser;
componentLabel: string;
replyOpts: { ephemeral?: boolean };
}): Promise<boolean> {
const { ctx, interaction, user, componentLabel, replyOpts } = params;
const dmPolicy = ctx.dmPolicy ?? "pairing";
if (dmPolicy === "disabled") {
logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`);
try {
await interaction.reply({
content: "DM interactions are disabled.",
...replyOpts,
});
} catch {
// Interaction may have expired
}
return false;
}
if (dmPolicy === "open") {
return true;
}
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "discord",
accountId: ctx.accountId,
dmPolicy,
});
const effectiveAllowFrom = [...(ctx.allowFrom ?? []), ...storeAllowFrom];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
const allowMatch = allowList
? resolveDiscordAllowListMatch({
allowList,
candidate: {
id: user.id,
name: user.username,
tag: formatDiscordUserTag(user),
},
allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig),
})
: { allowed: false };
if (allowMatch.allowed) {
return true;
}
if (dmPolicy === "pairing") {
const pairingResult = await issuePairingChallenge({
channel: "discord",
senderId: user.id,
senderIdLine: `Your Discord user id: ${user.id}`,
meta: {
tag: formatDiscordUserTag(user),
name: user.username,
},
upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({
channel: "discord",
id,
accountId: ctx.accountId,
meta,
}),
sendPairingReply: async (text) => {
await interaction.reply({
content: text,
...replyOpts,
});
},
});
if (!pairingResult.created) {
try {
await interaction.reply({
content: "Pairing already requested. Ask the bot owner to approve your code.",
...replyOpts,
});
} catch {
// Interaction may have expired
}
}
return false;
}
logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`);
try {
await interaction.reply({
content: `You are not authorized to use this ${componentLabel}.`,
...replyOpts,
});
} catch {
// Interaction may have expired
}
return false;
}
async function resolveInteractionContextWithDmAuth(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
label: string;
componentLabel: string;
defer?: boolean;
}): Promise<ComponentInteractionContext | null> {
const interactionCtx = await resolveComponentInteractionContext({
interaction: params.interaction,
label: params.label,
defer: params.defer,
});
if (!interactionCtx) {
return null;
}
if (interactionCtx.isDirectMessage) {
const authorized = await ensureDmComponentAuthorized({
ctx: params.ctx,
interaction: params.interaction,
user: interactionCtx.user,
componentLabel: params.componentLabel,
replyOpts: interactionCtx.replyOpts,
});
if (!authorized) {
return null;
}
}
return interactionCtx;
}
function normalizeComponentId(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
return undefined;
}
function parseDiscordComponentData(
data: ComponentData,
customId?: string,
): { componentId: string; modalId?: string } | null {
if (!data || typeof data !== "object") {
return null;
}
const rawComponentId = readParsedComponentId(data);
const rawModalId =
"mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId;
let componentId = normalizeComponentId(rawComponentId);
let modalId = normalizeComponentId(rawModalId);
if (!componentId && customId) {
const parsed = parseDiscordComponentCustomId(customId);
if (parsed) {
componentId = parsed.componentId;
modalId = parsed.modalId;
}
}
if (!componentId) {
return null;
}
return { componentId, modalId };
}
function parseDiscordModalId(data: ComponentData, customId?: string): string | null {
if (data && typeof data === "object") {
const rawModalId =
"mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId;
const modalId = normalizeComponentId(rawModalId);
if (modalId) {
return modalId;
}
}
if (customId) {
return parseDiscordModalCustomId(customId);
}
return null;
}
function resolveInteractionCustomId(interaction: AgentComponentInteraction): string | undefined {
if (!interaction?.rawData || typeof interaction.rawData !== "object") {
return undefined;
}
if (!("data" in interaction.rawData)) {
return undefined;
}
const data = (interaction.rawData as { data?: { custom_id?: unknown } }).data;
const customId = data?.custom_id;
if (typeof customId !== "string") {
return undefined;
}
const trimmed = customId.trim();
return trimmed ? trimmed : undefined;
}
function mapOptionLabels(
options: Array<{ value: string; label: string }> | undefined,
values: string[],
) {
if (!options || options.length === 0) {
return values;
}
const map = new Map(options.map((option) => [option.value, option.label]));
return values.map((value) => map.get(value) ?? value);
}
function mapSelectValues(entry: DiscordComponentEntry, values: string[]): string[] {
if (entry.selectType === "string") {
return mapOptionLabels(entry.options, values);
}
if (entry.selectType === "user") {
return values.map((value) => `user:${value}`);
}
if (entry.selectType === "role") {
return values.map((value) => `role:${value}`);
}
if (entry.selectType === "mentionable") {
return values.map((value) => `mentionable:${value}`);
}
if (entry.selectType === "channel") {
return values.map((value) => `channel:${value}`);
}
return values;
}
function resolveModalFieldValues(
field: DiscordModalEntry["fields"][number],
interaction: ModalInteraction,
): string[] {
const fields = interaction.fields;
const optionLabels = field.options?.map((option) => ({
value: option.value,
label: option.label,
}));
const required = field.required === true;
try {
switch (field.type) {
case "text": {
const value = required ? fields.getText(field.id, true) : fields.getText(field.id);
return value ? [value] : [];
}
case "select":
case "checkbox":
case "radio": {
const values = required
? fields.getStringSelect(field.id, true)
: (fields.getStringSelect(field.id) ?? []);
return mapOptionLabels(optionLabels, values);
}
case "role-select": {
try {
const roles = required
? fields.getRoleSelect(field.id, true)
: (fields.getRoleSelect(field.id) ?? []);
return roles.map((role) => role.name ?? role.id);
} catch {
const values = required
? fields.getStringSelect(field.id, true)
: (fields.getStringSelect(field.id) ?? []);
return values;
}
}
case "user-select": {
const users = required
? fields.getUserSelect(field.id, true)
: (fields.getUserSelect(field.id) ?? []);
return users.map((user) => formatDiscordUserTag(user));
}
default:
return [];
}
} catch (err) {
logError(`agent modal: failed to read field ${field.id}: ${String(err)}`);
return [];
}
}
function formatModalSubmissionText(
entry: DiscordModalEntry,
interaction: ModalInteraction,
): string {
const lines: string[] = [`Form "${entry.title}" submitted.`];
for (const field of entry.fields) {
const values = resolveModalFieldValues(field, interaction);
if (values.length === 0) {
continue;
}
lines.push(`- ${field.label}: ${values.join(", ")}`);
}
if (lines.length === 1) {
lines.push("- (no values)");
}
return lines.join("\n");
}
function resolveDiscordInteractionId(interaction: AgentComponentInteraction): string {
const rawId =
interaction.rawData && typeof interaction.rawData === "object" && "id" in interaction.rawData
? (interaction.rawData as { id?: unknown }).id
: undefined;
if (typeof rawId === "string" && rawId.trim()) {
return rawId.trim();
}
if (typeof rawId === "number" && Number.isFinite(rawId)) {
return String(rawId);
}
return `discord-interaction:${Date.now()}`;
}
async function dispatchPluginDiscordInteractiveEvent(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
@@ -931,54 +239,6 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
return "unmatched";
}
function resolveComponentCommandAuthorized(params: {
ctx: AgentComponentContext;
interactionCtx: ComponentInteractionContext;
channelConfig: ReturnType<typeof resolveDiscordChannelConfigWithFallback>;
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
allowNameMatching: boolean;
}): boolean {
const { ctx, interactionCtx, channelConfig, guildInfo } = params;
if (interactionCtx.isDirectMessage) {
return true;
}
const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({
allowFrom: ctx.allowFrom,
sender: {
id: interactionCtx.user.id,
name: interactionCtx.user.username,
tag: formatDiscordUserTag(interactionCtx.user),
},
allowNameMatching: params.allowNameMatching,
});
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
channelConfig,
guildInfo,
memberRoleIds: interactionCtx.memberRoleIds,
sender: {
id: interactionCtx.user.id,
name: interactionCtx.user.username,
tag: formatDiscordUserTag(interactionCtx.user),
},
allowNameMatching: params.allowNameMatching,
});
const useAccessGroups = ctx.cfg.commands?.useAccessGroups !== false;
const authorizers = useAccessGroups
? [
{ configured: ownerAllowList != null, allowed: ownerOk },
{ configured: hasAccessRestrictions, allowed: memberAllowed },
]
: [{ configured: hasAccessRestrictions, allowed: memberAllowed }];
return resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers,
modeWhenAccessGroupsOff: "configured",
});
}
async function dispatchDiscordComponentEvent(params: {
ctx: AgentComponentContext;
interaction: AgentComponentInteraction;
@@ -1045,7 +305,7 @@ async function dispatchDiscordComponentEvent(params: {
? resolvePinnedMainDmOwnerFromAllowlist({
dmScope: ctx.cfg.session?.dmScope,
allowFrom: channelConfig?.users ?? guildInfo?.users,
normalizeEntry: (entry) => {
normalizeEntry: (entry: string) => {
const normalized = normalizeDiscordAllowList([entry], ["discord:", "user:", "pk:"]);
const candidate = normalized?.ids.values().next().value;
return typeof candidate === "string" && /^\d+$/.test(candidate) ? candidate : undefined;

View File

@@ -7,11 +7,11 @@ import type {
import type { Client } from "@buape/carbon";
import { ChannelType } from "discord-api-types/v10";
import type { GatewayPresenceUpdate } from "discord-api-types/v10";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { buildPluginBindingApprovalCustomId } from "openclaw/plugin-sdk/conversation-runtime";
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import type { DiscordAccountConfig } from "../../../../src/config/types.discord.js";
import { buildPluginBindingApprovalCustomId } from "../../../../src/plugins/conversation-binding.js";
import { buildAgentSessionKey } from "../../../../src/routing/resolve-route.js";
import {
clearDiscordComponentEntries,
registerDiscordComponentEntries,
@@ -50,7 +50,6 @@ const readAllowFromStoreMock = vi.hoisted(() => vi.fn());
const upsertPairingRequestMock = vi.hoisted(() => vi.fn());
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
const dispatchReplyMock = vi.hoisted(() => vi.fn());
const deliverDiscordReplyMock = vi.hoisted(() => vi.fn());
const recordInboundSessionMock = vi.hoisted(() => vi.fn());
const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn());
const resolveStorePathMock = vi.hoisted(() => vi.fn());
@@ -59,37 +58,20 @@ const resolvePluginConversationBindingApprovalMock = vi.hoisted(() => vi.fn());
const buildPluginBindingResolvedTextMock = vi.hoisted(() => vi.fn());
let lastDispatchCtx: Record<string, unknown> | undefined;
vi.mock("../../../../src/pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
}));
vi.mock("../../../../src/infra/system-events.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/infra/system-events.js")>();
vi.mock("../../../../src/security/dm-policy-shared.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../../src/security/dm-policy-shared.js")>();
return {
...actual,
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
readStoreAllowFromForDmPolicy: (...args: unknown[]) => readAllowFromStoreMock(...args),
};
});
vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", () => ({
dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args),
}));
vi.mock("./reply-delivery.js", () => ({
deliverDiscordReply: (...args: unknown[]) => deliverDiscordReplyMock(...args),
}));
vi.mock("../../../../src/channels/session.js", () => ({
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
}));
vi.mock("../../../../src/config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/config/sessions.js")>();
vi.mock("../../../../src/pairing/pairing-store.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/pairing/pairing-store.js")>();
return {
...actual,
readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args),
resolveStorePath: (...args: unknown[]) => resolveStorePathMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
};
});
@@ -105,6 +87,42 @@ vi.mock("../../../../src/plugins/conversation-binding.js", async (importOriginal
};
});
vi.mock("../../../../src/infra/system-events.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/infra/system-events.js")>();
return {
...actual,
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
};
});
vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("../../../../src/auto-reply/reply/provider-dispatcher.js")
>();
return {
...actual,
dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args),
};
});
vi.mock("../../../../src/channels/session.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/channels/session.js")>();
return {
...actual,
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
};
});
vi.mock("../../../../src/config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/config/sessions.js")>();
return {
...actual,
readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args),
resolveStorePath: (...args: unknown[]) => resolveStorePathMock(...args),
};
});
vi.mock("../../../../src/plugins/interactive.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/plugins/interactive.js")>();
return {
@@ -287,12 +305,18 @@ describe("discord component interactions", () => {
const createComponentButtonInteraction = (overrides: Partial<ButtonInteraction> = {}) => {
const reply = vi.fn().mockResolvedValue(undefined);
const defer = vi.fn().mockResolvedValue(undefined);
const rest = {
get: vi.fn().mockResolvedValue({ type: ChannelType.DM }),
post: vi.fn().mockResolvedValue({}),
patch: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue(undefined),
};
const interaction = {
rawData: { channel_id: "dm-channel", id: "interaction-1" },
user: { id: "123456789", username: "AgentUser", discriminator: "0001" },
customId: "occomp:cid=btn_1",
message: { id: "msg-1" },
client: { rest: {} },
client: { rest },
defer,
reply,
...overrides,
@@ -303,6 +327,12 @@ describe("discord component interactions", () => {
const createModalInteraction = (overrides: Partial<ModalInteraction> = {}) => {
const reply = vi.fn().mockResolvedValue(undefined);
const acknowledge = vi.fn().mockResolvedValue(undefined);
const rest = {
get: vi.fn().mockResolvedValue({ type: ChannelType.DM }),
post: vi.fn().mockResolvedValue({}),
patch: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue(undefined),
};
const fields = {
getText: (key: string) => (key === "fld_1" ? "Casey" : undefined),
getStringSelect: (_key: string) => undefined,
@@ -316,7 +346,7 @@ describe("discord component interactions", () => {
fields,
acknowledge,
reply,
client: { rest: {} },
client: { rest },
...overrides,
} as unknown as ModalInteraction;
return { interaction, acknowledge, reply };
@@ -363,7 +393,6 @@ describe("discord component interactions", () => {
lastDispatchCtx = params.ctx;
await params.dispatcherOptions.deliver({ text: "ok" });
});
deliverDiscordReplyMock.mockClear();
recordInboundSessionMock.mockClear().mockResolvedValue(undefined);
readSessionUpdatedAtMock.mockClear().mockReturnValue(undefined);
resolveStorePathMock.mockClear().mockReturnValue("/tmp/openclaw-sessions-test.json");
@@ -415,8 +444,6 @@ describe("discord component interactions", () => {
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(lastDispatchCtx?.BodyForAgent).toBe('Clicked "Approve".');
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1);
expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-1");
expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull();
});
@@ -482,8 +509,6 @@ describe("discord component interactions", () => {
expect(lastDispatchCtx?.BodyForAgent).toContain('Form "Details" submitted.');
expect(lastDispatchCtx?.BodyForAgent).toContain("- Name: Casey");
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1);
expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-2");
expect(resolveDiscordModalEntry({ id: "mdl_1" })).toBeNull();
});

View File

@@ -1,12 +1,12 @@
import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime";
import {
createAccountScopedAllowFromSection,
createAccountScopedGroupAccessSection,
createLegacyCompatChannelDmPolicy,
DEFAULT_ACCOUNT_ID,
createEnvPatchedAccountSetupAdapter,
noteChannelLookupFailure,
noteChannelLookupSummary,
parseMentionOrPrefixedId,
patchChannelConfigForAccount,
setLegacyChannelDmPolicyWithAllowFrom,
setSetupChannelEnabled,
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
@@ -88,21 +88,11 @@ export function createDiscordSetupWizardBase(handlers: {
NonNullable<NonNullable<ChannelSetupWizard["groupAccess"]>["resolveAllowlist"]>
>;
}) {
const discordDmPolicy: ChannelSetupDmPolicy = {
const discordDmPolicy: ChannelSetupDmPolicy = createLegacyCompatChannelDmPolicy({
label: "Discord",
channel,
policyKey: "channels.discord.dmPolicy",
allowFromKey: "channels.discord.allowFrom",
getCurrent: (cfg: OpenClawConfig) =>
cfg.channels?.discord?.dmPolicy ?? cfg.channels?.discord?.dm?.policy ?? "pairing",
setPolicy: (cfg: OpenClawConfig, policy) =>
setLegacyChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy: policy,
}),
promptAllowFrom: handlers.promptAllowFrom,
};
});
return {
channel,
@@ -145,7 +135,8 @@ export function createDiscordSetupWizardBase(handlers: {
},
},
],
groupAccess: {
groupAccess: createAccountScopedGroupAccessSection({
channel,
label: "Discord channels",
placeholder: "My Server/#general, guildId/channelId, #support",
currentPolicy: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) =>
@@ -164,57 +155,8 @@ export function createDiscordSetupWizardBase(handlers: {
),
updatePrompt: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) =>
Boolean(resolveDiscordAccount({ cfg, accountId }).config.guilds),
setPolicy: ({
cfg,
accountId,
policy,
}: {
cfg: OpenClawConfig;
accountId: string;
policy: "open" | "allowlist" | "disabled";
}) =>
patchChannelConfigForAccount({
cfg,
channel,
accountId,
patch: { groupPolicy: policy },
}),
resolveAllowlist: async ({
cfg,
accountId,
credentialValues,
entries,
prompter,
}: {
cfg: OpenClawConfig;
accountId: string;
credentialValues: { token?: string };
entries: string[];
prompter: { note: (message: string, title?: string) => Promise<void> };
}) => {
try {
return await handlers.resolveGroupAllowlist({
cfg,
accountId,
credentialValues,
entries,
prompter,
});
} catch (error) {
await noteChannelLookupFailure({
prompter,
label: "Discord channels",
error,
});
await noteChannelLookupSummary({
prompter,
label: "Discord channels",
resolvedSections: [],
unresolved: entries,
});
return entries.map((input) => ({ input, resolved: false }));
}
},
resolveAllowlist: handlers.resolveGroupAllowlist,
fallbackResolved: (entries) => entries.map((input) => ({ input, resolved: false })),
applyAllowlist: ({
cfg,
accountId,
@@ -224,8 +166,9 @@ export function createDiscordSetupWizardBase(handlers: {
accountId: string;
resolved: unknown;
}) => setDiscordGuildChannelAllowlist(cfg, accountId, resolved as never),
},
allowFrom: {
}),
allowFrom: createAccountScopedAllowFromSection({
channel,
credentialInputKey: "token",
helpTitle: "Discord allowlist",
helpLines: [
@@ -242,33 +185,8 @@ export function createDiscordSetupWizardBase(handlers: {
invalidWithoutCredentialNote:
"Bot token missing; use numeric user ids (or mention form) only.",
parseId: parseDiscordAllowFromId,
resolveEntries: async ({
cfg,
accountId,
credentialValues,
entries,
}: {
cfg: OpenClawConfig;
accountId: string;
credentialValues: { token?: string };
entries: string[];
}) => await handlers.resolveAllowFromEntries({ cfg, accountId, credentialValues, entries }),
apply: async ({
cfg,
accountId,
allowFrom,
}: {
cfg: OpenClawConfig;
accountId: string;
allowFrom: string[];
}) =>
patchChannelConfigForAccount({
cfg,
channel,
accountId,
patch: { dmPolicy: "allowlist", allowFrom },
}),
},
resolveEntries: handlers.resolveAllowFromEntries,
}),
dmPolicy: discordDmPolicy,
disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false),
} satisfies ChannelSetupWizard;

View File

@@ -1,17 +1,13 @@
import {
resolveEntriesWithOptionalToken,
type OpenClawConfig,
promptLegacyChannelAllowFrom,
resolveSetupAccountId,
promptLegacyChannelAllowFromForAccount,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup";
import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import { resolveDefaultDiscordAccountId, resolveDiscordAccount } from "./accounts.js";
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
import {
resolveDiscordChannelAllowlist,
type DiscordChannelResolution,
} from "./resolve-channels.js";
import { resolveDiscordChannelAllowlist } from "./resolve-channels.js";
import { resolveDiscordUserAllowlist } from "./resolve-users.js";
import {
createDiscordSetupWizardBase,
@@ -23,22 +19,26 @@ import {
const channel = "discord" as const;
async function resolveDiscordAllowFromEntries(params: { token?: string; entries: string[] }) {
if (!params.token?.trim()) {
return params.entries.map((input) => ({
return await resolveEntriesWithOptionalToken({
token: params.token,
entries: params.entries,
buildWithoutToken: (input) => ({
input,
resolved: false,
id: null,
}));
}
const resolved = await resolveDiscordUserAllowlist({
token: params.token,
entries: params.entries,
}),
resolveEntries: async ({ token, entries }) =>
(
await resolveDiscordUserAllowlist({
token,
entries,
})
).map((entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.id ?? null,
})),
});
return resolved.map((entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.id ?? null,
}));
}
async function promptDiscordAllowFrom(params: {
@@ -46,17 +46,15 @@ async function promptDiscordAllowFrom(params: {
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
const accountId = resolveSetupAccountId({
accountId: params.accountId,
defaultAccountId: resolveDefaultDiscordAccountId(params.cfg),
});
const resolved = resolveDiscordAccount({ cfg: params.cfg, accountId });
return promptLegacyChannelAllowFrom({
return await promptLegacyChannelAllowFromForAccount({
cfg: params.cfg,
channel,
prompter: params.prompter,
existing: resolved.config.allowFrom ?? resolved.config.dm?.allowFrom ?? [],
token: resolved.token,
accountId: params.accountId,
defaultAccountId: resolveDefaultDiscordAccountId(params.cfg),
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
resolveExisting: (account) => account.config.allowFrom ?? account.config.dm?.allowFrom ?? [],
resolveToken: (account) => account.token,
noteTitle: "Discord allowlist",
noteLines: [
"Allowlist Discord DMs by username (we resolve to user ids).",
@@ -71,11 +69,17 @@ async function promptDiscordAllowFrom(params: {
placeholder: "@alice, 123456789012345678",
parseId: parseDiscordAllowFromId,
invalidWithoutTokenNote: "Bot token missing; use numeric user ids (or mention form) only.",
resolveEntries: ({ token, entries }) =>
resolveDiscordUserAllowlist({
token,
entries,
}),
resolveEntries: async ({ token, entries }) =>
(
await resolveDiscordUserAllowlist({
token,
entries,
})
).map((entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.id ?? null,
})),
});
}
@@ -85,18 +89,20 @@ async function resolveDiscordGroupAllowlist(params: {
credentialValues: { token?: string };
entries: string[];
}) {
const token =
resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }).token ||
(typeof params.credentialValues.token === "string" ? params.credentialValues.token : "");
if (!token || params.entries.length === 0) {
return params.entries.map((input) => ({
return await resolveEntriesWithOptionalToken({
token:
resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }).token ||
(typeof params.credentialValues.token === "string" ? params.credentialValues.token : ""),
entries: params.entries,
buildWithoutToken: (input) => ({
input,
resolved: false,
}));
}
return await resolveDiscordChannelAllowlist({
token,
entries: params.entries,
}),
resolveEntries: async ({ token, entries }) =>
await resolveDiscordChannelAllowlist({
token,
entries,
}),
});
}

View File

@@ -0,0 +1,121 @@
import { createFeishuClient } from "./client.js";
import type { ResolvedFeishuAccount } from "./types.js";
export type FeishuPermissionError = {
code: number;
message: string;
grantUrl?: string;
};
type SenderNameResult = {
name?: string;
permissionError?: FeishuPermissionError;
};
const IGNORED_PERMISSION_SCOPE_TOKENS = ["contact:contact.base:readonly"];
const FEISHU_SCOPE_CORRECTIONS: Record<string, string> = {
"contact:contact.base:readonly": "contact:user.base:readonly",
};
const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
const senderNameCache = new Map<string, { name: string; expireAt: number }>();
function correctFeishuScopeInUrl(url: string): string {
let corrected = url;
for (const [wrong, right] of Object.entries(FEISHU_SCOPE_CORRECTIONS)) {
corrected = corrected.replaceAll(encodeURIComponent(wrong), encodeURIComponent(right));
corrected = corrected.replaceAll(wrong, right);
}
return corrected;
}
function shouldSuppressPermissionErrorNotice(permissionError: FeishuPermissionError): boolean {
const message = permissionError.message.toLowerCase();
return IGNORED_PERMISSION_SCOPE_TOKENS.some((token) => message.includes(token));
}
function extractPermissionError(err: unknown): FeishuPermissionError | null {
if (!err || typeof err !== "object") {
return null;
}
const axiosErr = err as { response?: { data?: unknown } };
const data = axiosErr.response?.data;
if (!data || typeof data !== "object") {
return null;
}
const feishuErr = data as { code?: number; msg?: string };
if (feishuErr.code !== 99991672) {
return null;
}
const msg = feishuErr.msg ?? "";
const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);
return {
code: feishuErr.code,
message: msg,
grantUrl: urlMatch?.[0] ? correctFeishuScopeInUrl(urlMatch[0]) : undefined,
};
}
function resolveSenderLookupIdType(senderId: string): "open_id" | "user_id" | "union_id" {
const trimmed = senderId.trim();
if (trimmed.startsWith("ou_")) {
return "open_id";
}
if (trimmed.startsWith("on_")) {
return "union_id";
}
return "user_id";
}
export async function resolveFeishuSenderName(params: {
account: ResolvedFeishuAccount;
senderId: string;
log: (...args: any[]) => void;
}): Promise<SenderNameResult> {
const { account, senderId, log } = params;
if (!account.configured) {
return {};
}
const normalizedSenderId = senderId.trim();
if (!normalizedSenderId) {
return {};
}
const cached = senderNameCache.get(normalizedSenderId);
const now = Date.now();
if (cached && cached.expireAt > now) {
return { name: cached.name };
}
try {
const client = createFeishuClient(account);
const userIdType = resolveSenderLookupIdType(normalizedSenderId);
const res: any = await client.contact.user.get({
path: { user_id: normalizedSenderId },
params: { user_id_type: userIdType },
});
const name: string | undefined =
res?.data?.user?.name ||
res?.data?.user?.display_name ||
res?.data?.user?.nickname ||
res?.data?.user?.en_name;
if (name && typeof name === "string") {
senderNameCache.set(normalizedSenderId, { name, expireAt: now + SENDER_NAME_TTL_MS });
return { name };
}
return {};
} catch (err) {
const permErr = extractPermissionError(err);
if (permErr) {
if (shouldSuppressPermissionErrorNotice(permErr)) {
log(`feishu: ignoring stale permission scope error: ${permErr.message}`);
return {};
}
log(`feishu: permission error resolving sender name: code=${permErr.code}`);
return { permissionError: permErr };
}
log(`feishu: failed to resolve sender name for ${normalizedSenderId}: ${String(err)}`);
return {};
}
}

View File

@@ -22,6 +22,7 @@ import {
warnMissingProviderGroupPolicyFallbackOnce,
} from "../runtime-api.js";
import { resolveFeishuAccount } from "./accounts.js";
import { type FeishuPermissionError, resolveFeishuSenderName } from "./bot-sender-name.js";
import { createFeishuClient } from "./client.js";
import { buildFeishuConversationId } from "./conversation-id.js";
import { finalizeFeishuMessageProcessing, tryRecordMessagePersistent } from "./dedup.js";
@@ -39,150 +40,13 @@ import { parsePostContent } from "./post.js";
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js";
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
import type { FeishuMessageContext, FeishuMediaInfo } from "./types.js";
import type { DynamicAgentCreationConfig } from "./types.js";
// --- Permission error extraction ---
// Extract permission grant URL from Feishu API error response.
type PermissionError = {
code: number;
message: string;
grantUrl?: string;
};
const IGNORED_PERMISSION_SCOPE_TOKENS = ["contact:contact.base:readonly"];
// Feishu API sometimes returns incorrect scope names in permission error
// responses (e.g. "contact:contact.base:readonly" instead of the valid
// "contact:user.base:readonly"). This map corrects known mismatches.
const FEISHU_SCOPE_CORRECTIONS: Record<string, string> = {
"contact:contact.base:readonly": "contact:user.base:readonly",
};
function correctFeishuScopeInUrl(url: string): string {
let corrected = url;
for (const [wrong, right] of Object.entries(FEISHU_SCOPE_CORRECTIONS)) {
corrected = corrected.replaceAll(encodeURIComponent(wrong), encodeURIComponent(right));
corrected = corrected.replaceAll(wrong, right);
}
return corrected;
}
function shouldSuppressPermissionErrorNotice(permissionError: PermissionError): boolean {
const message = permissionError.message.toLowerCase();
return IGNORED_PERMISSION_SCOPE_TOKENS.some((token) => message.includes(token));
}
function extractPermissionError(err: unknown): PermissionError | null {
if (!err || typeof err !== "object") return null;
// Axios error structure: err.response.data contains the Feishu error
const axiosErr = err as { response?: { data?: unknown } };
const data = axiosErr.response?.data;
if (!data || typeof data !== "object") return null;
const feishuErr = data as {
code?: number;
msg?: string;
error?: { permission_violations?: Array<{ uri?: string }> };
};
// Feishu permission error code: 99991672
if (feishuErr.code !== 99991672) return null;
// Extract the grant URL from the error message (contains the direct link)
const msg = feishuErr.msg ?? "";
const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);
const grantUrl = urlMatch?.[0] ? correctFeishuScopeInUrl(urlMatch[0]) : undefined;
return {
code: feishuErr.code,
message: msg,
grantUrl,
};
}
// --- Sender name resolution (so the agent can distinguish who is speaking in group chats) ---
// Cache display names by sender id (open_id/user_id) to avoid an API call on every message.
const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
const senderNameCache = new Map<string, { name: string; expireAt: number }>();
// Cache permission errors to avoid spamming the user with repeated notifications.
// Key: appId or "default", Value: timestamp of last notification
const permissionErrorNotifiedAt = new Map<string, number>();
const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
type SenderNameResult = {
name?: string;
permissionError?: PermissionError;
};
function resolveSenderLookupIdType(senderId: string): "open_id" | "user_id" | "union_id" {
const trimmed = senderId.trim();
if (trimmed.startsWith("ou_")) {
return "open_id";
}
if (trimmed.startsWith("on_")) {
return "union_id";
}
return "user_id";
}
async function resolveFeishuSenderName(params: {
account: ResolvedFeishuAccount;
senderId: string;
log: (...args: any[]) => void;
}): Promise<SenderNameResult> {
const { account, senderId, log } = params;
if (!account.configured) return {};
const normalizedSenderId = senderId.trim();
if (!normalizedSenderId) return {};
const cached = senderNameCache.get(normalizedSenderId);
const now = Date.now();
if (cached && cached.expireAt > now) return { name: cached.name };
try {
const client = createFeishuClient(account);
const userIdType = resolveSenderLookupIdType(normalizedSenderId);
// contact/v3/users/:user_id?user_id_type=<open_id|user_id|union_id>
const res: any = await client.contact.user.get({
path: { user_id: normalizedSenderId },
params: { user_id_type: userIdType },
});
const name: string | undefined =
res?.data?.user?.name ||
res?.data?.user?.display_name ||
res?.data?.user?.nickname ||
res?.data?.user?.en_name;
if (name && typeof name === "string") {
senderNameCache.set(normalizedSenderId, { name, expireAt: now + SENDER_NAME_TTL_MS });
return { name };
}
return {};
} catch (err) {
// Check if this is a permission error
const permErr = extractPermissionError(err);
if (permErr) {
if (shouldSuppressPermissionErrorNotice(permErr)) {
log(`feishu: ignoring stale permission scope error: ${permErr.message}`);
return {};
}
log(`feishu: permission error resolving sender name: code=${permErr.code}`);
return { permissionError: permErr };
}
// Best-effort. Don't fail message handling if name lookup fails.
log(`feishu: failed to resolve sender name for ${normalizedSenderId}: ${String(err)}`);
return {};
}
}
export type FeishuMessageEvent = {
sender: {
sender_id: {
@@ -848,7 +712,7 @@ export function buildFeishuAgentBody(params: {
"content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId" | "hasAnyMention"
>;
quotedContent?: string;
permissionErrorForAgent?: PermissionError;
permissionErrorForAgent?: FeishuPermissionError;
botOpenId?: string;
}): string {
const { ctx, quotedContent, permissionErrorForAgent, botOpenId } = params;
@@ -967,7 +831,7 @@ export async function handleFeishuMessage(params: {
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
// Optimization: skip if disabled to save API quota (Feishu free tier limit).
let permissionErrorForAgent: PermissionError | undefined;
let permissionErrorForAgent: FeishuPermissionError | undefined;
if (feishuCfg?.resolveSenderNames ?? true) {
const senderResult = await resolveFeishuSenderName({
account,

View File

@@ -1,5 +1,8 @@
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
import {
createHybridChannelConfigBase,
createScopedAccountConfigAccessors,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime";
import type {
@@ -126,6 +129,21 @@ function setFeishuNamedAccountEnabled(
};
}
const feishuConfigBase = createHybridChannelConfigBase<ResolvedFeishuAccount, ClawdbotConfig>({
sectionKey: "feishu",
listAccountIds: listFeishuAccountIds,
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultFeishuAccountId,
clearBaseFields: [],
});
const feishuConfigAccessors = createScopedAccountConfigAccessors<ResolvedFeishuAccount>({
resolveAccount: ({ cfg, accountId }) =>
resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }),
resolveAllowFrom: (account) => account.config.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
});
function isFeishuReactionsActionEnabled(params: {
cfg: ClawdbotConfig;
account: ResolvedFeishuAccount;
@@ -377,15 +395,10 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
reload: { configPrefixes: ["channels.feishu"] },
configSchema: buildChannelConfigSchema(FeishuConfigSchema),
config: {
listAccountIds: (cfg) => listFeishuAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
...feishuConfigBase,
setAccountEnabled: ({ cfg, accountId, enabled }) => {
const account = resolveFeishuAccount({ cfg, accountId });
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
if (isDefault) {
// For default account, set top-level enabled
return {
...cfg,
channels: {
@@ -397,8 +410,6 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
},
};
}
// For named accounts, set enabled in accounts[accountId]
return setFeishuNamedAccountEnabled(cfg, accountId, enabled);
},
deleteAccount: ({ cfg, accountId }) => {
@@ -442,11 +453,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
appId: account.appId,
domain: account.domain,
}),
resolveAllowFrom: ({ cfg, accountId }) => {
const account = resolveFeishuAccount({ cfg, accountId });
return mapAllowFromEntries(account.config?.allowFrom);
},
formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }),
...feishuConfigAccessors,
},
actions: {
describeMessageTool: describeFeishuMessageTool,

View File

@@ -1,17 +1,17 @@
import {
buildSingleChannelSecretPromptState,
createTopLevelChannelAllowFromSetter,
createTopLevelChannelDmPolicy,
createTopLevelChannelGroupPolicySetter,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
hasConfiguredSecretInput,
mergeAllowFromEntries,
patchTopLevelChannelConfigSection,
promptSingleChannelSecretInput,
setTopLevelChannelAllowFrom,
setTopLevelChannelDmPolicyWithAllowFrom,
setTopLevelChannelGroupPolicy,
splitSetupEntries,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
type DmPolicy,
type OpenClawConfig,
type SecretInput,
} from "openclaw/plugin-sdk/setup";
@@ -21,6 +21,13 @@ import { feishuSetupAdapter } from "./setup-core.js";
import type { FeishuConfig } from "./types.js";
const channel = "feishu" as const;
const setFeishuAllowFrom = createTopLevelChannelAllowFromSetter({
channel,
});
const setFeishuGroupPolicy = createTopLevelChannelGroupPolicySetter({
channel,
enabled: true,
});
function normalizeString(value: unknown): string | undefined {
if (typeof value !== "string") {
@@ -30,34 +37,6 @@ function normalizeString(value: unknown): string | undefined {
return trimmed || undefined;
}
function setFeishuDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig {
return setTopLevelChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy,
}) as OpenClawConfig;
}
function setFeishuAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig {
return setTopLevelChannelAllowFrom({
cfg,
channel,
allowFrom,
}) as OpenClawConfig;
}
function setFeishuGroupPolicy(
cfg: OpenClawConfig,
groupPolicy: "open" | "allowlist" | "disabled",
): OpenClawConfig {
return setTopLevelChannelGroupPolicy({
cfg,
channel,
groupPolicy,
enabled: true,
}) as OpenClawConfig;
}
function setFeishuGroupAllowFrom(cfg: OpenClawConfig, groupAllowFrom: string[]): OpenClawConfig {
return {
...cfg,
@@ -177,15 +156,14 @@ async function promptFeishuAppId(params: {
).trim();
}
const feishuDmPolicy: ChannelSetupDmPolicy = {
const feishuDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({
label: "Feishu",
channel,
policyKey: "channels.feishu.dmPolicy",
allowFromKey: "channels.feishu.allowFrom",
getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg as OpenClawConfig, policy),
promptAllowFrom: promptFeishuAllowFrom,
};
});
export { feishuSetupAdapter } from "./setup-core.js";
@@ -263,13 +241,12 @@ export const feishuSetupWizard: ChannelSetupWizard = {
});
if (appSecretResult.action === "use-env") {
next = {
...next,
channels: {
...next.channels,
feishu: { ...next.channels?.feishu, enabled: true },
},
};
next = patchTopLevelChannelConfigSection({
cfg: next,
channel,
enabled: true,
patch: {},
}) as OpenClawConfig;
} else if (appSecretResult.action === "set") {
appSecret = appSecretResult.value;
appSecretProbeValue = appSecretResult.resolvedValue;
@@ -281,18 +258,15 @@ export const feishuSetupWizard: ChannelSetupWizard = {
}
if (appId && appSecret) {
next = {
...next,
channels: {
...next.channels,
feishu: {
...next.channels?.feishu,
enabled: true,
appId,
appSecret,
},
next = patchTopLevelChannelConfigSection({
cfg: next,
channel,
enabled: true,
patch: {
appId,
appSecret,
},
};
}) as OpenClawConfig;
try {
const probe = await probeFeishu({
@@ -326,16 +300,11 @@ export const feishuSetupWizard: ChannelSetupWizard = {
],
initialValue: currentMode,
})) as "websocket" | "webhook";
next = {
...next,
channels: {
...next.channels,
feishu: {
...next.channels?.feishu,
connectionMode,
},
},
};
next = patchTopLevelChannelConfigSection({
cfg: next,
channel,
patch: { connectionMode },
}) as OpenClawConfig;
if (connectionMode === "webhook") {
const currentVerificationToken = (next.channels?.feishu as FeishuConfig | undefined)
@@ -357,16 +326,11 @@ export const feishuSetupWizard: ChannelSetupWizard = {
preferredEnvVar: "FEISHU_VERIFICATION_TOKEN",
});
if (verificationTokenResult.action === "set") {
next = {
...next,
channels: {
...next.channels,
feishu: {
...next.channels?.feishu,
verificationToken: verificationTokenResult.value,
},
},
};
next = patchTopLevelChannelConfigSection({
cfg: next,
channel,
patch: { verificationToken: verificationTokenResult.value },
}) as OpenClawConfig;
}
const currentEncryptKey = (next.channels?.feishu as FeishuConfig | undefined)?.encryptKey;
@@ -387,16 +351,11 @@ export const feishuSetupWizard: ChannelSetupWizard = {
preferredEnvVar: "FEISHU_ENCRYPT_KEY",
});
if (encryptKeyResult.action === "set") {
next = {
...next,
channels: {
...next.channels,
feishu: {
...next.channels?.feishu,
encryptKey: encryptKeyResult.value,
},
},
};
next = patchTopLevelChannelConfigSection({
cfg: next,
channel,
patch: { encryptKey: encryptKeyResult.value },
}) as OpenClawConfig;
}
const currentWebhookPath = (next.channels?.feishu as FeishuConfig | undefined)?.webhookPath;
@@ -407,16 +366,11 @@ export const feishuSetupWizard: ChannelSetupWizard = {
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
next = {
...next,
channels: {
...next.channels,
feishu: {
...next.channels?.feishu,
webhookPath,
},
},
};
next = patchTopLevelChannelConfigSection({
cfg: next,
channel,
patch: { webhookPath },
}) as OpenClawConfig;
}
const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu";
@@ -428,16 +382,11 @@ export const feishuSetupWizard: ChannelSetupWizard = {
],
initialValue: currentDomain,
});
next = {
...next,
channels: {
...next.channels,
feishu: {
...next.channels?.feishu,
domain: domain as "feishu" | "lark",
},
},
};
next = patchTopLevelChannelConfigSection({
cfg: next,
channel,
patch: { domain: domain as "feishu" | "lark" },
}) as OpenClawConfig;
const groupPolicy = (await prompter.select({
message: "Group chat policy",
@@ -468,11 +417,10 @@ export const feishuSetupWizard: ChannelSetupWizard = {
return { cfg: next };
},
dmPolicy: feishuDmPolicy,
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
feishu: { ...cfg.channels?.feishu, enabled: false },
},
}),
disable: (cfg) =>
patchTopLevelChannelConfigSection({
cfg,
channel,
patch: { enabled: false },
}),
};

View File

@@ -19,7 +19,6 @@ import {
missingTargetError,
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
resolveGoogleChatGroupRequireMention,
runPassiveAccountLifecycle,
type ChannelMessageActionAdapter,
type ChannelPlugin,
@@ -36,6 +35,7 @@ import {
type ResolvedGoogleChatAccount,
} from "./accounts.js";
import { googlechatMessageActions } from "./actions.js";
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
import { getGoogleChatRuntime } from "./runtime.js";
import { googlechatSetupAdapter } from "./setup-core.js";
import { googlechatSetupWizard } from "./setup-surface.js";

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
describe("googlechat group policy", () => {
it("uses generic channel group policy helpers", () => {
const cfg = {
channels: {
googlechat: {
groups: {
"spaces/AAA": {
requireMention: false,
},
"*": {
requireMention: true,
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/AAA" })).toBe(false);
expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/BBB" })).toBe(true);
});
});

View File

@@ -0,0 +1,17 @@
import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy";
import type { OpenClawConfig } from "openclaw/plugin-sdk/googlechat";
type GoogleChatGroupContext = {
cfg: OpenClawConfig;
accountId?: string | null;
groupId?: string | null;
};
export function resolveGoogleChatGroupRequireMention(params: GoogleChatGroupContext): boolean {
return resolveChannelGroupRequireMention({
cfg: params.cfg,
channel: "googlechat",
groupId: params.groupId,
accountId: params.accountId,
});
}

View File

@@ -1,15 +1,14 @@
import {
addWildcardAllowFrom,
applySetupAccountConfigPatch,
createNestedChannelDmPolicy,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
mergeAllowFromEntries,
migrateBaseNameToDefaultAccount,
setTopLevelChannelDmPolicyWithAllowFrom,
patchNestedChannelConfigSection,
splitSetupEntries,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
type DmPolicy,
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
import {
@@ -25,25 +24,6 @@ const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
const USE_ENV_FLAG = "__googlechatUseEnv";
const AUTH_METHOD_FLAG = "__googlechatAuthMethod";
function setGoogleChatDmPolicy(cfg: OpenClawConfig, policy: DmPolicy) {
const allowFrom =
policy === "open" ? addWildcardAllowFrom(cfg.channels?.googlechat?.dm?.allowFrom) : undefined;
return {
...cfg,
channels: {
...cfg.channels,
googlechat: {
...cfg.channels?.googlechat,
dm: {
...cfg.channels?.googlechat?.dm,
policy,
...(allowFrom ? { allowFrom } : {}),
},
},
},
};
}
async function promptAllowFrom(params: {
cfg: OpenClawConfig;
prompter: Parameters<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0]["prompter"];
@@ -57,32 +37,28 @@ async function promptAllowFrom(params: {
});
const parts = splitSetupEntries(String(entry));
const unique = mergeAllowFromEntries(undefined, parts);
return {
...params.cfg,
channels: {
...params.cfg.channels,
googlechat: {
...params.cfg.channels?.googlechat,
enabled: true,
dm: {
...params.cfg.channels?.googlechat?.dm,
policy: "allowlist",
allowFrom: unique,
},
},
return patchNestedChannelConfigSection({
cfg: params.cfg,
channel,
section: "dm",
enabled: true,
patch: {
policy: "allowlist",
allowFrom: unique,
},
};
});
}
const googlechatDmPolicy: ChannelSetupDmPolicy = {
const googlechatDmPolicy: ChannelSetupDmPolicy = createNestedChannelDmPolicy({
label: "Google Chat",
channel,
section: "dm",
policyKey: "channels.googlechat.dm.policy",
allowFromKey: "channels.googlechat.dm.allowFrom",
getCurrent: (cfg) => cfg.channels?.googlechat?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setGoogleChatDmPolicy(cfg, policy),
promptAllowFrom,
};
enabled: true,
});
export { googlechatSetupAdapter } from "./setup-core.js";

View File

@@ -1,6 +1,6 @@
import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit";
import {
buildAccountScopedDmSecurityPolicy,
createScopedDmSecurityResolver,
collectAllowlistProviderRestrictSendersWarnings,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
@@ -11,14 +11,16 @@ import {
formatTrimmedAllowFromEntries,
looksLikeIMessageTargetId,
normalizeIMessageMessagingTarget,
resolveIMessageGroupRequireMention,
resolveIMessageGroupToolPolicy,
type ChannelPlugin,
} from "openclaw/plugin-sdk/imessage";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { type RoutePeer } from "openclaw/plugin-sdk/routing";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js";
import {
resolveIMessageGroupRequireMention,
resolveIMessageGroupToolPolicy,
} from "./group-policy.js";
import { getIMessageRuntime } from "./runtime.js";
import { imessageSetupAdapter } from "./setup-core.js";
import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js";
@@ -26,6 +28,13 @@ import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js";
const loadIMessageChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
const resolveIMessageDmPolicy = createScopedDmSecurityResolver<ResolvedIMessageAccount>({
channelKey: "imessage",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
});
function buildIMessageBaseSessionKey(params: {
cfg: Parameters<typeof resolveIMessageAccount>[0]["cfg"];
agentId: string;
@@ -127,17 +136,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
}),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
return buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: "imessage",
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.config.dmPolicy,
allowFrom: account.config.allowFrom ?? [],
policyPathSuffix: "dmPolicy",
});
},
resolveDmPolicy: resolveIMessageDmPolicy,
collectWarnings: ({ account, cfg }) => {
return collectAllowlistProviderRestrictSendersWarnings({
cfg,

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import {
resolveIMessageGroupRequireMention,
resolveIMessageGroupToolPolicy,
} from "./group-policy.js";
describe("imessage group policy", () => {
it("uses generic channel group policy helpers", () => {
const cfg = {
channels: {
imessage: {
groups: {
"chat:family": {
requireMention: false,
tools: { deny: ["exec"] },
},
"*": {
requireMention: true,
tools: { allow: ["message.send"] },
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(resolveIMessageGroupRequireMention({ cfg, groupId: "chat:family" })).toBe(false);
expect(resolveIMessageGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true);
expect(resolveIMessageGroupToolPolicy({ cfg, groupId: "chat:family" })).toEqual({
deny: ["exec"],
});
expect(resolveIMessageGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({
allow: ["message.send"],
});
});
});

View File

@@ -0,0 +1,40 @@
import {
resolveChannelGroupRequireMention,
resolveChannelGroupToolsPolicy,
type GroupToolPolicyConfig,
} from "openclaw/plugin-sdk/channel-policy";
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
type IMessageGroupContext = {
cfg: OpenClawConfig;
accountId?: string | null;
groupId?: string | null;
senderId?: string | null;
senderName?: string | null;
senderUsername?: string | null;
senderE164?: string | null;
};
export function resolveIMessageGroupRequireMention(params: IMessageGroupContext): boolean {
return resolveChannelGroupRequireMention({
cfg: params.cfg,
channel: "imessage",
groupId: params.groupId,
accountId: params.accountId,
});
}
export function resolveIMessageGroupToolPolicy(
params: IMessageGroupContext,
): GroupToolPolicyConfig | undefined {
return resolveChannelGroupToolsPolicy({
cfg: params.cfg,
channel: "imessage",
groupId: params.groupId,
accountId: params.accountId,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
}

View File

@@ -1,7 +1,10 @@
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
import { createScopedAccountConfigAccessors } from "openclaw/plugin-sdk/channel-config-helpers";
import {
buildAccountScopedDmSecurityPolicy,
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import {
buildOpenGroupPolicyWarning,
collectAllowlistProviderGroupPolicyWarnings,
} from "openclaw/plugin-sdk/channel-policy";
@@ -11,10 +14,8 @@ import {
buildChannelConfigSchema,
createAccountStatusSink,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
getChatChannelMeta,
PAIRING_APPROVED_MESSAGE,
setAccountEnabledInConfigSection,
type ChannelPlugin,
} from "openclaw/plugin-sdk/irc";
import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js";
@@ -61,6 +62,33 @@ const ircConfigAccessors = createScopedAccountConfigAccessors({
resolveDefaultTo: (account: ResolvedIrcAccount) => account.config.defaultTo,
});
const ircConfigBase = createScopedChannelConfigBase<ResolvedIrcAccount, CoreConfig>({
sectionKey: "irc",
listAccountIds: listIrcAccountIds,
resolveAccount: (cfg, accountId) => resolveIrcAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultIrcAccountId,
clearBaseFields: [
"name",
"host",
"port",
"tls",
"nick",
"username",
"realname",
"password",
"passwordFile",
"channels",
],
});
const resolveIrcDmPolicy = createScopedDmSecurityResolver<ResolvedIrcAccount>({
channelKey: "irc",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => normalizeIrcAllowEntry(raw),
});
export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
id: "irc",
meta: {
@@ -88,35 +116,7 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
reload: { configPrefixes: ["channels.irc"] },
configSchema: buildChannelConfigSchema(IrcConfigSchema),
config: {
listAccountIds: (cfg) => listIrcAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) => resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultIrcAccountId(cfg as CoreConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as CoreConfig,
sectionKey: "irc",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as CoreConfig,
sectionKey: "irc",
accountId,
clearBaseFields: [
"name",
"host",
"port",
"tls",
"nick",
"username",
"realname",
"password",
"passwordFile",
"channels",
],
}),
...ircConfigBase,
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
@@ -132,18 +132,7 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
...ircConfigAccessors,
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
return buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: "irc",
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.config.dmPolicy,
allowFrom: account.config.allowFrom ?? [],
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => normalizeIrcAllowEntry(raw),
});
},
resolveDmPolicy: resolveIrcDmPolicy,
collectWarnings: ({ account, cfg }) => {
const warnings = collectAllowlistProviderGroupPolicyWarnings({
cfg,

View File

@@ -4,15 +4,19 @@ import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import {
applyAccountNameToChannelSection,
createTopLevelChannelAllowFromSetter,
createTopLevelChannelDmPolicySetter,
patchScopedAccountConfig,
} from "openclaw/plugin-sdk/setup";
import {
setTopLevelChannelAllowFrom,
setTopLevelChannelDmPolicyWithAllowFrom,
} from "openclaw/plugin-sdk/setup";
import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js";
const channel = "irc" as const;
const setIrcTopLevelDmPolicy = createTopLevelChannelDmPolicySetter({
channel,
});
const setIrcTopLevelAllowFrom = createTopLevelChannelAllowFromSetter({
channel,
});
type IrcSetupInput = ChannelSetupInput & {
host?: string;
@@ -53,19 +57,11 @@ export function updateIrcAccountConfig(
}
export function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
return setTopLevelChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy,
}) as CoreConfig;
return setIrcTopLevelDmPolicy(cfg, dmPolicy) as CoreConfig;
}
export function setIrcAllowFrom(cfg: CoreConfig, allowFrom: string[]): CoreConfig {
return setTopLevelChannelAllowFrom({
cfg,
channel,
allowFrom,
}) as CoreConfig;
return setIrcTopLevelAllowFrom(cfg, allowFrom) as CoreConfig;
}
export function setIrcNickServ(

View File

@@ -1,9 +1,9 @@
import {
createTopLevelChannelDmPolicy,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
resolveLineAccount,
setSetupChannelEnabled,
setTopLevelChannelDmPolicyWithAllowFrom,
splitSetupEntries,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
@@ -35,19 +35,13 @@ const LINE_ALLOW_FROM_HELP_LINES = [
`Docs: ${formatDocsLink("/channels/line", "channels/line")}`,
];
const lineDmPolicy: ChannelSetupDmPolicy = {
const lineDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({
label: "LINE",
channel,
policyKey: "channels.line.dmPolicy",
allowFromKey: "channels.line.allowFrom",
getCurrent: (cfg) => cfg.channels?.line?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) =>
setTopLevelChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy: policy,
}),
};
});
export { lineSetupAdapter } from "./setup-core.js";

View File

@@ -1,83 +1 @@
export {
createActionGate,
jsonResult,
readNumberParam,
readReactionParams,
readStringParam,
} from "../../src/agents/tools/common.js";
export type { ReplyPayload } from "../../src/auto-reply/types.js";
export {
compileAllowlist,
resolveCompiledAllowlistMatch,
} from "../../src/channels/allowlist-match.js";
export { mergeAllowlist, summarizeMapping } from "../../src/channels/allowlists/resolve-utils.js";
export { resolveControlCommandGate } from "../../src/channels/command-gating.js";
export type { NormalizedLocation } from "../../src/channels/location.js";
export { formatLocationText, toLocationContext } from "../../src/channels/location.js";
export { logInboundDrop, logTypingFailure } from "../../src/channels/logging.js";
export type { AllowlistMatch } from "../../src/channels/plugins/allowlist-match.js";
export { formatAllowlistMatchMeta } from "../../src/channels/plugins/allowlist-match.js";
export {
buildChannelKeyCandidates,
resolveChannelEntryMatch,
} from "../../src/channels/plugins/channel-config.js";
export { buildChannelConfigSchema } from "../../src/channels/plugins/config-schema.js";
export { createAccountListHelpers } from "../../src/channels/plugins/account-helpers.js";
export type {
BaseProbeResult,
ChannelDirectoryEntry,
ChannelGroupContext,
ChannelMessageActionAdapter,
ChannelMessageActionContext,
ChannelMessageActionName,
ChannelOutboundAdapter,
ChannelResolveKind,
ChannelResolveResult,
ChannelToolSend,
} from "../../src/channels/plugins/types.js";
export type { ChannelPlugin } from "../../src/channels/plugins/types.plugin.js";
export { createReplyPrefixOptions } from "../../src/channels/reply-prefix.js";
export { createTypingCallbacks } from "../../src/channels/typing.js";
export {
GROUP_POLICY_BLOCKED_LABEL,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "../../src/config/runtime-group-policy.js";
export type {
DmPolicy,
GroupPolicy,
GroupToolPolicyConfig,
MarkdownTableMode,
} from "../../src/config/types.js";
export type { SecretInput } from "../../src/config/types.secrets.js";
export {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "../../src/config/types.secrets.js";
export { ToolPolicySchema } from "../../src/config/zod-schema.agent-runtime.js";
export { MarkdownConfigSchema } from "../../src/config/zod-schema.core.js";
export { fetchWithSsrFGuard } from "../../src/infra/net/fetch-guard.js";
export { issuePairingChallenge } from "../../src/pairing/pairing-challenge.js";
export type { PluginRuntime, RuntimeLogger } from "../../src/plugins/runtime/types.js";
export { DEFAULT_ACCOUNT_ID } from "../../src/routing/session-key.js";
export type { PollInput } from "../../src/polls.js";
export {
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
} from "../../src/security/dm-policy-shared.js";
export { normalizeStringEntries } from "../../src/shared/string-normalization.js";
export {
evaluateGroupRouteAccessForPolicy,
resolveSenderScopedGroupPolicy,
} from "../../src/plugin-sdk/group-access.js";
export { createScopedPairingAccess } from "../../src/plugin-sdk/pairing-access.js";
export { runPluginCommandWithTimeout } from "../../src/plugin-sdk/run-command.js";
export { dispatchReplyFromConfigWithSettledDispatcher } from "../../src/plugin-sdk/inbound-reply-dispatch.js";
export { resolveRuntimeEnv } from "../../src/plugin-sdk/runtime.js";
export { resolveInboundSessionEnvelopeContext } from "../../src/channels/session-envelope.js";
export {
buildProbeChannelStatusSummary,
collectStatusIssuesFromLastError,
} from "../../src/plugin-sdk/status-helpers.js";
export * from "openclaw/plugin-sdk/matrix";

View File

@@ -1,16 +1,16 @@
import {
addWildcardAllowFrom,
buildSingleChannelSecretPromptState,
createNestedChannelDmPolicy,
createTopLevelChannelGroupPolicySetter,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
formatResolvedUnresolvedNote,
hasConfiguredSecretInput,
mergeAllowFromEntries,
patchNestedChannelConfigSection,
promptSingleChannelSecretInput,
setTopLevelChannelGroupPolicy,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
type DmPolicy,
type OpenClawConfig,
type SecretInput,
type WizardPrompter,
@@ -23,25 +23,10 @@ import { buildMatrixConfigUpdate, matrixSetupAdapter } from "./setup-core.js";
import type { CoreConfig } from "./types.js";
const channel = "matrix" as const;
function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) {
const allowFrom =
policy === "open" ? addWildcardAllowFrom(cfg.channels?.matrix?.dm?.allowFrom) : undefined;
return {
...cfg,
channels: {
...cfg.channels,
matrix: {
...cfg.channels?.matrix,
dm: {
...cfg.channels?.matrix?.dm,
policy,
...(allowFrom ? { allowFrom } : {}),
},
},
},
};
}
const setMatrixGroupPolicy = createTopLevelChannelGroupPolicySetter({
channel,
enabled: true,
});
async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
@@ -128,33 +113,19 @@ async function promptMatrixAllowFrom(params: {
}
const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds);
return {
...cfg,
channels: {
...cfg.channels,
matrix: {
...cfg.channels?.matrix,
enabled: true,
dm: {
...cfg.channels?.matrix?.dm,
policy: "allowlist",
allowFrom: unique,
},
},
return patchNestedChannelConfigSection({
cfg,
channel,
section: "dm",
enabled: true,
patch: {
policy: "allowlist",
allowFrom: unique,
},
};
}) as CoreConfig;
}
}
function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") {
return setTopLevelChannelGroupPolicy({
cfg,
channel: "matrix",
groupPolicy,
enabled: true,
}) as CoreConfig;
}
function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) {
const groups = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }]));
return {
@@ -242,15 +213,16 @@ const matrixGroupAccess: NonNullable<ChannelSetupWizard["groupAccess"]> = {
setMatrixGroupRooms(cfg as CoreConfig, resolved as string[]),
};
const matrixDmPolicy: ChannelSetupDmPolicy = {
const matrixDmPolicy: ChannelSetupDmPolicy = createNestedChannelDmPolicy({
label: "Matrix",
channel,
section: "dm",
policyKey: "channels.matrix.dm.policy",
allowFromKey: "channels.matrix.dm.allowFrom",
getCurrent: (cfg) => (cfg as CoreConfig).channels?.matrix?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy),
promptAllowFrom: promptMatrixAllowFrom,
};
enabled: true,
});
export { matrixSetupAdapter } from "./setup-core.js";

View File

@@ -1,9 +1,10 @@
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
import { createScopedAccountConfigAccessors } from "openclaw/plugin-sdk/channel-config-helpers";
import {
buildAccountScopedDmSecurityPolicy,
collectAllowlistProviderRestrictSendersWarnings,
} from "openclaw/plugin-sdk/channel-policy";
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-runtime";
import type { ChannelMessageToolDiscovery } from "openclaw/plugin-sdk/channel-runtime";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
@@ -31,10 +32,8 @@ import {
buildChannelConfigSchema,
createAccountStatusSink,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
setAccountEnabledInConfigSection,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
type ChannelPlugin,
@@ -258,6 +257,22 @@ const mattermostConfigAccessors = createScopedAccountConfigAccessors({
}),
});
const mattermostConfigBase = createScopedChannelConfigBase<ResolvedMattermostAccount>({
sectionKey: "mattermost",
listAccountIds: listMattermostAccountIds,
resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultMattermostAccountId,
clearBaseFields: ["botToken", "baseUrl", "name"],
});
const resolveMattermostDmPolicy = createScopedDmSecurityResolver<ResolvedMattermostAccount>({
channelKey: "mattermost",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => normalizeAllowEntry(raw),
});
export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
id: "mattermost",
meta: {
@@ -295,24 +310,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
reload: { configPrefixes: ["channels.mattermost"] },
configSchema: buildChannelConfigSchema(MattermostConfigSchema),
config: {
listAccountIds: (cfg) => listMattermostAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultMattermostAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "mattermost",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "mattermost",
accountId,
clearBaseFields: ["botToken", "baseUrl", "name"],
}),
...mattermostConfigBase,
isConfigured: (account) => Boolean(account.botToken && account.baseUrl),
describeAccount: (account) => ({
accountId: account.accountId,
@@ -325,18 +323,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
...mattermostConfigAccessors,
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
return buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: "mattermost",
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.config.dmPolicy,
allowFrom: account.config.allowFrom ?? [],
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => normalizeAllowEntry(raw),
});
},
resolveDmPolicy: resolveMattermostDmPolicy,
collectWarnings: ({ account, cfg }) => {
return collectAllowlistProviderRestrictSendersWarnings({
cfg,

View File

@@ -0,0 +1,99 @@
import type { ChatType, OpenClawConfig } from "../runtime-api.js";
export function mapMattermostChannelTypeToChatType(channelType?: string | null): ChatType {
if (!channelType) {
return "channel";
}
const normalized = channelType.trim().toUpperCase();
if (normalized === "D") {
return "direct";
}
if (normalized === "G" || normalized === "P") {
return "group";
}
return "channel";
}
export type MattermostRequireMentionResolverInput = {
cfg: OpenClawConfig;
channel: "mattermost";
accountId: string;
groupId: string;
requireMentionOverride?: boolean;
};
export type MattermostMentionGateInput = {
kind: ChatType;
cfg: OpenClawConfig;
accountId: string;
channelId: string;
threadRootId?: string;
requireMentionOverride?: boolean;
resolveRequireMention: (params: MattermostRequireMentionResolverInput) => boolean;
wasMentioned: boolean;
isControlCommand: boolean;
commandAuthorized: boolean;
oncharEnabled: boolean;
oncharTriggered: boolean;
canDetectMention: boolean;
};
type MattermostMentionGateDecision = {
shouldRequireMention: boolean;
shouldBypassMention: boolean;
effectiveWasMentioned: boolean;
dropReason: "onchar-not-triggered" | "missing-mention" | null;
};
export function evaluateMattermostMentionGate(
params: MattermostMentionGateInput,
): MattermostMentionGateDecision {
const shouldRequireMention =
params.kind !== "direct" &&
params.resolveRequireMention({
cfg: params.cfg,
channel: "mattermost",
accountId: params.accountId,
groupId: params.channelId,
requireMentionOverride: params.requireMentionOverride,
});
const shouldBypassMention =
params.isControlCommand &&
shouldRequireMention &&
!params.wasMentioned &&
params.commandAuthorized;
const effectiveWasMentioned =
params.wasMentioned || shouldBypassMention || params.oncharTriggered;
if (
params.oncharEnabled &&
!params.oncharTriggered &&
!params.wasMentioned &&
!params.isControlCommand
) {
return {
shouldRequireMention,
shouldBypassMention,
effectiveWasMentioned,
dropReason: "onchar-not-triggered",
};
}
if (
params.kind !== "direct" &&
shouldRequireMention &&
params.canDetectMention &&
!effectiveWasMentioned
) {
return {
shouldRequireMention,
shouldBypassMention,
effectiveWasMentioned,
dropReason: "missing-mention",
};
}
return {
shouldRequireMention,
shouldBypassMention,
effectiveWasMentioned,
dropReason: null,
};
}

View File

@@ -67,6 +67,10 @@ import {
isMattermostSenderAllowed,
normalizeMattermostAllowList,
} from "./monitor-auth.js";
import {
evaluateMattermostMentionGate,
mapMattermostChannelTypeToChatType,
} from "./monitor-gating.js";
import {
createDedupeCache,
formatInboundFromLabel,
@@ -96,6 +100,15 @@ import {
getSlashCommandState,
} from "./slash-state.js";
export {
evaluateMattermostMentionGate,
mapMattermostChannelTypeToChatType,
} from "./monitor-gating.js";
export type {
MattermostMentionGateInput,
MattermostRequireMentionResolverInput,
} from "./monitor-gating.js";
export type MonitorMattermostOpts = {
botToken?: string;
baseUrl?: string;
@@ -150,27 +163,6 @@ function isSystemPost(post: MattermostPost): boolean {
return Boolean(type);
}
export function mapMattermostChannelTypeToChatType(channelType?: string | null): ChatType {
if (!channelType) {
return "channel";
}
// Mattermost channel types: D=direct, G=group DM, O=public channel, P=private channel.
const normalized = channelType.trim().toUpperCase();
if (normalized === "D") {
return "direct";
}
if (normalized === "G") {
return "group";
}
if (normalized === "P") {
// Private channels are invitation-restricted spaces; route as "group" so
// groupPolicy / groupAllowFrom can gate access separately from open public
// channels (type "O"), and the From prefix becomes mattermost:group:<id>.
return "group";
}
return "channel";
}
function channelChatType(kind: ChatType): "direct" | "group" | "channel" {
if (kind === "direct") {
return "direct";
@@ -181,90 +173,6 @@ function channelChatType(kind: ChatType): "direct" | "group" | "channel" {
return "channel";
}
export type MattermostRequireMentionResolverInput = {
cfg: OpenClawConfig;
channel: "mattermost";
accountId: string;
groupId: string;
requireMentionOverride?: boolean;
};
export type MattermostMentionGateInput = {
kind: ChatType;
cfg: OpenClawConfig;
accountId: string;
channelId: string;
threadRootId?: string;
requireMentionOverride?: boolean;
resolveRequireMention: (params: MattermostRequireMentionResolverInput) => boolean;
wasMentioned: boolean;
isControlCommand: boolean;
commandAuthorized: boolean;
oncharEnabled: boolean;
oncharTriggered: boolean;
canDetectMention: boolean;
};
type MattermostMentionGateDecision = {
shouldRequireMention: boolean;
shouldBypassMention: boolean;
effectiveWasMentioned: boolean;
dropReason: "onchar-not-triggered" | "missing-mention" | null;
};
export function evaluateMattermostMentionGate(
params: MattermostMentionGateInput,
): MattermostMentionGateDecision {
const shouldRequireMention =
params.kind !== "direct" &&
params.resolveRequireMention({
cfg: params.cfg,
channel: "mattermost",
accountId: params.accountId,
groupId: params.channelId,
requireMentionOverride: params.requireMentionOverride,
});
const shouldBypassMention =
params.isControlCommand &&
shouldRequireMention &&
!params.wasMentioned &&
params.commandAuthorized;
const effectiveWasMentioned =
params.wasMentioned || shouldBypassMention || params.oncharTriggered;
if (
params.oncharEnabled &&
!params.oncharTriggered &&
!params.wasMentioned &&
!params.isControlCommand
) {
return {
shouldRequireMention,
shouldBypassMention,
effectiveWasMentioned,
dropReason: "onchar-not-triggered",
};
}
if (
params.kind !== "direct" &&
shouldRequireMention &&
params.canDetectMention &&
!effectiveWasMentioned
) {
return {
shouldRequireMention,
shouldBypassMention,
effectiveWasMentioned,
dropReason: "missing-mention",
};
}
return {
shouldRequireMention,
shouldBypassMention,
effectiveWasMentioned,
dropReason: null,
};
}
export function resolveMattermostReplyRootId(params: {
threadRootId?: string;
replyToId?: string;

View File

@@ -1,4 +1,8 @@
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import {
createScopedAccountConfigAccessors,
createTopLevelChannelConfigBase,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime";
import type {
@@ -63,6 +67,30 @@ const loadMSTeamsChannelRuntime = createLazyRuntimeNamedExport(
"msTeamsChannelRuntime",
);
const resolveMSTeamsChannelConfig = (cfg: OpenClawConfig) => ({
allowFrom: cfg.channels?.msteams?.allowFrom,
defaultTo: cfg.channels?.msteams?.defaultTo,
});
const msteamsConfigBase = createTopLevelChannelConfigBase<ResolvedMSTeamsAccount>({
sectionKey: "msteams",
resolveAccount: (cfg) => ({
accountId: DEFAULT_ACCOUNT_ID,
enabled: cfg.channels?.msteams?.enabled !== false,
configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
}),
});
const msteamsConfigAccessors = createScopedAccountConfigAccessors<{
allowFrom?: Array<string | number>;
defaultTo?: string;
}>({
resolveAccount: ({ cfg }) => resolveMSTeamsChannelConfig(cfg),
resolveAllowFrom: (account) => account.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
resolveDefaultTo: (account) => account.defaultTo,
});
function describeMSTeamsMessageTool({
cfg,
}: Parameters<
@@ -128,43 +156,14 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
reload: { configPrefixes: ["channels.msteams"] },
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
config: {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
resolveAccount: (cfg) => ({
accountId: DEFAULT_ACCOUNT_ID,
enabled: cfg.channels?.msteams?.enabled !== false,
configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
}),
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
setAccountEnabled: ({ cfg, enabled }) => ({
...cfg,
channels: {
...cfg.channels,
msteams: {
...cfg.channels?.msteams,
enabled,
},
},
}),
deleteAccount: ({ cfg }) => {
const next = { ...cfg } as OpenClawConfig;
const nextChannels = { ...cfg.channels };
delete nextChannels.msteams;
if (Object.keys(nextChannels).length > 0) {
next.channels = nextChannels;
} else {
delete next.channels;
}
return next;
},
...msteamsConfigBase,
isConfigured: (_account, cfg) => Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
describeAccount: (account) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
}),
resolveAllowFrom: ({ cfg }) => cfg.channels?.msteams?.allowFrom ?? [],
formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }),
resolveDefaultTo: ({ cfg }) => cfg.channels?.msteams?.defaultTo?.trim() || undefined,
...msteamsConfigAccessors,
},
security: {
collectWarnings: ({ cfg }) => {

View File

@@ -1,14 +1,13 @@
import {
createTopLevelChannelAllowFromSetter,
createTopLevelChannelDmPolicy,
createTopLevelChannelGroupPolicySetter,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
mergeAllowFromEntries,
setTopLevelChannelAllowFrom,
setTopLevelChannelDmPolicyWithAllowFrom,
setTopLevelChannelGroupPolicy,
splitSetupEntries,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
type DmPolicy,
type OpenClawConfig,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup";
@@ -23,22 +22,13 @@ import { msteamsSetupAdapter } from "./setup-core.js";
import { hasConfiguredMSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js";
const channel = "msteams" as const;
function setMSTeamsDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) {
return setTopLevelChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy,
});
}
function setMSTeamsAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig {
return setTopLevelChannelAllowFrom({
cfg,
channel,
allowFrom,
});
}
const setMSTeamsAllowFrom = createTopLevelChannelAllowFromSetter({
channel,
});
const setMSTeamsGroupPolicy = createTopLevelChannelGroupPolicySetter({
channel,
enabled: true,
});
function looksLikeGuid(value: string): boolean {
return /^[0-9a-fA-F-]{16,}$/.test(value);
@@ -146,18 +136,6 @@ async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise<void
);
}
function setMSTeamsGroupPolicy(
cfg: OpenClawConfig,
groupPolicy: "open" | "allowlist" | "disabled",
): OpenClawConfig {
return setTopLevelChannelGroupPolicy({
cfg,
channel,
groupPolicy,
enabled: true,
});
}
function setMSTeamsTeamsAllowlist(
cfg: OpenClawConfig,
entries: Array<{ teamKey: string; channelKey?: string }>,
@@ -281,15 +259,14 @@ const msteamsGroupAccess: NonNullable<ChannelSetupWizard["groupAccess"]> = {
setMSTeamsTeamsAllowlist(cfg, resolved as Array<{ teamKey: string; channelKey?: string }>),
};
const msteamsDmPolicy: ChannelSetupDmPolicy = {
const msteamsDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({
label: "MS Teams",
channel,
policyKey: "channels.msteams.dmPolicy",
allowFromKey: "channels.msteams.allowFrom",
getCurrent: (cfg) => cfg.channels?.msteams?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setMSTeamsDmPolicy(cfg, policy),
promptAllowFrom: promptMSTeamsAllowFrom,
};
});
export { msteamsSetupAdapter } from "./setup-core.js";

View File

@@ -1,8 +1,11 @@
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
import {
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import {
buildAccountScopedDmSecurityPolicy,
collectAllowlistProviderGroupPolicyWarnings,
collectOpenGroupPolicyRouteAllowlistWarnings,
} from "openclaw/plugin-sdk/channel-policy";
@@ -13,8 +16,6 @@ import {
buildRuntimeAccountStatusSnapshot,
clearAccountEntryFields,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
type ChannelPlugin,
type OpenClawConfig,
} from "../runtime-api.js";
@@ -49,6 +50,37 @@ const meta = {
quickstartAllowFrom: true,
};
const nextcloudTalkConfigAccessors =
createScopedAccountConfigAccessors<ResolvedNextcloudTalkAccount>({
resolveAccount: ({ cfg, accountId }) =>
resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }),
resolveAllowFrom: (account) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatAllowFromLowercase({
allowFrom,
stripPrefixRe: /^(nextcloud-talk|nc-talk|nc):/i,
}),
});
const nextcloudTalkConfigBase = createScopedChannelConfigBase<
ResolvedNextcloudTalkAccount,
CoreConfig
>({
sectionKey: "nextcloud-talk",
listAccountIds: listNextcloudTalkAccountIds,
resolveAccount: (cfg, accountId) => resolveNextcloudTalkAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultNextcloudTalkAccountId,
clearBaseFields: ["botSecret", "botSecretFile", "baseUrl", "name"],
});
const resolveNextcloudTalkDmPolicy = createScopedDmSecurityResolver<ResolvedNextcloudTalkAccount>({
channelKey: "nextcloud-talk",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => raw.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(),
});
export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> = {
id: "nextcloud-talk",
meta,
@@ -72,25 +104,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
reload: { configPrefixes: ["channels.nextcloud-talk"] },
configSchema: buildChannelConfigSchema(NextcloudTalkConfigSchema),
config: {
listAccountIds: (cfg) => listNextcloudTalkAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) =>
resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "nextcloud-talk",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "nextcloud-talk",
accountId,
clearBaseFields: ["botSecret", "botSecretFile", "baseUrl", "name"],
}),
...nextcloudTalkConfigBase,
isConfigured: (account) => Boolean(account.secret?.trim() && account.baseUrl?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
@@ -100,29 +114,10 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
secretSource: account.secretSource,
baseUrl: account.baseUrl ? "[set]" : "[missing]",
}),
resolveAllowFrom: ({ cfg, accountId }) =>
mapAllowFromEntries(
resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom,
).map((entry) => entry.toLowerCase()),
formatAllowFrom: ({ allowFrom }) =>
formatAllowFromLowercase({
allowFrom,
stripPrefixRe: /^(nextcloud-talk|nc-talk|nc):/i,
}),
...nextcloudTalkConfigAccessors,
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
return buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: "nextcloud-talk",
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.config.dmPolicy,
allowFrom: account.config.allowFrom ?? [],
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => raw.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(),
});
},
resolveDmPolicy: resolveNextcloudTalkDmPolicy,
collectWarnings: ({ account, cfg }) => {
const roomAllowlistConfigured =
account.config.rooms && Object.keys(account.config.rooms).length > 0;

View File

@@ -8,9 +8,9 @@ import {
} from "openclaw/plugin-sdk/setup";
import {
mergeAllowFromEntries,
createTopLevelChannelDmPolicy,
resolveSetupAccountId,
setSetupChannelEnabled,
setTopLevelChannelDmPolicyWithAllowFrom,
} from "openclaw/plugin-sdk/setup";
import type { ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup";
import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
@@ -21,7 +21,7 @@ import {
resolveDefaultNextcloudTalkAccountId,
resolveNextcloudTalkAccount,
} from "./accounts.js";
import type { CoreConfig, DmPolicy } from "./types.js";
import type { CoreConfig } from "./types.js";
const channel = "nextcloud-talk" as const;
@@ -46,14 +46,6 @@ export function validateNextcloudTalkBaseUrl(value: string): string | undefined
return undefined;
}
function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
return setTopLevelChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy,
}) as CoreConfig;
}
export function setNextcloudTalkAccountConfig(
cfg: CoreConfig,
accountId: string,
@@ -174,15 +166,14 @@ async function promptNextcloudTalkAllowFromForAccount(params: {
});
}
export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = {
export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({
label: "Nextcloud Talk",
channel,
policyKey: "channels.nextcloud-talk.dmPolicy",
allowFromKey: "channels.nextcloud-talk.allowFrom",
getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy),
promptAllowFrom: promptNextcloudTalkAllowFromForAccount,
};
});
export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),

View File

@@ -1,3 +1,4 @@
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import {
buildChannelConfigSchema,
collectStatusIssuesFromLastError,
@@ -31,6 +32,22 @@ const activeBuses = new Map<string, NostrBusHandle>();
// Store metrics snapshots per account (for status reporting)
const metricsSnapshots = new Map<string, MetricsSnapshot>();
const resolveNostrDmPolicy = createScopedDmSecurityResolver<ResolvedNostrAccount>({
channelKey: "nostr",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
defaultPolicy: "pairing",
approveHint: formatPairingApproveHint("nostr"),
normalizeEntry: (raw) => {
try {
return normalizePubkey(raw.replace(/^nostr:/i, "").trim());
} catch {
return raw.trim();
}
},
});
export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
id: "nostr",
meta: {
@@ -101,22 +118,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
},
security: {
resolveDmPolicy: ({ account }) => {
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: "channels.nostr.dmPolicy",
allowFromPath: "channels.nostr.allowFrom",
approveHint: formatPairingApproveHint("nostr"),
normalizeEntry: (raw) => {
try {
return normalizePubkey(raw.replace(/^nostr:/i, "").trim());
} catch {
return raw.trim();
}
},
};
},
resolveDmPolicy: resolveNostrDmPolicy,
},
messaging: {

View File

@@ -1,12 +1,12 @@
import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import {
createTopLevelChannelAllowFromSetter,
createTopLevelChannelDmPolicy,
mergeAllowFromEntries,
parseSetupEntriesWithParser,
setTopLevelChannelAllowFrom,
setTopLevelChannelDmPolicyWithAllowFrom,
patchTopLevelChannelConfigSection,
splitSetupEntries,
} from "openclaw/plugin-sdk/setup";
import type { ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup";
@@ -18,6 +18,9 @@ import { getPublicKeyFromPrivate, normalizePubkey } from "./nostr-bus.js";
import { resolveNostrAccount } from "./types.js";
const channel = "nostr" as const;
const setNostrAllowFrom = createTopLevelChannelAllowFromSetter({
channel,
});
const NOSTR_SETUP_HELP_LINES = [
"Use a Nostr private key in nsec or 64-character hex format.",
@@ -36,46 +39,6 @@ const NOSTR_ALLOW_FROM_HELP_LINES = [
`Docs: ${formatDocsLink("/channels/nostr", "channels/nostr")}`,
];
function patchNostrConfig(params: {
cfg: OpenClawConfig;
patch: Record<string, unknown>;
clearFields?: string[];
enabled?: boolean;
}): OpenClawConfig {
const existing = (params.cfg.channels?.nostr ?? {}) as Record<string, unknown>;
const nextNostr = { ...existing };
for (const field of params.clearFields ?? []) {
delete nextNostr[field];
}
return {
...params.cfg,
channels: {
...params.cfg.channels,
nostr: {
...nextNostr,
...(params.enabled ? { enabled: true } : {}),
...params.patch,
},
},
};
}
function setNostrDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig {
return setTopLevelChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy,
});
}
function setNostrAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig {
return setTopLevelChannelAllowFrom({
cfg,
channel,
allowFrom,
});
}
function parseRelayUrls(raw: string): { relays: string[]; error?: string } {
const entries = splitSetupEntries(raw);
const relays: string[] = [];
@@ -126,21 +89,21 @@ async function promptNostrAllowFrom(params: {
return setNostrAllowFrom(params.cfg, mergeAllowFromEntries(existing, parsed.entries));
}
const nostrDmPolicy: ChannelSetupDmPolicy = {
const nostrDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({
label: "Nostr",
channel,
policyKey: "channels.nostr.dmPolicy",
allowFromKey: "channels.nostr.allowFrom",
getCurrent: (cfg) => cfg.channels?.nostr?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setNostrDmPolicy(cfg, policy),
promptAllowFrom: promptNostrAllowFrom,
};
});
export const nostrSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountName: ({ cfg, name }) =>
patchNostrConfig({
patchTopLevelChannelConfigSection({
cfg,
channel,
patch: name?.trim() ? { name: name.trim() } : {},
}),
validateInput: ({ input }) => {
@@ -174,8 +137,9 @@ export const nostrSetupAdapter: ChannelSetupAdapter = {
const relayResult = typedInput.relayUrls?.trim()
? parseRelayUrls(typedInput.relayUrls)
: { relays: [] };
return patchNostrConfig({
return patchTopLevelChannelConfigSection({
cfg,
channel,
enabled: true,
clearFields: typedInput.useEnv ? ["privateKey"] : undefined,
patch: {
@@ -218,8 +182,9 @@ export const nostrSetupWizard: ChannelSetupWizard = {
Boolean(process.env.NOSTR_PRIVATE_KEY?.trim()) &&
!resolveNostrAccount({ cfg, accountId }).config.privateKey?.trim(),
apply: async ({ cfg }) =>
patchNostrConfig({
patchTopLevelChannelConfigSection({
cfg,
channel,
enabled: true,
clearFields: ["privateKey"],
patch: {},
@@ -247,15 +212,17 @@ export const nostrSetupWizard: ChannelSetupWizard = {
};
},
applyUseEnv: async ({ cfg }) =>
patchNostrConfig({
patchTopLevelChannelConfigSection({
cfg,
channel,
enabled: true,
clearFields: ["privateKey"],
patch: {},
}),
applySet: async ({ cfg, resolvedValue }) =>
patchNostrConfig({
patchTopLevelChannelConfigSection({
cfg,
channel,
enabled: true,
patch: { privateKey: resolvedValue },
}),
@@ -280,8 +247,9 @@ export const nostrSetupWizard: ChannelSetupWizard = {
validate: ({ value }) => parseRelayUrls(value).error,
applySet: async ({ cfg, value }) => {
const relayResult = parseRelayUrls(value);
return patchNostrConfig({
return patchTopLevelChannelConfigSection({
cfg,
channel,
enabled: true,
clearFields: relayResult.relays.length > 0 ? undefined : ["relays"],
patch: relayResult.relays.length > 0 ? { relays: relayResult.relays } : {},
@@ -291,8 +259,9 @@ export const nostrSetupWizard: ChannelSetupWizard = {
],
dmPolicy: nostrDmPolicy,
disable: (cfg) =>
patchNostrConfig({
patchTopLevelChannelConfigSection({
cfg,
channel,
patch: { enabled: false },
}),
};

View File

@@ -32,3 +32,22 @@ describe("signalPlugin outbound sendMedia", () => {
);
});
});
describe("signalPlugin actions", () => {
it("owns unified message tool discovery", () => {
const discovery = signalPlugin.actions?.describeMessageTool?.({
cfg: {
channels: {
signal: {
actions: { reactions: false },
accounts: {
work: { account: "+15550001111", actions: { reactions: true } },
},
},
},
} as never,
});
expect(discovery?.actions).toEqual(["send", "react"]);
});
});

View File

@@ -1,7 +1,7 @@
import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit";
import {
buildAccountScopedDmSecurityPolicy,
collectAllowlistProviderRestrictSendersWarnings,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
@@ -19,7 +19,6 @@ import {
normalizeSignalMessagingTarget,
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
type ChannelMessageActionAdapter,
type ChannelPlugin,
} from "openclaw/plugin-sdk/signal";
import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js";
@@ -30,25 +29,19 @@ import {
resolveSignalRecipient,
resolveSignalSender,
} from "./identity.js";
import { signalMessageActions } from "./message-actions.js";
import type { SignalProbe } from "./probe.js";
import { getSignalRuntime } from "./runtime.js";
import { signalSetupAdapter } from "./setup-core.js";
import { createSignalPluginBase, signalConfigAccessors, signalSetupWizard } from "./shared.js";
const signalMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: (ctx) =>
getSignalRuntime().channel.signal.messageActions?.describeMessageTool?.(ctx) ?? null,
supportsAction: (ctx) =>
getSignalRuntime().channel.signal.messageActions?.supportsAction?.(ctx) ?? false,
handleAction: async (ctx) => {
const ma = getSignalRuntime().channel.signal.messageActions;
if (!ma?.handleAction) {
throw new Error("Signal message actions not available");
}
return ma.handleAction(ctx);
},
};
const resolveSignalDmPolicy = createScopedDmSecurityResolver<ResolvedSignalAccount>({
channelKey: "signal",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()),
});
type SignalSendFn = ReturnType<typeof getSignalRuntime>["channel"]["signal"]["sendMessageSignal"];
function resolveSignalSendContext(params: {
@@ -311,18 +304,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
}),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
return buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: "signal",
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.config.dmPolicy,
allowFrom: account.config.allowFrom ?? [],
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()),
});
},
resolveDmPolicy: resolveSignalDmPolicy,
collectWarnings: ({ account, cfg }) => {
return collectAllowlistProviderRestrictSendersWarnings({
cfg,

View File

@@ -3,3 +3,4 @@ export { probeSignal } from "./probe.js";
export { sendMessageSignal } from "./send.js";
export { sendReactionSignal, removeReactionSignal } from "./send-reactions.js";
export { resolveSignalReactionLevel } from "./reaction-level.js";
export { signalMessageActions } from "./message-actions.js";

View File

@@ -1,13 +1,14 @@
import { createActionGate, jsonResult, readStringParam } from "../../../agents/tools/common.js";
import { resolveSignalAccount } from "../../../plugin-sdk/account-resolution.js";
import {
listEnabledSignalAccounts,
removeReactionSignal,
resolveSignalReactionLevel,
sendReactionSignal,
} from "../../../plugin-sdk/signal.js";
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
import { resolveReactionMessageId } from "./reaction-message-id.js";
createActionGate,
jsonResult,
readStringParam,
resolveReactionMessageId,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
} from "openclaw/plugin-sdk/channel-runtime";
import { listEnabledSignalAccounts, resolveSignalAccount } from "./accounts.js";
import { resolveSignalReactionLevel } from "./reaction-level.js";
import { removeReactionSignal, sendReactionSignal } from "./send-reactions.js";
const providerId = "signal";
const GROUP_PREFIX = "group:";
@@ -103,7 +104,6 @@ export const signalMessageActions: ChannelMessageActionAdapter = {
}
if (action === "react") {
// Check reaction level first
const reactionLevelInfo = resolveSignalReactionLevel({
cfg,
accountId: accountId ?? undefined,
@@ -115,7 +115,6 @@ export const signalMessageActions: ChannelMessageActionAdapter = {
);
}
// Also check the action gate for backward compatibility
const actionConfig = resolveSignalAccount({ cfg, accountId }).config.actions;
const isActionEnabled = createActionGate(actionConfig);
if (!isActionEnabled("reactions")) {

View File

@@ -1,10 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { removeReactionSignal, sendReactionSignal } from "./send-reactions.js";
const rpcMock = vi.fn();
vi.mock("../../../src/config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadConfig: () => ({}),
@@ -25,9 +24,14 @@ vi.mock("./client.js", () => ({
signalRpcRequest: (...args: unknown[]) => rpcMock(...args),
}));
let sendReactionSignal: typeof import("./send-reactions.js").sendReactionSignal;
let removeReactionSignal: typeof import("./send-reactions.js").removeReactionSignal;
describe("sendReactionSignal", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
rpcMock.mockClear().mockResolvedValue({ timestamp: 123 });
({ sendReactionSignal, removeReactionSignal } = await import("./send-reactions.js"));
});
it("uses recipients array and targetAuthor for uuid dms", async () => {

View File

@@ -6,6 +6,7 @@ export * from "./src/blocks-render.js";
export * from "./src/http/index.js";
export * from "./src/interactive-replies.js";
export * from "./src/message-actions.js";
export * from "./src/group-policy.js";
export * from "./src/sent-thread-cache.js";
export * from "./src/targets.js";
export * from "./src/threading-tool-context.js";

View File

@@ -3,7 +3,7 @@ import {
resolveLegacyDmAllowlistConfigPaths,
} from "openclaw/plugin-sdk/allowlist-config-edit";
import {
buildAccountScopedDmSecurityPolicy,
createScopedDmSecurityResolver,
collectOpenGroupPolicyConfiguredRouteWarnings,
collectOpenProviderGroupPolicyWarnings,
} from "openclaw/plugin-sdk/channel-config-helpers";
@@ -20,8 +20,6 @@ import {
PAIRING_APPROVED_MESSAGE,
projectCredentialSnapshotFields,
resolveConfiguredFromRequiredCredentialStatuses,
resolveSlackGroupRequireMention,
resolveSlackGroupToolPolicy,
type ChannelPlugin,
type OpenClawConfig,
type SlackActionContext,
@@ -36,6 +34,7 @@ import {
import { parseSlackBlocksInput } from "./blocks-input.js";
import { createSlackActions } from "./channel-actions.js";
import { createSlackWebClient } from "./client.js";
import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js";
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
import { normalizeAllowListLower } from "./monitor/allow-list.js";
import type { SlackProbe } from "./probe.js";
@@ -55,6 +54,14 @@ import { buildSlackThreadingToolContext } from "./threading-tool-context.js";
const SLACK_CHANNEL_TYPE_CACHE = new Map<string, "channel" | "group" | "dm" | "unknown">();
const resolveSlackDmPolicy = createScopedDmSecurityResolver<ResolvedSlackAccount>({
channelKey: "slack",
resolvePolicy: (account) => account.dm?.policy,
resolveAllowFrom: (account) => account.dm?.allowFrom,
allowFromPathSuffix: "dm.",
normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""),
});
// Select the appropriate Slack token for read/write operations.
function getTokenForOperation(
account: ResolvedSlackAccount,
@@ -352,18 +359,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
}),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
return buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: "slack",
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.dm?.policy,
allowFrom: account.dm?.allowFrom ?? [],
allowFromPathSuffix: "dm.",
normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""),
});
},
resolveDmPolicy: resolveSlackDmPolicy,
collectWarnings: ({ account, cfg }) => {
const channelAllowlistConfigured =
Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0;

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js";
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
channels: {
alerts: {
requireMention: false,
tools: { allow: ["message.send"] },
toolsBySender: {
"id:user:alice": { allow: ["sessions.list"] },
},
},
"*": {
requireMention: true,
tools: { deny: ["exec"] },
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
describe("slack group policy", () => {
it("uses matched channel requireMention and wildcard fallback", () => {
expect(resolveSlackGroupRequireMention({ cfg, groupChannel: "#alerts" })).toBe(false);
expect(resolveSlackGroupRequireMention({ cfg, groupChannel: "#missing" })).toBe(true);
});
it("resolves sender override, then channel tools, then wildcard tools", () => {
const senderOverride = resolveSlackGroupToolPolicy({
cfg,
groupChannel: "#alerts",
senderId: "user:alice",
});
expect(senderOverride).toEqual({ allow: ["sessions.list"] });
const channelTools = resolveSlackGroupToolPolicy({
cfg,
groupChannel: "#alerts",
senderId: "user:bob",
});
expect(channelTools).toEqual({ allow: ["message.send"] });
const wildcardTools = resolveSlackGroupToolPolicy({
cfg,
groupChannel: "#missing",
senderId: "user:bob",
});
expect(wildcardTools).toEqual({ deny: ["exec"] });
});
});

View File

@@ -0,0 +1,74 @@
import {
resolveToolsBySender,
type GroupToolPolicyBySenderConfig,
type GroupToolPolicyConfig,
} from "openclaw/plugin-sdk/channel-policy";
import { type ChannelGroupContext } from "openclaw/plugin-sdk/channel-runtime";
import { normalizeHyphenSlug } from "openclaw/plugin-sdk/core";
import { inspectSlackAccount } from "./account-inspect.js";
type SlackChannelPolicyEntry = {
requireMention?: boolean;
tools?: GroupToolPolicyConfig;
toolsBySender?: GroupToolPolicyBySenderConfig;
};
function resolveSlackChannelPolicyEntry(
params: ChannelGroupContext,
): SlackChannelPolicyEntry | undefined {
const account = inspectSlackAccount({
cfg: params.cfg,
accountId: params.accountId,
});
const channels = (account.channels ?? {}) as Record<string, SlackChannelPolicyEntry>;
if (Object.keys(channels).length === 0) {
return undefined;
}
const channelId = params.groupId?.trim();
const groupChannel = params.groupChannel;
const channelName = groupChannel?.replace(/^#/, "");
const normalizedName = normalizeHyphenSlug(channelName);
const candidates = [
channelId ?? "",
channelName ? `#${channelName}` : "",
channelName ?? "",
normalizedName,
].filter(Boolean);
for (const candidate of candidates) {
if (candidate && channels[candidate]) {
return channels[candidate];
}
}
return channels["*"];
}
function resolveSenderToolsEntry(
entry: SlackChannelPolicyEntry | undefined,
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
if (!entry) {
return undefined;
}
const senderPolicy = resolveToolsBySender({
toolsBySender: entry.toolsBySender,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
return senderPolicy ?? entry.tools;
}
export function resolveSlackGroupRequireMention(params: ChannelGroupContext): boolean {
const resolved = resolveSlackChannelPolicyEntry(params);
if (typeof resolved?.requireMention === "boolean") {
return resolved.requireMention;
}
return true;
}
export function resolveSlackGroupToolPolicy(
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
return resolveSenderToolsEntry(resolveSlackChannelPolicyEntry(params), params);
}

View File

@@ -22,4 +22,5 @@ export {
export { monitorSlackProvider } from "./monitor.js";
export { probeSlack } from "./probe.js";
export { sendMessageSlack } from "./send.js";
export { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js";
export { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";

View File

@@ -1,15 +1,14 @@
import {
createAllowlistSetupWizardProxy,
createAccountScopedAllowFromSection,
createAccountScopedGroupAccessSection,
createLegacyCompatChannelDmPolicy,
DEFAULT_ACCOUNT_ID,
createEnvPatchedAccountSetupAdapter,
hasConfiguredSecretInput,
type OpenClawConfig,
noteChannelLookupFailure,
noteChannelLookupSummary,
parseMentionOrPrefixedId,
patchChannelConfigForAccount,
setAccountGroupPolicyForChannel,
setLegacyChannelDmPolicyWithAllowFrom,
setSetupChannelEnabled,
} from "openclaw/plugin-sdk/setup";
import {
@@ -112,21 +111,11 @@ export function createSlackSetupWizardBase(handlers: {
NonNullable<NonNullable<ChannelSetupWizard["groupAccess"]>["resolveAllowlist"]>
>;
}) {
const slackDmPolicy: ChannelSetupDmPolicy = {
const slackDmPolicy: ChannelSetupDmPolicy = createLegacyCompatChannelDmPolicy({
label: "Slack",
channel,
policyKey: "channels.slack.dmPolicy",
allowFromKey: "channels.slack.allowFrom",
getCurrent: (cfg: OpenClawConfig) =>
cfg.channels?.slack?.dmPolicy ?? cfg.channels?.slack?.dm?.policy ?? "pairing",
setPolicy: (cfg: OpenClawConfig, policy) =>
setLegacyChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy: policy,
}),
promptAllowFrom: handlers.promptAllowFrom,
};
});
return {
channel,
@@ -178,7 +167,9 @@ export function createSlackSetupWizardBase(handlers: {
}),
],
dmPolicy: slackDmPolicy,
allowFrom: {
allowFrom: createAccountScopedAllowFromSection({
channel,
credentialInputKey: "botToken",
helpTitle: "Slack allowlist",
helpLines: [
"Allowlist Slack DMs by username (we resolve to user ids).",
@@ -188,7 +179,6 @@ export function createSlackSetupWizardBase(handlers: {
"Multiple entries: comma-separated.",
`Docs: ${formatDocsLink("/slack", "slack")}`,
],
credentialInputKey: "botToken",
message: "Slack allowFrom (usernames or ids)",
placeholder: "@alice, U12345678",
invalidWithoutCredentialNote: "Slack token missing; use user ids (or mention form) only.",
@@ -200,34 +190,10 @@ export function createSlackSetupWizardBase(handlers: {
idPattern: /^[A-Z][A-Z0-9]+$/i,
normalizeId: (id) => id.toUpperCase(),
}),
resolveEntries: async ({
cfg,
accountId,
credentialValues,
entries,
}: {
cfg: OpenClawConfig;
accountId: string;
credentialValues: { botToken?: string };
entries: string[];
}) => await handlers.resolveAllowFromEntries({ cfg, accountId, credentialValues, entries }),
apply: ({
cfg,
accountId,
allowFrom,
}: {
cfg: OpenClawConfig;
accountId: string;
allowFrom: string[];
}) =>
patchChannelConfigForAccount({
cfg,
channel,
accountId,
patch: { dmPolicy: "allowlist", allowFrom },
}),
},
groupAccess: {
resolveEntries: handlers.resolveAllowFromEntries,
}),
groupAccess: createAccountScopedGroupAccessSection({
channel,
label: "Slack channels",
placeholder: "#general, #private, C123",
currentPolicy: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) =>
@@ -238,57 +204,8 @@ export function createSlackSetupWizardBase(handlers: {
.map(([key]) => key),
updatePrompt: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) =>
Boolean(resolveSlackAccount({ cfg, accountId }).config.channels),
setPolicy: ({
cfg,
accountId,
policy,
}: {
cfg: OpenClawConfig;
accountId: string;
policy: "open" | "allowlist" | "disabled";
}) =>
setAccountGroupPolicyForChannel({
cfg,
channel,
accountId,
groupPolicy: policy,
}),
resolveAllowlist: async ({
cfg,
accountId,
credentialValues,
entries,
prompter,
}: {
cfg: OpenClawConfig;
accountId: string;
credentialValues: { botToken?: string };
entries: string[];
prompter: { note: (message: string, title?: string) => Promise<void> };
}) => {
try {
return await handlers.resolveGroupAllowlist({
cfg,
accountId,
credentialValues,
entries,
prompter,
});
} catch (error) {
await noteChannelLookupFailure({
prompter,
label: "Slack channels",
error,
});
await noteChannelLookupSummary({
prompter,
label: "Slack channels",
resolvedSections: [],
unresolved: entries,
});
return entries;
}
},
resolveAllowlist: handlers.resolveGroupAllowlist,
fallbackResolved: (entries) => entries,
applyAllowlist: ({
cfg,
accountId,
@@ -298,7 +215,7 @@ export function createSlackSetupWizardBase(handlers: {
accountId: string;
resolved: unknown;
}) => setSlackChannelAllowlist(cfg, accountId, resolved as string[]),
},
}),
disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false),
} satisfies ChannelSetupWizard;
}

View File

@@ -1,10 +1,10 @@
import {
noteChannelLookupFailure,
noteChannelLookupSummary,
resolveEntriesWithOptionalToken,
type OpenClawConfig,
parseMentionOrPrefixedId,
promptLegacyChannelAllowFrom,
resolveSetupAccountId,
promptLegacyChannelAllowFromForAccount,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup";
import type {
@@ -22,22 +22,26 @@ async function resolveSlackAllowFromEntries(params: {
token?: string;
entries: string[];
}): Promise<ChannelSetupWizardAllowFromEntry[]> {
if (!params.token?.trim()) {
return params.entries.map((input) => ({
return await resolveEntriesWithOptionalToken({
token: params.token,
entries: params.entries,
buildWithoutToken: (input) => ({
input,
resolved: false,
id: null,
}));
}
const resolved = await resolveSlackUserAllowlist({
token: params.token,
entries: params.entries,
}),
resolveEntries: async ({ token, entries }) =>
(
await resolveSlackUserAllowlist({
token,
entries,
})
).map((entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.id ?? null,
})),
});
return resolved.map((entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.id ?? null,
}));
}
async function promptSlackAllowFrom(params: {
@@ -45,14 +49,6 @@ async function promptSlackAllowFrom(params: {
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
const accountId = resolveSetupAccountId({
accountId: params.accountId,
defaultAccountId: resolveDefaultSlackAccountId(params.cfg),
});
const resolved = resolveSlackAccount({ cfg: params.cfg, accountId });
const token = resolved.userToken ?? resolved.botToken ?? "";
const existing =
params.cfg.channels?.slack?.allowFrom ?? params.cfg.channels?.slack?.dm?.allowFrom ?? [];
const parseId = (value: string) =>
parseMentionOrPrefixedId({
value,
@@ -62,12 +58,16 @@ async function promptSlackAllowFrom(params: {
normalizeId: (id) => id.toUpperCase(),
});
return promptLegacyChannelAllowFrom({
return await promptLegacyChannelAllowFromForAccount({
cfg: params.cfg,
channel,
prompter: params.prompter,
existing,
token,
accountId: params.accountId,
defaultAccountId: resolveDefaultSlackAccountId(params.cfg),
resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }),
resolveExisting: (_account, cfg) =>
cfg.channels?.slack?.allowFrom ?? cfg.channels?.slack?.dm?.allowFrom ?? [],
resolveToken: (account) => account.userToken ?? account.botToken ?? "",
noteTitle: "Slack allowlist",
noteLines: [
"Allowlist Slack DMs by username (we resolve to user ids).",
@@ -81,11 +81,17 @@ async function promptSlackAllowFrom(params: {
placeholder: "@alice, U12345678",
parseId,
invalidWithoutTokenNote: "Slack token missing; use user ids (or mention form) only.",
resolveEntries: ({ token, entries }) =>
resolveSlackUserAllowlist({
token,
entries,
}),
resolveEntries: async ({ token, entries }) =>
(
await resolveSlackUserAllowlist({
token,
entries,
})
).map((entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.id ?? null,
})),
});
}
@@ -102,11 +108,21 @@ async function resolveSlackGroupAllowlist(params: {
accountId: params.accountId,
});
const activeBotToken = accountWithTokens.botToken || params.credentialValues.botToken || "";
if (activeBotToken && params.entries.length > 0) {
if (params.entries.length > 0) {
try {
const resolved = await resolveSlackChannelAllowlist({
const resolved = await resolveEntriesWithOptionalToken<{
input: string;
resolved: boolean;
id?: string;
}>({
token: activeBotToken,
entries: params.entries,
buildWithoutToken: (input) => ({ input, resolved: false, id: undefined }),
resolveEntries: async ({ token, entries }) =>
await resolveSlackChannelAllowlist({
token,
entries,
}),
});
const resolvedKeys = resolved
.filter((entry) => entry.resolved && entry.id)

View File

@@ -55,7 +55,7 @@ describe("createSynologyChatPlugin", () => {
it("defaultAccountId returns 'default'", () => {
const plugin = createSynologyChatPlugin();
expect(plugin.config.defaultAccountId({})).toBe("default");
expect(plugin.config.defaultAccountId?.({})).toBe("default");
});
});
@@ -79,7 +79,7 @@ describe("createSynologyChatPlugin", () => {
expect(result.policy).toBe("allowlist");
expect(result.allowFrom).toEqual(["user1"]);
expect(typeof result.normalizeEntry).toBe("function");
expect(result.normalizeEntry(" USER1 ")).toBe("user1");
expect(result.normalizeEntry?.(" USER1 ")).toBe("user1");
});
});

View File

@@ -4,13 +4,12 @@
* Implements the ChannelPlugin interface following the LINE pattern.
*/
import { z } from "zod";
import {
DEFAULT_ACCOUNT_ID,
setAccountEnabledInConfigSection,
registerPluginHttpRoute,
buildChannelConfigSchema,
} from "../api.js";
createHybridChannelConfigBase,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { z } from "zod";
import { DEFAULT_ACCOUNT_ID, registerPluginHttpRoute, buildChannelConfigSchema } from "../api.js";
import { listAccountIds, resolveAccount } from "./accounts.js";
import { sendMessage, sendFileUrl } from "./client.js";
import { getSynologyRuntime } from "./runtime.js";
@@ -23,6 +22,34 @@ const SynologyChatConfigSchema = buildChannelConfigSchema(z.object({}).passthrou
const activeRouteUnregisters = new Map<string, () => void>();
const resolveSynologyChatDmPolicy = createScopedDmSecurityResolver<ResolvedSynologyChatAccount>({
channelKey: CHANNEL_ID,
resolvePolicy: (account) => account.dmPolicy,
resolveAllowFrom: (account) => account.allowedUserIds,
policyPathSuffix: "dmPolicy",
defaultPolicy: "allowlist",
approveHint: "openclaw pairing approve synology-chat <code>",
normalizeEntry: (raw) => raw.toLowerCase().trim(),
});
const synologyChatConfigBase = createHybridChannelConfigBase<ResolvedSynologyChatAccount>({
sectionKey: CHANNEL_ID,
listAccountIds: (cfg: any) => listAccountIds(cfg),
resolveAccount: (cfg: any, accountId?: string | null) => resolveAccount(cfg, accountId),
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
clearBaseFields: [
"token",
"incomingUrl",
"nasHost",
"webhookPath",
"dmPolicy",
"allowedUserIds",
"rateLimitPerMinute",
"botName",
"allowInsecureSsl",
],
});
function waitUntilAbort(signal?: AbortSignal, onAbort?: () => void): Promise<void> {
return new Promise((resolve) => {
const complete = () => {
@@ -73,30 +100,7 @@ export function createSynologyChatPlugin() {
setupWizard: synologyChatSetupWizard,
config: {
listAccountIds: (cfg: any) => listAccountIds(cfg),
resolveAccount: (cfg: any, accountId?: string | null) => resolveAccount(cfg, accountId),
defaultAccountId: (_cfg: any) => DEFAULT_ACCOUNT_ID,
setAccountEnabled: ({ cfg, accountId, enabled }: any) => {
const channelConfig = cfg?.channels?.[CHANNEL_ID] ?? {};
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
[CHANNEL_ID]: { ...channelConfig, enabled },
},
};
}
return setAccountEnabledInConfigSection({
cfg,
sectionKey: `channels.${CHANNEL_ID}`,
accountId,
enabled,
});
},
...synologyChatConfigBase,
},
pairing: {
@@ -115,30 +119,7 @@ export function createSynologyChatPlugin() {
},
security: {
resolveDmPolicy: ({
cfg,
accountId,
account,
}: {
cfg: any;
accountId?: string | null;
account: ResolvedSynologyChatAccount;
}) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const channelCfg = (cfg as any).channels?.["synology-chat"];
const useAccountPath = Boolean(channelCfg?.accounts?.[resolvedAccountId]);
const basePath = useAccountPath
? `channels.synology-chat.accounts.${resolvedAccountId}.`
: "channels.synology-chat.";
return {
policy: account.dmPolicy ?? "allowlist",
allowFrom: account.allowedUserIds ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: "openclaw pairing approve synology-chat <code>",
normalizeEntry: (raw: string) => raw.toLowerCase().trim(),
};
},
resolveDmPolicy: resolveSynologyChatDmPolicy,
collectWarnings: ({ account }: { account: ResolvedSynologyChatAccount }) => {
const warnings: string[] = [];
if (!account.token) {

View File

@@ -3,6 +3,7 @@ export * from "./src/accounts.js";
export * from "./src/allow-from.js";
export * from "./src/api-fetch.js";
export * from "./src/exec-approvals.js";
export * from "./src/group-policy.js";
export * from "./src/inline-buttons.js";
export * from "./src/model-buttons.js";
export * from "./src/normalize.js";

View File

@@ -0,0 +1,40 @@
import type { Message } from "@grammyjs/types";
import { MediaFetchError } from "openclaw/plugin-sdk/media-runtime";
export const APPROVE_CALLBACK_DATA_RE =
/^\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(allow-once|allow-always|deny)\b/i;
export function isMediaSizeLimitError(err: unknown): boolean {
const errMsg = String(err);
return errMsg.includes("exceeds") && errMsg.includes("MB limit");
}
export function isRecoverableMediaGroupError(err: unknown): boolean {
return err instanceof MediaFetchError || isMediaSizeLimitError(err);
}
export function hasInboundMedia(msg: Message): boolean {
return (
Boolean(msg.media_group_id) ||
(Array.isArray(msg.photo) && msg.photo.length > 0) ||
Boolean(msg.video ?? msg.video_note ?? msg.document ?? msg.audio ?? msg.voice ?? msg.sticker)
);
}
export function hasReplyTargetMedia(msg: Message): boolean {
const externalReply = (msg as Message & { external_reply?: Message }).external_reply;
const replyTarget = msg.reply_to_message ?? externalReply;
return Boolean(replyTarget && hasInboundMedia(replyTarget));
}
export function resolveInboundMediaFileId(msg: Message): string | undefined {
return (
msg.sticker?.file_id ??
msg.photo?.[msg.photo.length - 1]?.file_id ??
msg.video?.file_id ??
msg.video_note?.file_id ??
msg.document?.file_id ??
msg.audio?.file_id ??
msg.voice?.file_id
);
}

View File

@@ -25,7 +25,6 @@ import {
resolvePluginConversationBindingApproval,
} from "openclaw/plugin-sdk/conversation-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
import { MediaFetchError } from "openclaw/plugin-sdk/media-runtime";
import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime";
import {
createInboundDebouncer,
@@ -48,6 +47,14 @@ import {
normalizeDmAllowFromWithStore,
type NormalizedAllowFrom,
} from "./bot-access.js";
import {
APPROVE_CALLBACK_DATA_RE,
hasInboundMedia,
hasReplyTargetMedia,
isMediaSizeLimitError,
isRecoverableMediaGroupError,
resolveInboundMediaFileId,
} from "./bot-handlers.media.js";
import type { TelegramMediaRef } from "./bot-message-context.js";
import { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import {
@@ -92,44 +99,6 @@ import {
import { buildInlineKeyboard } from "./send.js";
import { wasSentByBot } from "./sent-message-cache.js";
const APPROVE_CALLBACK_DATA_RE =
/^\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(allow-once|allow-always|deny)\b/i;
function isMediaSizeLimitError(err: unknown): boolean {
const errMsg = String(err);
return errMsg.includes("exceeds") && errMsg.includes("MB limit");
}
function isRecoverableMediaGroupError(err: unknown): boolean {
return err instanceof MediaFetchError || isMediaSizeLimitError(err);
}
function hasInboundMedia(msg: Message): boolean {
return (
Boolean(msg.media_group_id) ||
(Array.isArray(msg.photo) && msg.photo.length > 0) ||
Boolean(msg.video ?? msg.video_note ?? msg.document ?? msg.audio ?? msg.voice ?? msg.sticker)
);
}
function hasReplyTargetMedia(msg: Message): boolean {
const externalReply = (msg as Message & { external_reply?: Message }).external_reply;
const replyTarget = msg.reply_to_message ?? externalReply;
return Boolean(replyTarget && hasInboundMedia(replyTarget));
}
function resolveInboundMediaFileId(msg: Message): string | undefined {
return (
msg.sticker?.file_id ??
msg.photo?.[msg.photo.length - 1]?.file_id ??
msg.video?.file_id ??
msg.video_note?.file_id ??
msg.document?.file_id ??
msg.audio?.file_id ??
msg.voice?.file_id
);
}
export const registerTelegramHandlers = ({
cfg,
accountId,

View File

@@ -154,6 +154,42 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe("telegramPlugin groups", () => {
it("uses plugin-owned group policy resolvers", () => {
const cfg = {
channels: {
telegram: {
botToken: "telegram-test",
groups: {
"-1001": {
requireMention: true,
tools: { allow: ["message.send"] },
topics: {
"77": {
requireMention: false,
},
},
},
},
},
},
} as OpenClawConfig;
expect(
telegramPlugin.groups?.resolveRequireMention?.({
cfg,
groupId: "-1001:topic:77",
}),
).toBe(false);
expect(
telegramPlugin.groups?.resolveToolPolicy?.({
cfg,
groupId: "-1001:topic:77",
}),
).toEqual({ allow: ["message.send"] });
});
});
describe("telegramPlugin duplicate token guard", () => {
it("marks secondary account as not configured when token is shared", async () => {
const cfg = createCfg();

View File

@@ -20,8 +20,6 @@ import {
PAIRING_APPROVED_MESSAGE,
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,
resolveTelegramGroupRequireMention,
resolveTelegramGroupToolPolicy,
type ChannelPlugin,
type ChannelMessageActionAdapter,
type OpenClawConfig,
@@ -38,6 +36,10 @@ import {
isTelegramExecApprovalClientEnabled,
resolveTelegramExecApprovalTarget,
} from "./exec-approvals.js";
import {
resolveTelegramGroupRequireMention,
resolveTelegramGroupToolPolicy,
} from "./group-policy.js";
import { monitorTelegramProvider } from "./monitor.js";
import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./normalize.js";
import { sendTelegramPayloadMessages } from "./outbound-adapter.js";

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
resolveTelegramGroupRequireMention,
resolveTelegramGroupToolPolicy,
} from "./group-policy.js";
describe("telegram group policy", () => {
it("resolves topic-level requireMention and chat-level tools for topic ids", () => {
const telegramCfg = {
channels: {
telegram: {
botToken: "telegram-test",
groups: {
"-1001": {
requireMention: true,
tools: { allow: ["message.send"] },
topics: {
"77": {
requireMention: false,
},
},
},
"*": {
requireMention: true,
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(
resolveTelegramGroupRequireMention({ cfg: telegramCfg, groupId: "-1001:topic:77" }),
).toBe(false);
expect(resolveTelegramGroupToolPolicy({ cfg: telegramCfg, groupId: "-1001:topic:77" })).toEqual(
{
allow: ["message.send"],
},
);
});
});

View File

@@ -0,0 +1,91 @@
import {
resolveChannelGroupRequireMention,
resolveChannelGroupToolsPolicy,
type GroupToolPolicyConfig,
} from "openclaw/plugin-sdk/channel-policy";
import { type ChannelGroupContext } from "openclaw/plugin-sdk/channel-runtime";
function parseTelegramGroupId(value?: string | null) {
const raw = value?.trim() ?? "";
if (!raw) {
return { chatId: undefined, topicId: undefined };
}
const parts = raw.split(":").filter(Boolean);
if (
parts.length >= 3 &&
parts[1] === "topic" &&
/^-?\d+$/.test(parts[0]) &&
/^\d+$/.test(parts[2])
) {
return { chatId: parts[0], topicId: parts[2] };
}
if (parts.length >= 2 && /^-?\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
return { chatId: parts[0], topicId: parts[1] };
}
return { chatId: raw, topicId: undefined };
}
function resolveTelegramRequireMention(params: {
cfg: ChannelGroupContext["cfg"];
chatId?: string;
topicId?: string;
}): boolean | undefined {
const { cfg, chatId, topicId } = params;
if (!chatId) {
return undefined;
}
const groupConfig = cfg.channels?.telegram?.groups?.[chatId];
const groupDefault = cfg.channels?.telegram?.groups?.["*"];
const topicConfig = topicId && groupConfig?.topics ? groupConfig.topics[topicId] : undefined;
const defaultTopicConfig =
topicId && groupDefault?.topics ? groupDefault.topics[topicId] : undefined;
if (typeof topicConfig?.requireMention === "boolean") {
return topicConfig.requireMention;
}
if (typeof defaultTopicConfig?.requireMention === "boolean") {
return defaultTopicConfig.requireMention;
}
if (typeof groupConfig?.requireMention === "boolean") {
return groupConfig.requireMention;
}
if (typeof groupDefault?.requireMention === "boolean") {
return groupDefault.requireMention;
}
return undefined;
}
export function resolveTelegramGroupRequireMention(
params: ChannelGroupContext,
): boolean | undefined {
const { chatId, topicId } = parseTelegramGroupId(params.groupId);
const requireMention = resolveTelegramRequireMention({
cfg: params.cfg,
chatId,
topicId,
});
if (typeof requireMention === "boolean") {
return requireMention;
}
return resolveChannelGroupRequireMention({
cfg: params.cfg,
channel: "telegram",
groupId: chatId ?? params.groupId,
accountId: params.accountId,
});
}
export function resolveTelegramGroupToolPolicy(
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
const { chatId } = parseTelegramGroupId(params.groupId);
return resolveChannelGroupToolsPolicy({
cfg: params.cfg,
channel: "telegram",
groupId: chatId ?? params.groupId,
accountId: params.accountId,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
}

View File

@@ -1,3 +1,4 @@
import { createHybridChannelConfigBase } from "openclaw/plugin-sdk/channel-config-helpers";
import type { ChannelAccountSnapshot, ChannelPlugin } from "openclaw/plugin-sdk/channel-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
@@ -37,6 +38,16 @@ const tlonSetupWizardProxy = createTlonSetupWizardBase({
).tlonSetupWizard.finalize!(params),
}) satisfies NonNullable<ChannelPlugin["setupWizard"]>;
const tlonConfigBase = createHybridChannelConfigBase({
sectionKey: TLON_CHANNEL_ID,
listAccountIds: (cfg: OpenClawConfig) => listTlonAccountIds(cfg),
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
resolveTlonAccount(cfg, accountId ?? undefined),
defaultAccountId: () => "default",
clearBaseFields: ["ship", "code", "url", "name"],
preserveSectionOnDefaultDelete: true,
});
export const tlonPlugin: ChannelPlugin = {
id: TLON_CHANNEL_ID,
meta: {
@@ -60,70 +71,7 @@ export const tlonPlugin: ChannelPlugin = {
reload: { configPrefixes: ["channels.tlon"] },
configSchema: tlonChannelConfigSchema,
config: {
listAccountIds: (cfg) => listTlonAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveTlonAccount(cfg, accountId ?? undefined),
defaultAccountId: () => "default",
setAccountEnabled: ({ cfg, accountId, enabled }) => {
const useDefault = !accountId || accountId === "default";
if (useDefault) {
return {
...cfg,
channels: {
...cfg.channels,
tlon: {
...cfg.channels?.tlon,
enabled,
},
},
} as OpenClawConfig;
}
return {
...cfg,
channels: {
...cfg.channels,
tlon: {
...cfg.channels?.tlon,
accounts: {
...cfg.channels?.tlon?.accounts,
[accountId]: {
...cfg.channels?.tlon?.accounts?.[accountId],
enabled,
},
},
},
},
} as OpenClawConfig;
},
deleteAccount: ({ cfg, accountId }) => {
const useDefault = !accountId || accountId === "default";
if (useDefault) {
const {
ship: _ship,
code: _code,
url: _url,
name: _name,
...rest
} = cfg.channels?.tlon ?? {};
return {
...cfg,
channels: {
...cfg.channels,
tlon: rest,
},
} as OpenClawConfig;
}
const { [accountId]: _removed, ...remainingAccounts } = cfg.channels?.tlon?.accounts ?? {};
return {
...cfg,
channels: {
...cfg.channels,
tlon: {
...cfg.channels?.tlon,
accounts: remainingAccounts,
},
},
} as OpenClawConfig;
},
...tlonConfigBase,
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,

View File

@@ -0,0 +1,30 @@
import type { OpenClawConfig } from "../../api.js";
import type { TlonSettingsStore } from "../settings.js";
type ChannelAuthorization = {
mode?: "restricted" | "open";
allowedShips?: string[];
};
export function resolveChannelAuthorization(
cfg: OpenClawConfig,
channelNest: string,
settings?: TlonSettingsStore,
): { mode: "restricted" | "open"; allowedShips: string[] } {
const tlonConfig = cfg.channels?.tlon as
| {
authorization?: { channelRules?: Record<string, ChannelAuthorization> };
defaultAuthorizedShips?: string[];
}
| undefined;
const fileRules = tlonConfig?.authorization?.channelRules ?? {};
const settingsRules = settings?.channelRules ?? {};
const rule = settingsRules[channelNest] ?? fileRules[channelNest];
const defaultShips = settings?.defaultAuthorizedShips ?? tlonConfig?.defaultAuthorizedShips ?? [];
return {
mode: rule?.mode ?? "restricted",
allowedShips: rule?.allowedShips ?? defaultShips,
};
}

View File

@@ -24,6 +24,7 @@ import {
formatBlockedList,
formatPendingList,
} from "./approval.js";
import { resolveChannelAuthorization } from "./authorization.js";
import { fetchAllChannels, fetchInitData } from "./discovery.js";
import { cacheMessage, getChannelHistory, fetchThreadHistory } from "./history.js";
import { downloadMessageImages } from "./media.js";
@@ -46,40 +47,6 @@ export type MonitorTlonOpts = {
accountId?: string | null;
};
type ChannelAuthorization = {
mode?: "restricted" | "open";
allowedShips?: string[];
};
/**
* Resolve channel authorization by merging file config with settings store.
* Settings store takes precedence for fields it defines.
*/
function resolveChannelAuthorization(
cfg: OpenClawConfig,
channelNest: string,
settings?: TlonSettingsStore,
): { mode: "restricted" | "open"; allowedShips: string[] } {
const tlonConfig = cfg.channels?.tlon as
| {
authorization?: { channelRules?: Record<string, ChannelAuthorization> };
defaultAuthorizedShips?: string[];
}
| undefined;
// Merge channel rules: settings override file config
const fileRules = tlonConfig?.authorization?.channelRules ?? {};
const settingsRules = settings?.channelRules ?? {};
const rule = settingsRules[channelNest] ?? fileRules[channelNest];
// Merge default authorized ships: settings override file config
const defaultShips = settings?.defaultAuthorizedShips ?? tlonConfig?.defaultAuthorizedShips ?? [];
const allowedShips = rule?.allowedShips ?? defaultShips;
const mode = rule?.mode ?? "restricted";
return { mode, allowedShips };
}
export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<void> {
const core = getTlonRuntime();
const cfg = core.config.loadConfig() as OpenClawConfig;

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import {
resolveWhatsAppGroupRequireMention,
resolveWhatsAppGroupToolPolicy,
} from "./group-policy.js";
describe("whatsapp group policy", () => {
it("uses generic channel group policy helpers", () => {
const cfg = {
channels: {
whatsapp: {
groups: {
"1203630@g.us": {
requireMention: false,
tools: { deny: ["exec"] },
},
"*": {
requireMention: true,
tools: { allow: ["message.send"] },
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(resolveWhatsAppGroupRequireMention({ cfg, groupId: "1203630@g.us" })).toBe(false);
expect(resolveWhatsAppGroupRequireMention({ cfg, groupId: "other@g.us" })).toBe(true);
expect(resolveWhatsAppGroupToolPolicy({ cfg, groupId: "1203630@g.us" })).toEqual({
deny: ["exec"],
});
expect(resolveWhatsAppGroupToolPolicy({ cfg, groupId: "other@g.us" })).toEqual({
allow: ["message.send"],
});
});
});

View File

@@ -0,0 +1,40 @@
import {
resolveChannelGroupRequireMention,
resolveChannelGroupToolsPolicy,
type GroupToolPolicyConfig,
} from "openclaw/plugin-sdk/channel-policy";
import type { OpenClawConfig } from "openclaw/plugin-sdk/whatsapp-core";
type WhatsAppGroupContext = {
cfg: OpenClawConfig;
accountId?: string | null;
groupId?: string | null;
senderId?: string | null;
senderName?: string | null;
senderUsername?: string | null;
senderE164?: string | null;
};
export function resolveWhatsAppGroupRequireMention(params: WhatsAppGroupContext): boolean {
return resolveChannelGroupRequireMention({
cfg: params.cfg,
channel: "whatsapp",
groupId: params.groupId,
accountId: params.accountId,
});
}
export function resolveWhatsAppGroupToolPolicy(
params: WhatsAppGroupContext,
): GroupToolPolicyConfig | undefined {
return resolveChannelGroupToolsPolicy({
cfg: params.cfg,
channel: "whatsapp",
groupId: params.groupId,
accountId: params.accountId,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
}

View File

@@ -13,8 +13,6 @@ import {
resolveWhatsAppConfigAllowFrom,
resolveWhatsAppConfigDefaultTo,
resolveWhatsAppGroupIntroHint,
resolveWhatsAppGroupRequireMention,
resolveWhatsAppGroupToolPolicy,
WhatsAppConfigSchema,
type ChannelPlugin,
} from "openclaw/plugin-sdk/whatsapp-core";
@@ -24,6 +22,10 @@ import {
resolveWhatsAppAccount,
type ResolvedWhatsAppAccount,
} from "./accounts.js";
import {
resolveWhatsAppGroupRequireMention,
resolveWhatsAppGroupToolPolicy,
} from "./group-policy.js";
export const WHATSAPP_CHANNEL = "whatsapp" as const;

View File

@@ -1,6 +1,10 @@
import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
import {
buildAccountScopedDmSecurityPolicy,
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
createScopedDmSecurityResolver,
mapAllowFromEntries,
} from "openclaw/plugin-sdk/channel-config-helpers";
import {
buildOpenGroupPolicyRestrictSendersWarning,
buildOpenGroupPolicyWarning,
collectOpenProviderGroupPolicyWarnings,
@@ -17,13 +21,11 @@ import {
buildTokenChannelStatusSummary,
buildChannelSendResult,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
chunkTextForOutbound,
formatAllowFromLowercase,
listDirectoryUserEntriesFromAllowFrom,
isNumericTargetId,
sendPayloadWithChunkedTextAndMedia,
setAccountEnabledInConfigSection,
} from "openclaw/plugin-sdk/zalo";
import {
listZaloAccountIds,
@@ -59,6 +61,29 @@ function normalizeZaloMessagingTarget(raw: string): string | undefined {
const loadZaloChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
const zaloConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveZaloAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedZaloAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
});
const zaloConfigBase = createScopedChannelConfigBase<ResolvedZaloAccount>({
sectionKey: "zalo",
listAccountIds: listZaloAccountIds,
resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultZaloAccountId,
clearBaseFields: ["botToken", "tokenFile", "name"],
});
const resolveZaloDmPolicy = createScopedDmSecurityResolver<ResolvedZaloAccount>({
channelKey: "zalo",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""),
});
export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
id: "zalo",
meta,
@@ -76,24 +101,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
reload: { configPrefixes: ["channels.zalo"] },
configSchema: buildChannelConfigSchema(ZaloConfigSchema),
config: {
listAccountIds: (cfg) => listZaloAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultZaloAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg,
sectionKey: "zalo",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg,
sectionKey: "zalo",
accountId,
clearBaseFields: ["botToken", "tokenFile", "name"],
}),
...zaloConfigBase,
isConfigured: (account) => Boolean(account.token?.trim()),
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
@@ -102,24 +110,10 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
configured: Boolean(account.token?.trim()),
tokenSource: account.tokenSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
mapAllowFromEntries(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom),
formatAllowFrom: ({ allowFrom }) =>
formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
...zaloConfigAccessors,
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
return buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: "zalo",
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.config.dmPolicy,
allowFrom: account.config.allowFrom ?? [],
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""),
});
},
resolveDmPolicy: resolveZaloDmPolicy,
collectWarnings: ({ account, cfg }) => {
return collectOpenProviderGroupPolicyWarnings({
cfg,

View File

@@ -1,5 +1,6 @@
import {
buildSingleChannelSecretPromptState,
createTopLevelChannelDmPolicy,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
hasConfiguredSecretInput,
@@ -7,7 +8,6 @@ import {
normalizeAccountId,
promptSingleChannelSecretInput,
runSingleChannelSecretStep,
setTopLevelChannelDmPolicyWithAllowFrom,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
type OpenClawConfig,
@@ -20,17 +20,6 @@ const channel = "zalo" as const;
type UpdateMode = "polling" | "webhook";
function setZaloDmPolicy(
cfg: OpenClawConfig,
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
) {
return setTopLevelChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy,
}) as OpenClawConfig;
}
function setZaloUpdateMode(
cfg: OpenClawConfig,
accountId: string,
@@ -183,13 +172,12 @@ async function promptZaloAllowFrom(params: {
} as OpenClawConfig;
}
const zaloDmPolicy: ChannelSetupDmPolicy = {
const zaloDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({
label: "Zalo",
channel,
policyKey: "channels.zalo.dmPolicy",
allowFromKey: "channels.zalo.allowFrom",
getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing",
setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as OpenClawConfig, policy),
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
const id =
accountId && normalizeAccountId(accountId)
@@ -201,7 +189,7 @@ const zaloDmPolicy: ChannelSetupDmPolicy = {
accountId: id,
});
},
};
});
export { zaloSetupAdapter } from "./setup-core.js";

View File

@@ -1,5 +1,5 @@
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import { buildAccountScopedDmSecurityPolicy } from "openclaw/plugin-sdk/channel-policy";
import type {
ChannelAccountSnapshot,
ChannelDirectoryEntry,
@@ -217,6 +217,14 @@ function resolveZalouserRequireMention(params: ChannelGroupContext): boolean {
return true;
}
const resolveZalouserDmPolicy = createScopedDmSecurityResolver<ResolvedZalouserAccount>({
channelKey: "zalouser",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => raw.replace(/^(zalouser|zlu):/i, ""),
});
const zalouserMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: ({ cfg }) => {
const accounts = listZalouserAccountIds(cfg)
@@ -292,18 +300,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
setup: zalouserSetupAdapter,
}),
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
return buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: "zalouser",
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.config.dmPolicy,
allowFrom: account.config.allowFrom ?? [],
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => raw.replace(/^(zalouser|zlu):/i, ""),
});
},
resolveDmPolicy: resolveZalouserDmPolicy,
},
groups: {
resolveRequireMention: resolveZalouserRequireMention,

View File

@@ -1,4 +1,6 @@
import {
createTopLevelChannelDmPolicy,
createTopLevelChannelDmPolicySetter,
DEFAULT_ACCOUNT_ID,
formatCliCommand,
formatDocsLink,
@@ -6,7 +8,6 @@ import {
mergeAllowFromEntries,
normalizeAccountId,
patchScopedAccountConfig,
setTopLevelChannelDmPolicyWithAllowFrom,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
type DmPolicy,
@@ -29,6 +30,9 @@ import {
} from "./zalo-js.js";
const channel = "zalouser" as const;
const setZalouserDmPolicy = createTopLevelChannelDmPolicySetter({
channel,
});
const ZALOUSER_ALLOW_FROM_PLACEHOLDER = "Alice, 123456789, or leave empty to configure later";
const ZALOUSER_GROUPS_PLACEHOLDER = "Family, Work, 123456789, or leave empty for now";
const ZALOUSER_DM_ACCESS_TITLE = "Zalo Personal DM access";
@@ -57,14 +61,6 @@ function setZalouserAccountScopedConfig(
}) as OpenClawConfig;
}
function setZalouserDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig {
return setTopLevelChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy,
}) as OpenClawConfig;
}
function setZalouserGroupPolicy(
cfg: OpenClawConfig,
accountId: string,
@@ -193,13 +189,12 @@ async function promptZalouserAllowFrom(params: {
}
}
const zalouserDmPolicy: ChannelSetupDmPolicy = {
const zalouserDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({
label: "Zalo Personal",
channel,
policyKey: "channels.zalouser.dmPolicy",
allowFromKey: "channels.zalouser.allowFrom",
getCurrent: (cfg) => (cfg.channels?.zalouser?.dmPolicy ?? "pairing") as DmPolicy,
setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as OpenClawConfig, policy),
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
const id =
accountId && normalizeAccountId(accountId)
@@ -211,7 +206,7 @@ const zalouserDmPolicy: ChannelSetupDmPolicy = {
accountId: id,
});
},
};
});
async function promptZalouserQuickstartDmPolicy(params: {
cfg: OpenClawConfig;

View File

@@ -4,10 +4,6 @@ const rootEntries = [
"src/entry.ts!",
"src/cli/daemon-cli.ts!",
"src/infra/warning-filter.ts!",
"src/channels/plugins/agent-tools/whatsapp-login.ts!",
"src/channels/plugins/actions/discord.ts!",
"src/channels/plugins/actions/signal.ts!",
"src/channels/plugins/actions/telegram.ts!",
"extensions/telegram/src/audit.ts!",
"extensions/telegram/src/token.ts!",
"src/line/accounts.ts!",

View File

@@ -2,6 +2,21 @@ import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
const resolveProviderCapabilitiesWithPluginMock = vi.fn(
(params: { provider: string; workspaceDir?: string }) => {
if (
params.provider === "workspace-anthropic-proxy" &&
params.workspaceDir === "/tmp/workspace-capabilities"
) {
return {
anthropicToolSchemaMode: "openai-functions",
anthropicToolChoiceMode: "openai-string-modes",
};
}
return undefined;
},
);
vi.mock("../plugins/provider-runtime.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/provider-runtime.js")>();
const {
@@ -65,6 +80,10 @@ vi.mock("../plugins/provider-runtime.js", async (importOriginal) => {
const thinkingLevel = skipReasoningInjection ? undefined : params.context.thinkingLevel;
return createOpenRouterSystemCacheWrapper(createOpenRouterWrapper(streamFn, thinkingLevel));
},
resolveProviderCapabilitiesWithPlugin: (params: {
provider: string;
workspaceDir?: string;
}) => resolveProviderCapabilitiesWithPluginMock(params),
};
});
@@ -1047,6 +1066,64 @@ describe("applyExtraParamsToAgent", () => {
]);
});
it("uses workspace plugin capability metadata for anthropic tool payload normalization", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {
tools: [
{
name: "read",
description: "Read file",
input_schema: { type: "object", properties: {} },
},
],
tool_choice: { type: "any" },
};
options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(
agent,
{ plugins: { enabled: true } },
"workspace-anthropic-proxy",
"proxy-model",
undefined,
"low",
undefined,
"/tmp/workspace-capabilities",
);
const model = {
api: "anthropic-messages",
provider: "workspace-anthropic-proxy",
id: "proxy-model",
} as Model<"anthropic-messages">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, {});
expect(payloads).toHaveLength(1);
expect(payloads[0]?.tools).toEqual([
{
type: "function",
function: {
name: "read",
description: "Read file",
parameters: { type: "object", properties: {} },
},
},
]);
expect(payloads[0]?.tool_choice).toBe("required");
expect(resolveProviderCapabilitiesWithPluginMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "workspace-anthropic-proxy",
workspaceDir: "/tmp/workspace-capabilities",
}),
);
});
it("removes invalid negative Google thinkingBudget and maps Gemini 3.1 to thinkingLevel", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {

View File

@@ -1,7 +1,9 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
import type { OpenClawConfig } from "../../config/config.js";
import { resolveFastModeParam } from "../fast-mode.js";
import {
type ProviderCapabilityLookupOptions,
requiresOpenAiCompatibleAnthropicToolPayload,
usesOpenAiFunctionAnthropicToolSchema,
usesOpenAiStringModeAnthropicToolChoice,
@@ -23,6 +25,7 @@ const PI_AI_OAUTH_ANTHROPIC_BETAS = [
type AnthropicServiceTier = "auto" | "standard_only";
type CacheRetention = "none" | "short" | "long";
type AnthropicToolPayloadResolverOptions = ProviderCapabilityLookupOptions;
function isAnthropic1MModel(modelId: string): boolean {
const normalized = modelId.trim().toLowerCase();
@@ -90,14 +93,14 @@ function requiresAnthropicToolPayloadCompatibilityForModel(model: {
api?: unknown;
provider?: unknown;
compat?: unknown;
}): boolean {
}, options?: AnthropicToolPayloadResolverOptions): boolean {
if (model.api !== "anthropic-messages") {
return false;
}
if (
typeof model.provider === "string" &&
requiresOpenAiCompatibleAnthropicToolPayload(model.provider)
requiresOpenAiCompatibleAnthropicToolPayload(model.provider, options)
) {
return true;
}
@@ -107,8 +110,11 @@ function requiresAnthropicToolPayloadCompatibilityForModel(model: {
function usesOpenAiFunctionAnthropicToolSchemaForModel(model: {
provider?: unknown;
compat?: unknown;
}): boolean {
if (typeof model.provider === "string" && usesOpenAiFunctionAnthropicToolSchema(model.provider)) {
}, options?: AnthropicToolPayloadResolverOptions): boolean {
if (
typeof model.provider === "string" &&
usesOpenAiFunctionAnthropicToolSchema(model.provider, options)
) {
return true;
}
return hasOpenAiAnthropicToolPayloadCompatFlag(model);
@@ -117,10 +123,10 @@ function usesOpenAiFunctionAnthropicToolSchemaForModel(model: {
function usesOpenAiStringModeAnthropicToolChoiceForModel(model: {
provider?: unknown;
compat?: unknown;
}): boolean {
}, options?: AnthropicToolPayloadResolverOptions): boolean {
if (
typeof model.provider === "string" &&
usesOpenAiStringModeAnthropicToolChoice(model.provider)
usesOpenAiStringModeAnthropicToolChoice(model.provider, options)
) {
return true;
}
@@ -284,28 +290,47 @@ export function createAnthropicBetaHeadersWrapper(
export function createAnthropicToolPayloadCompatibilityWrapper(
baseStreamFn: StreamFn | undefined,
resolverOptions?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
},
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const originalOnPayload = options?.onPayload;
return (model, context, streamOptions) => {
const originalOnPayload = streamOptions?.onPayload;
return underlying(model, context, {
...options,
...streamOptions,
onPayload: (payload) => {
if (
payload &&
typeof payload === "object" &&
requiresAnthropicToolPayloadCompatibilityForModel(model)
requiresAnthropicToolPayloadCompatibilityForModel(model, {
config: resolverOptions?.config,
workspaceDir: resolverOptions?.workspaceDir,
env: resolverOptions?.env,
})
) {
const payloadObj = payload as Record<string, unknown>;
if (
Array.isArray(payloadObj.tools) &&
usesOpenAiFunctionAnthropicToolSchemaForModel(model)
usesOpenAiFunctionAnthropicToolSchemaForModel(model, {
config: resolverOptions?.config,
workspaceDir: resolverOptions?.workspaceDir,
env: resolverOptions?.env,
})
) {
payloadObj.tools = payloadObj.tools
.map((tool) => normalizeOpenAiFunctionAnthropicToolDefinition(tool))
.filter((tool): tool is Record<string, unknown> => !!tool);
}
if (usesOpenAiStringModeAnthropicToolChoiceForModel(model)) {
if (
usesOpenAiStringModeAnthropicToolChoiceForModel(model, {
config: resolverOptions?.config,
workspaceDir: resolverOptions?.workspaceDir,
env: resolverOptions?.env,
})
) {
payloadObj.tool_choice = normalizeOpenAiStringModeAnthropicToolChoice(
payloadObj.tool_choice,
);

View File

@@ -277,6 +277,7 @@ export function applyExtraParamsToAgent(
extraParamsOverride?: Record<string, unknown>,
thinkingLevel?: ThinkLevel,
agentId?: string,
workspaceDir?: string,
): void {
const resolvedExtraParams = resolveExtraParams({
cfg,
@@ -337,7 +338,10 @@ export function applyExtraParamsToAgent(
agent.streamFn = createSiliconFlowThinkingWrapper(agent.streamFn);
}
agent.streamFn = createAnthropicToolPayloadCompatibilityWrapper(agent.streamFn);
agent.streamFn = createAnthropicToolPayloadCompatibilityWrapper(agent.streamFn, {
config: cfg,
workspaceDir,
});
const providerStreamBase = agent.streamFn;
const pluginWrappedStreamFn = wrapProviderStreamFn({
provider,

View File

@@ -1987,6 +1987,7 @@ export async function runEmbeddedAttempt(
},
params.thinkLevel,
sessionAgentId,
effectiveWorkspace,
);
if (cacheTrace) {

View File

@@ -156,4 +156,22 @@ describe("resolveProviderCapabilities", () => {
}),
).toBe(true);
});
it("forwards config and workspace context to plugin capability lookup", () => {
const config = { plugins: { enabled: true } };
const env = { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv;
resolveProviderCapabilities("anthropic", {
config,
workspaceDir: "/tmp/workspace",
env,
});
expect(resolveProviderCapabilitiesWithPluginMock).toHaveBeenLastCalledWith({
provider: "anthropic",
config,
workspaceDir: "/tmp/workspace",
env,
});
});
});

View File

@@ -1,5 +1,6 @@
import { resolveProviderCapabilitiesWithPlugin } from "../plugins/provider-runtime.js";
import { normalizeProviderId } from "./model-selection.js";
import type { OpenClawConfig } from "../config/config.js";
export type ProviderCapabilities = {
anthropicToolSchemaMode: "native" | "openai-functions";
@@ -14,6 +15,12 @@ export type ProviderCapabilities = {
dropThinkingBlockModelHints: string[];
};
export type ProviderCapabilityLookupOptions = {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
};
const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = {
anthropicToolSchemaMode: "native",
anthropicToolChoiceMode: "native",
@@ -66,10 +73,18 @@ const PLUGIN_CAPABILITIES_FALLBACKS: Record<string, Partial<ProviderCapabilities
},
};
export function resolveProviderCapabilities(provider?: string | null): ProviderCapabilities {
export function resolveProviderCapabilities(
provider?: string | null,
options?: ProviderCapabilityLookupOptions,
): ProviderCapabilities {
const normalized = normalizeProviderId(provider ?? "");
const pluginCapabilities = normalized
? resolveProviderCapabilitiesWithPlugin({ provider: normalized })
? resolveProviderCapabilitiesWithPlugin({
provider: normalized,
config: options?.config,
workspaceDir: options?.workspaceDir,
env: options?.env,
})
: undefined;
return {
...DEFAULT_PROVIDER_CAPABILITIES,
@@ -78,32 +93,55 @@ export function resolveProviderCapabilities(provider?: string | null): ProviderC
};
}
export function preservesAnthropicThinkingSignatures(provider?: string | null): boolean {
return resolveProviderCapabilities(provider).preserveAnthropicThinkingSignatures;
export function preservesAnthropicThinkingSignatures(
provider?: string | null,
options?: ProviderCapabilityLookupOptions,
): boolean {
return resolveProviderCapabilities(provider, options).preserveAnthropicThinkingSignatures;
}
export function requiresOpenAiCompatibleAnthropicToolPayload(provider?: string | null): boolean {
const capabilities = resolveProviderCapabilities(provider);
export function requiresOpenAiCompatibleAnthropicToolPayload(
provider?: string | null,
options?: ProviderCapabilityLookupOptions,
): boolean {
const capabilities = resolveProviderCapabilities(provider, options);
return (
capabilities.anthropicToolSchemaMode !== "native" ||
capabilities.anthropicToolChoiceMode !== "native"
);
}
export function usesOpenAiFunctionAnthropicToolSchema(provider?: string | null): boolean {
return resolveProviderCapabilities(provider).anthropicToolSchemaMode === "openai-functions";
export function usesOpenAiFunctionAnthropicToolSchema(
provider?: string | null,
options?: ProviderCapabilityLookupOptions,
): boolean {
return (
resolveProviderCapabilities(provider, options).anthropicToolSchemaMode === "openai-functions"
);
}
export function usesOpenAiStringModeAnthropicToolChoice(provider?: string | null): boolean {
return resolveProviderCapabilities(provider).anthropicToolChoiceMode === "openai-string-modes";
export function usesOpenAiStringModeAnthropicToolChoice(
provider?: string | null,
options?: ProviderCapabilityLookupOptions,
): boolean {
return (
resolveProviderCapabilities(provider, options).anthropicToolChoiceMode ===
"openai-string-modes"
);
}
export function supportsOpenAiCompatTurnValidation(provider?: string | null): boolean {
return resolveProviderCapabilities(provider).openAiCompatTurnValidation;
export function supportsOpenAiCompatTurnValidation(
provider?: string | null,
options?: ProviderCapabilityLookupOptions,
): boolean {
return resolveProviderCapabilities(provider, options).openAiCompatTurnValidation;
}
export function sanitizesGeminiThoughtSignatures(provider?: string | null): boolean {
return resolveProviderCapabilities(provider).geminiThoughtSignatureSanitization;
export function sanitizesGeminiThoughtSignatures(
provider?: string | null,
options?: ProviderCapabilityLookupOptions,
): boolean {
return resolveProviderCapabilities(provider, options).geminiThoughtSignatureSanitization;
}
function modelIncludesAnyHint(modelId: string | null | undefined, hints: string[]): boolean {
@@ -111,29 +149,41 @@ function modelIncludesAnyHint(modelId: string | null | undefined, hints: string[
return Boolean(normalized) && hints.some((hint) => normalized.includes(hint));
}
export function isOpenAiProviderFamily(provider?: string | null): boolean {
return resolveProviderCapabilities(provider).providerFamily === "openai";
export function isOpenAiProviderFamily(
provider?: string | null,
options?: ProviderCapabilityLookupOptions,
): boolean {
return resolveProviderCapabilities(provider, options).providerFamily === "openai";
}
export function isAnthropicProviderFamily(provider?: string | null): boolean {
return resolveProviderCapabilities(provider).providerFamily === "anthropic";
export function isAnthropicProviderFamily(
provider?: string | null,
options?: ProviderCapabilityLookupOptions,
): boolean {
return resolveProviderCapabilities(provider, options).providerFamily === "anthropic";
}
export function shouldDropThinkingBlocksForModel(params: {
provider?: string | null;
modelId?: string | null;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): boolean {
return modelIncludesAnyHint(
params.modelId,
resolveProviderCapabilities(params.provider).dropThinkingBlockModelHints,
resolveProviderCapabilities(params.provider, params).dropThinkingBlockModelHints,
);
}
export function shouldSanitizeGeminiThoughtSignaturesForModel(params: {
provider?: string | null;
modelId?: string | null;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): boolean {
const capabilities = resolveProviderCapabilities(params.provider);
const capabilities = resolveProviderCapabilities(params.provider, params);
return (
capabilities.geminiThoughtSignatureSanitization &&
modelIncludesAnyHint(params.modelId, capabilities.geminiThoughtSignatureModelHints)
@@ -143,8 +193,9 @@ export function shouldSanitizeGeminiThoughtSignaturesForModel(params: {
export function resolveTranscriptToolCallIdMode(
provider?: string | null,
modelId?: string | null,
options?: ProviderCapabilityLookupOptions,
): "strict9" | undefined {
const capabilities = resolveProviderCapabilities(provider);
const capabilities = resolveProviderCapabilities(provider, options);
const mode = capabilities.transcriptToolCallIdMode;
if (mode === "strict9") {
return mode;

Some files were not shown because too many files have changed in this diff Show More