mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-09 08:11:09 +00:00
Merge branch 'main' into feat/deepseek-provider
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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[]>();
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { resolveRequestUrl } from "openclaw/plugin-sdk/bluebubbles";
|
||||
export { resolveRequestUrl } from "./runtime-api.js";
|
||||
|
||||
1
extensions/bluebubbles/src/runtime-api.ts
Normal file
1
extensions/bluebubbles/src/runtime-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "openclaw/plugin-sdk/bluebubbles";
|
||||
@@ -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 };
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
} from "./runtime-api.js";
|
||||
|
||||
export {
|
||||
buildSecretInputSchema,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type ParsedChatTarget,
|
||||
resolveServicePrefixedAllowTarget,
|
||||
resolveServicePrefixedTarget,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
} from "./runtime-api.js";
|
||||
|
||||
export type BlueBubblesService = "imessage" | "sms" | "auto";
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
79
extensions/discord/src/group-policy.test.ts
Normal file
79
extensions/discord/src/group-policy.test.ts
Normal 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"] });
|
||||
});
|
||||
});
|
||||
111
extensions/discord/src/group-policy.ts
Normal file
111
extensions/discord/src/group-policy.ts
Normal 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);
|
||||
}
|
||||
754
extensions/discord/src/monitor/agent-components-helpers.ts
Normal file
754
extensions/discord/src/monitor/agent-components-helpers.ts
Normal 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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
121
extensions/feishu/src/bot-sender-name.ts
Normal file
121
extensions/feishu/src/bot-sender-name.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
25
extensions/googlechat/src/group-policy.test.ts
Normal file
25
extensions/googlechat/src/group-policy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
17
extensions/googlechat/src/group-policy.ts
Normal file
17
extensions/googlechat/src/group-policy.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
36
extensions/imessage/src/group-policy.test.ts
Normal file
36
extensions/imessage/src/group-policy.test.ts
Normal 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
40
extensions/imessage/src/group-policy.ts
Normal file
40
extensions/imessage/src/group-policy.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
99
extensions/mattermost/src/mattermost/monitor-gating.ts
Normal file
99
extensions/mattermost/src/mattermost/monitor-gating.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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")) {
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
55
extensions/slack/src/group-policy.test.ts
Normal file
55
extensions/slack/src/group-policy.test.ts
Normal 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"] });
|
||||
});
|
||||
});
|
||||
74
extensions/slack/src/group-policy.ts
Normal file
74
extensions/slack/src/group-policy.ts
Normal 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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
40
extensions/telegram/src/bot-handlers.media.ts
Normal file
40
extensions/telegram/src/bot-handlers.media.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
40
extensions/telegram/src/group-policy.test.ts
Normal file
40
extensions/telegram/src/group-policy.test.ts
Normal 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"],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
91
extensions/telegram/src/group-policy.ts
Normal file
91
extensions/telegram/src/group-policy.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
30
extensions/tlon/src/monitor/authorization.ts
Normal file
30
extensions/tlon/src/monitor/authorization.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
36
extensions/whatsapp/src/group-policy.test.ts
Normal file
36
extensions/whatsapp/src/group-policy.test.ts
Normal 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
40
extensions/whatsapp/src/group-policy.ts
Normal file
40
extensions/whatsapp/src/group-policy.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1987,6 +1987,7 @@ export async function runEmbeddedAttempt(
|
||||
},
|
||||
params.thinkLevel,
|
||||
sessionAgentId,
|
||||
effectiveWorkspace,
|
||||
);
|
||||
|
||||
if (cacheTrace) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user