diff --git a/extensions/discord/src/account-inspect.ts b/extensions/discord/src/account-inspect.ts index 9f13b612dab..7e0a28ec7fd 100644 --- a/extensions/discord/src/account-inspect.ts +++ b/extensions/discord/src/account-inspect.ts @@ -3,12 +3,12 @@ import { hasConfiguredSecretInput, normalizeSecretInputString, } from "openclaw/plugin-sdk/config-runtime"; -import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/discord-core"; import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId, resolveDiscordAccountConfig, } from "./accounts.js"; +import type { DiscordAccountConfig, OpenClawConfig } from "./runtime-api.js"; export type DiscordCredentialStatus = "available" | "configured_unavailable" | "missing"; diff --git a/extensions/discord/src/accounts.ts b/extensions/discord/src/accounts.ts index ab014f4bc4a..a323120a787 100644 --- a/extensions/discord/src/accounts.ts +++ b/extensions/discord/src/accounts.ts @@ -3,12 +3,8 @@ import { createAccountListHelpers, } from "openclaw/plugin-sdk/account-helpers"; import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import type { - DiscordAccountConfig, - DiscordActionConfig, - OpenClawConfig, -} from "openclaw/plugin-sdk/discord-core"; import { resolveAccountEntry } from "openclaw/plugin-sdk/routing"; +import type { DiscordAccountConfig, DiscordActionConfig, OpenClawConfig } from "./runtime-api.js"; import { resolveDiscordToken } from "./token.js"; export type ResolvedDiscordAccount = { diff --git a/extensions/discord/src/channel.runtime.ts b/extensions/discord/src/channel.runtime.ts index d4da518fdc1..263cba44e35 100644 --- a/extensions/discord/src/channel.runtime.ts +++ b/extensions/discord/src/channel.runtime.ts @@ -1,5 +1,7 @@ -import { discordSetupWizard as discordSetupWizardImpl } from "./setup-surface.js"; +import { createDiscordSetupWizardProxy } from "./setup-core.js"; type DiscordSetupWizard = typeof import("./setup-surface.js").discordSetupWizard; -export const discordSetupWizard: DiscordSetupWizard = { ...discordSetupWizardImpl }; +export const discordSetupWizard: DiscordSetupWizard = createDiscordSetupWizardProxy( + async () => (await import("./setup-surface.js")).discordSetupWizard, +); diff --git a/extensions/discord/src/setup-account-state.ts b/extensions/discord/src/setup-account-state.ts new file mode 100644 index 00000000000..725e6e4037e --- /dev/null +++ b/extensions/discord/src/setup-account-state.ts @@ -0,0 +1,163 @@ +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import { + hasConfiguredSecretInput, + normalizeSecretInputString, + type OpenClawConfig, +} from "openclaw/plugin-sdk/config-runtime"; +import type { DiscordAccountConfig } from "./runtime-api.js"; +import { resolveDiscordToken } from "./token.js"; + +export type InspectedDiscordSetupAccount = { + accountId: string; + enabled: boolean; + token: string; + tokenSource: "env" | "config" | "none"; + tokenStatus: "available" | "configured_unavailable" | "missing"; + configured: boolean; + config: DiscordAccountConfig; +}; + +function resolveDiscordAccountEntry( + cfg: OpenClawConfig, + accountId: string, +): DiscordAccountConfig | undefined { + const accounts = cfg.channels?.discord?.accounts; + if (!accounts || typeof accounts !== "object" || Array.isArray(accounts)) { + return undefined; + } + const normalized = normalizeAccountId(accountId); + const direct = accounts[normalized]; + if (direct) { + return direct; + } + const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized); + return matchKey ? accounts[matchKey] : undefined; +} + +function inspectConfiguredToken(value: unknown): { + token: string; + tokenSource: "config"; + tokenStatus: "available" | "configured_unavailable"; +} | null { + const normalized = normalizeSecretInputString(value); + if (normalized) { + return { + token: normalized.replace(/^Bot\s+/i, ""), + tokenSource: "config", + tokenStatus: "available", + }; + } + if (hasConfiguredSecretInput(value)) { + return { + token: "", + tokenSource: "config", + tokenStatus: "configured_unavailable", + }; + } + return null; +} + +export function listDiscordSetupAccountIds(cfg: OpenClawConfig): string[] { + const accounts = cfg.channels?.discord?.accounts; + const ids = + accounts && typeof accounts === "object" && !Array.isArray(accounts) + ? Object.keys(accounts) + .map((accountId) => normalizeAccountId(accountId)) + .filter(Boolean) + : []; + return [...new Set([DEFAULT_ACCOUNT_ID, ...ids])]; +} + +export function resolveDefaultDiscordSetupAccountId(cfg: OpenClawConfig): string { + return listDiscordSetupAccountIds(cfg)[0] ?? DEFAULT_ACCOUNT_ID; +} + +export function resolveDiscordSetupAccountConfig(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): { accountId: string; config: DiscordAccountConfig } { + const accountId = normalizeAccountId(params.accountId ?? DEFAULT_ACCOUNT_ID); + const { accounts: _ignored, ...base } = (params.cfg.channels?.discord ?? + {}) as DiscordAccountConfig & { + accounts?: unknown; + }; + return { + accountId, + config: { + ...base, + ...(resolveDiscordAccountEntry(params.cfg, accountId) ?? {}), + }, + }; +} + +export function inspectDiscordSetupAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): InspectedDiscordSetupAccount { + const { accountId, config } = resolveDiscordSetupAccountConfig(params); + const enabled = params.cfg.channels?.discord?.enabled !== false && config.enabled !== false; + const accountConfig = resolveDiscordAccountEntry(params.cfg, accountId); + const hasAccountToken = Boolean( + accountConfig && + Object.prototype.hasOwnProperty.call(accountConfig as Record, "token"), + ); + const accountToken = inspectConfiguredToken(accountConfig?.token); + if (accountToken) { + return { + accountId, + enabled, + token: accountToken.token, + tokenSource: accountToken.tokenSource, + tokenStatus: accountToken.tokenStatus, + configured: true, + config, + }; + } + if (hasAccountToken) { + return { + accountId, + enabled, + token: "", + tokenSource: "none", + tokenStatus: "missing", + configured: false, + config, + }; + } + + const channelToken = inspectConfiguredToken(params.cfg.channels?.discord?.token); + if (channelToken) { + return { + accountId, + enabled, + token: channelToken.token, + tokenSource: channelToken.tokenSource, + tokenStatus: channelToken.tokenStatus, + configured: true, + config, + }; + } + + const tokenResolution = resolveDiscordToken(params.cfg, { accountId }); + if (tokenResolution.token) { + return { + accountId, + enabled, + token: tokenResolution.token, + tokenSource: tokenResolution.source, + tokenStatus: "available", + configured: true, + config, + }; + } + + return { + accountId, + enabled, + token: "", + tokenSource: "none", + tokenStatus: "missing", + configured: false, + config, + }; +} diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index 7e82a9bcc35..e8bf6bb424d 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -1,24 +1,27 @@ +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { createEnvPatchedAccountSetupAdapter } from "openclaw/plugin-sdk/setup-adapter-runtime"; +import type { + ChannelSetupAdapter, + ChannelSetupDmPolicy, + ChannelSetupWizard, +} from "openclaw/plugin-sdk/setup-runtime"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; +import { + inspectDiscordSetupAccount, + listDiscordSetupAccountIds, + resolveDiscordSetupAccountConfig, +} from "./setup-account-state.js"; import { createAccountScopedAllowFromSection, createAccountScopedGroupAccessSection, + createAllowlistSetupWizardProxy, createLegacyCompatChannelDmPolicy, - DEFAULT_ACCOUNT_ID, - createEnvPatchedAccountSetupAdapter, parseMentionOrPrefixedId, patchChannelConfigForAccount, setSetupChannelEnabled, - type OpenClawConfig, -} from "openclaw/plugin-sdk/setup"; -import { - createAllowlistSetupWizardProxy, - type ChannelSetupAdapter, - type ChannelSetupDmPolicy, - type ChannelSetupWizard, -} from "openclaw/plugin-sdk/setup"; -import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; -import { inspectDiscordAccount } from "./account-inspect.js"; -import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js"; +} from "./setup-runtime-helpers.js"; const channel = "discord" as const; @@ -104,8 +107,8 @@ export function createDiscordSetupWizardBase(handlers: { configuredScore: 2, unconfiguredScore: 1, resolveConfigured: ({ cfg }) => - listDiscordAccountIds(cfg).some((accountId) => { - const account = inspectDiscordAccount({ cfg, accountId }); + listDiscordSetupAccountIds(cfg).some((accountId) => { + const account = inspectDiscordSetupAccount({ cfg, accountId }); return account.configured; }), }, @@ -122,7 +125,7 @@ export function createDiscordSetupWizardBase(handlers: { inputPrompt: "Enter Discord bot token", allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID, inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { - const account = inspectDiscordAccount({ cfg, accountId }); + const account = inspectDiscordSetupAccount({ cfg, accountId }); return { accountConfigured: account.configured, hasConfiguredValue: account.tokenStatus !== "missing", @@ -136,25 +139,24 @@ export function createDiscordSetupWizardBase(handlers: { }, ], groupAccess: createAccountScopedGroupAccessSection({ - channel, label: "Discord channels", placeholder: "My Server/#general, guildId/channelId, #support", currentPolicy: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => - resolveDiscordAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist", + resolveDiscordSetupAccountConfig({ cfg, accountId }).config.groupPolicy ?? "allowlist", currentEntries: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => - Object.entries(resolveDiscordAccount({ cfg, accountId }).config.guilds ?? {}).flatMap( - ([guildKey, value]) => { - const channels = value?.channels ?? {}; - const channelKeys = Object.keys(channels); - if (channelKeys.length === 0) { - const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey; - return [input]; - } - return channelKeys.map((channelKey) => `${guildKey}/${channelKey}`); - }, - ), + Object.entries( + resolveDiscordSetupAccountConfig({ cfg, accountId }).config.guilds ?? {}, + ).flatMap(([guildKey, value]) => { + const channels = value?.channels ?? {}; + const channelKeys = Object.keys(channels); + if (channelKeys.length === 0) { + const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey; + return [input]; + } + return channelKeys.map((channelKey) => `${guildKey}/${channelKey}`); + }), updatePrompt: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => - Boolean(resolveDiscordAccount({ cfg, accountId }).config.guilds), + Boolean(resolveDiscordSetupAccountConfig({ cfg, accountId }).config.guilds), resolveAllowlist: handlers.resolveGroupAllowlist, fallbackResolved: (entries) => entries.map((input) => ({ input, resolved: false })), applyAllowlist: ({ @@ -168,7 +170,6 @@ export function createDiscordSetupWizardBase(handlers: { }) => setDiscordGuildChannelAllowlist(cfg, accountId, resolved as never), }), allowFrom: createAccountScopedAllowFromSection({ - channel, credentialInputKey: "token", helpTitle: "Discord allowlist", helpLines: [ diff --git a/extensions/discord/src/setup-runtime-helpers.ts b/extensions/discord/src/setup-runtime-helpers.ts new file mode 100644 index 00000000000..0d5cfff68a4 --- /dev/null +++ b/extensions/discord/src/setup-runtime-helpers.ts @@ -0,0 +1,436 @@ +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { + ChannelSetupDmPolicy, + ChannelSetupWizard, + WizardPrompter, +} from "openclaw/plugin-sdk/setup-runtime"; +import { + resolveDefaultDiscordSetupAccountId, + resolveDiscordSetupAccountConfig, +} from "./setup-account-state.js"; + +export function parseMentionOrPrefixedId(params: { + value: string; + mentionPattern: RegExp; + prefixPattern?: RegExp; + idPattern: RegExp; + normalizeId?: (id: string) => string; +}): string | null { + const trimmed = params.value.trim(); + if (!trimmed) { + return null; + } + const mentionMatch = trimmed.match(params.mentionPattern); + if (mentionMatch?.[1]) { + return params.normalizeId ? params.normalizeId(mentionMatch[1]) : mentionMatch[1]; + } + if (params.prefixPattern?.test(trimmed)) { + const stripped = trimmed.replace(params.prefixPattern, "").trim(); + if (!stripped || !params.idPattern.test(stripped)) { + return null; + } + return params.normalizeId ? params.normalizeId(stripped) : stripped; + } + if (!params.idPattern.test(trimmed)) { + return null; + } + return params.normalizeId ? params.normalizeId(trimmed) : trimmed; +} + +function splitSetupEntries(raw: string): string[] { + return raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function mergeAllowFromEntries( + current: Array | null | undefined, + additions: Array, +): string[] { + const merged = [...(current ?? []), ...additions] + .map((value) => String(value).trim()) + .filter(Boolean); + return [...new Set(merged)]; +} + +function patchDiscordChannelConfigForAccount(params: { + cfg: OpenClawConfig; + accountId: string; + patch: Record; +}): OpenClawConfig { + const accountId = normalizeAccountId(params.accountId); + const channelConfig = (params.cfg.channels?.discord as Record | undefined) ?? {}; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + discord: { + ...channelConfig, + ...params.patch, + enabled: true, + }, + }, + }; + } + const accounts = + (channelConfig.accounts as Record> | undefined) ?? {}; + const accountConfig = accounts[accountId] ?? {}; + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + discord: { + ...channelConfig, + enabled: true, + accounts: { + ...accounts, + [accountId]: { + ...accountConfig, + ...params.patch, + enabled: true, + }, + }, + }, + }, + }; +} + +export function setSetupChannelEnabled( + cfg: OpenClawConfig, + channel: string, + enabled: boolean, +): OpenClawConfig { + const channelConfig = (cfg.channels?.[channel] as Record | undefined) ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + [channel]: { + ...channelConfig, + enabled, + }, + }, + }; +} + +export function patchChannelConfigForAccount(params: { + cfg: OpenClawConfig; + channel: "discord"; + accountId: string; + patch: Record; +}): OpenClawConfig { + return patchDiscordChannelConfigForAccount({ + cfg: params.cfg, + accountId: params.accountId, + patch: params.patch, + }); +} + +export function createLegacyCompatChannelDmPolicy(params: { + label: string; + channel: "discord"; + promptAllowFrom?: ChannelSetupDmPolicy["promptAllowFrom"]; +}): ChannelSetupDmPolicy { + return { + label: params.label, + channel: params.channel, + policyKey: `channels.${params.channel}.dmPolicy`, + allowFromKey: `channels.${params.channel}.allowFrom`, + getCurrent: (cfg) => + ( + cfg.channels?.[params.channel] as + | { + dmPolicy?: "open" | "pairing" | "allowlist"; + dm?: { policy?: "open" | "pairing" | "allowlist" }; + } + | undefined + )?.dmPolicy ?? + ( + cfg.channels?.[params.channel] as + | { + dmPolicy?: "open" | "pairing" | "allowlist"; + dm?: { policy?: "open" | "pairing" | "allowlist" }; + } + | undefined + )?.dm?.policy ?? + "pairing", + setPolicy: (cfg, policy) => + patchDiscordChannelConfigForAccount({ + cfg, + accountId: DEFAULT_ACCOUNT_ID, + patch: { + dmPolicy: policy, + ...(policy === "open" + ? { + allowFrom: [ + ...new Set( + [ + ...((( + cfg.channels?.discord as { allowFrom?: Array } | undefined + )?.allowFrom ?? []) as Array), + "*", + ] + .map((value) => String(value).trim()) + .filter(Boolean), + ), + ], + } + : {}), + }, + }), + ...(params.promptAllowFrom ? { promptAllowFrom: params.promptAllowFrom } : {}), + }; +} + +async function noteChannelLookupFailure(params: { + prompter: Pick; + label: string; + error: unknown; +}) { + await params.prompter.note( + `Channel lookup failed; keeping entries as typed. ${String(params.error)}`, + params.label, + ); +} + +export function createAccountScopedAllowFromSection(params: { + credentialInputKey?: NonNullable["credentialInputKey"]; + helpTitle?: string; + helpLines?: string[]; + message: string; + placeholder: string; + invalidWithoutCredentialNote: string; + parseId: NonNullable["parseId"]>; + resolveEntries: NonNullable["resolveEntries"]>; +}): NonNullable { + return { + ...(params.helpTitle ? { helpTitle: params.helpTitle } : {}), + ...(params.helpLines ? { helpLines: params.helpLines } : {}), + ...(params.credentialInputKey ? { credentialInputKey: params.credentialInputKey } : {}), + message: params.message, + placeholder: params.placeholder, + invalidWithoutCredentialNote: params.invalidWithoutCredentialNote, + parseId: params.parseId, + resolveEntries: params.resolveEntries, + apply: ({ cfg, accountId, allowFrom }) => + patchDiscordChannelConfigForAccount({ + cfg, + accountId, + patch: { dmPolicy: "allowlist", allowFrom }, + }), + }; +} + +export function createAccountScopedGroupAccessSection(params: { + label: string; + placeholder: string; + helpTitle?: string; + helpLines?: string[]; + skipAllowlistEntries?: boolean; + currentPolicy: NonNullable["currentPolicy"]; + currentEntries: NonNullable["currentEntries"]; + updatePrompt: NonNullable["updatePrompt"]; + resolveAllowlist?: NonNullable< + NonNullable["resolveAllowlist"] + >; + fallbackResolved: (entries: string[]) => TResolved; + applyAllowlist: (params: { + cfg: OpenClawConfig; + accountId: string; + resolved: TResolved; + }) => OpenClawConfig; +}): NonNullable { + return { + label: params.label, + placeholder: params.placeholder, + ...(params.helpTitle ? { helpTitle: params.helpTitle } : {}), + ...(params.helpLines ? { helpLines: params.helpLines } : {}), + ...(params.skipAllowlistEntries ? { skipAllowlistEntries: true } : {}), + currentPolicy: params.currentPolicy, + currentEntries: params.currentEntries, + updatePrompt: params.updatePrompt, + setPolicy: ({ cfg, accountId, policy }) => + patchDiscordChannelConfigForAccount({ + cfg, + accountId, + patch: { groupPolicy: policy }, + }), + ...(params.resolveAllowlist + ? { + resolveAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { + try { + return await params.resolveAllowlist!({ + cfg, + accountId, + credentialValues, + entries, + prompter, + }); + } catch (error) { + await noteChannelLookupFailure({ + prompter, + label: params.label, + error, + }); + return params.fallbackResolved(entries); + } + }, + } + : {}), + applyAllowlist: ({ cfg, accountId, resolved }) => + params.applyAllowlist({ + cfg, + accountId, + resolved: resolved as TResolved, + }), + }; +} + +export function createAllowlistSetupWizardProxy(params: { + loadWizard: () => Promise; + createBase: (handlers: { + promptAllowFrom: NonNullable; + resolveAllowFromEntries: NonNullable< + NonNullable["resolveEntries"] + >; + resolveGroupAllowlist: NonNullable< + NonNullable["resolveAllowlist"]> + >; + }) => ChannelSetupWizard; + fallbackResolvedGroupAllowlist: (entries: string[]) => TGroupResolved; +}) { + return params.createBase({ + promptAllowFrom: async ({ cfg, prompter, accountId }) => { + const wizard = await params.loadWizard(); + if (!wizard.dmPolicy?.promptAllowFrom) { + return cfg; + } + return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); + }, + resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) => { + const wizard = await params.loadWizard(); + if (!wizard.allowFrom) { + return entries.map((input) => ({ input, resolved: false, id: null })); + } + return await wizard.allowFrom.resolveEntries({ + cfg, + accountId, + credentialValues, + entries, + }); + }, + resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { + const wizard = await params.loadWizard(); + if (!wizard.groupAccess?.resolveAllowlist) { + return params.fallbackResolvedGroupAllowlist(entries) as Awaited< + ReturnType< + NonNullable["resolveAllowlist"]> + > + >; + } + return (await wizard.groupAccess.resolveAllowlist({ + cfg, + accountId, + credentialValues, + entries, + prompter, + })) as Awaited< + ReturnType["resolveAllowlist"]>> + >; + }, + }); +} + +export async function resolveEntriesWithOptionalToken(params: { + token?: string | null; + entries: string[]; + buildWithoutToken: (input: string) => TResult; + resolveEntries: (params: { token: string; entries: string[] }) => Promise; +}): Promise { + const token = params.token?.trim(); + if (!token) { + return params.entries.map(params.buildWithoutToken); + } + return await params.resolveEntries({ + token, + entries: params.entries, + }); +} + +export async function promptLegacyChannelAllowFromForAccount(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; + noteTitle: string; + noteLines: string[]; + message: string; + placeholder: string; + parseId: (value: string) => string | null; + invalidWithoutTokenNote: string; + resolveEntries: (params: { + token: string; + entries: string[]; + }) => Promise>; + resolveToken: (accountId: string) => string | null | undefined; + resolveExisting: (accountId: string, cfg: OpenClawConfig) => Array; +}): Promise { + const accountId = normalizeAccountId( + params.accountId ?? resolveDefaultDiscordSetupAccountId(params.cfg), + ); + await params.prompter.note(params.noteLines.join("\n"), params.noteTitle); + const token = params.resolveToken(accountId); + const existing = params.resolveExisting(accountId, params.cfg); + + while (true) { + const entry = await params.prompter.text({ + message: params.message, + placeholder: params.placeholder, + initialValue: existing[0] ? String(existing[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + const parts = splitSetupEntries(String(entry)); + if (!token) { + const ids = parts.map(params.parseId).filter(Boolean) as string[]; + if (ids.length !== parts.length) { + await params.prompter.note(params.invalidWithoutTokenNote, params.noteTitle); + continue; + } + return patchDiscordChannelConfigForAccount({ + cfg: params.cfg, + accountId, + patch: { + dmPolicy: "allowlist", + allowFrom: mergeAllowFromEntries(existing, ids), + }, + }); + } + + const results = await params.resolveEntries({ token, entries: parts }).catch(() => null); + if (!results) { + await params.prompter.note("Failed to resolve usernames. Try again.", params.noteTitle); + continue; + } + const unresolved = results.filter((result) => !result.resolved || !result.id); + if (unresolved.length > 0) { + await params.prompter.note( + `Could not resolve: ${unresolved.map((result) => result.input).join(", ")}`, + params.noteTitle, + ); + continue; + } + return patchDiscordChannelConfigForAccount({ + cfg: params.cfg, + accountId, + patch: { + dmPolicy: "allowlist", + allowFrom: mergeAllowFromEntries( + existing, + results.map((result) => result.id as string), + ), + }, + }); + } +} diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index fae95d56916..a970ff5773b 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -1,20 +1,26 @@ import { - resolveEntriesWithOptionalToken, type OpenClawConfig, - promptLegacyChannelAllowFromForAccount, type WizardPrompter, -} from "openclaw/plugin-sdk/setup"; -import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; + type ChannelSetupWizard, +} from "openclaw/plugin-sdk/setup-runtime"; import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; -import { resolveDefaultDiscordAccountId, resolveDiscordAccount } from "./accounts.js"; import { resolveDiscordChannelAllowlist } from "./resolve-channels.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; +import { + resolveDefaultDiscordSetupAccountId, + resolveDiscordSetupAccountConfig, +} from "./setup-account-state.js"; import { createDiscordSetupWizardBase, DISCORD_TOKEN_HELP_LINES, parseDiscordAllowFromId, setDiscordGuildChannelAllowlist, } from "./setup-core.js"; +import { + promptLegacyChannelAllowFromForAccount, + resolveEntriesWithOptionalToken, +} from "./setup-runtime-helpers.js"; +import { resolveDiscordToken } from "./token.js"; const channel = "discord" as const; @@ -48,13 +54,8 @@ async function promptDiscordAllowFrom(params: { }): Promise { return await promptLegacyChannelAllowFromForAccount({ cfg: params.cfg, - channel, prompter: params.prompter, 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).", @@ -69,6 +70,11 @@ async function promptDiscordAllowFrom(params: { placeholder: "@alice, 123456789012345678", parseId: parseDiscordAllowFromId, invalidWithoutTokenNote: "Bot token missing; use numeric user ids (or mention form) only.", + resolveExisting: (accountId, cfg) => { + const account = resolveDiscordSetupAccountConfig({ cfg, accountId }).config; + return account.allowFrom ?? account.dm?.allowFrom ?? []; + }, + resolveToken: (accountId) => resolveDiscordToken(params.cfg, { accountId }).token, resolveEntries: async ({ token, entries }) => ( await resolveDiscordUserAllowlist({ @@ -91,7 +97,7 @@ async function resolveDiscordGroupAllowlist(params: { }) { return await resolveEntriesWithOptionalToken({ token: - resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }).token || + resolveDiscordToken(params.cfg, { accountId: params.accountId }).token || (typeof params.credentialValues.token === "string" ? params.credentialValues.token : ""), entries: params.entries, buildWithoutToken: (input) => ({ @@ -111,7 +117,7 @@ export const discordSetupWizard: ChannelSetupWizard = createDiscordSetupWizardBa resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) => await resolveDiscordAllowFromEntries({ token: - resolveDiscordAccount({ cfg, accountId }).token || + resolveDiscordToken(cfg, { accountId }).token || (typeof credentialValues.token === "string" ? credentialValues.token : ""), entries, }), diff --git a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts index d8a0432627b..45c7484d3ca 100644 --- a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts @@ -100,7 +100,6 @@ function createHandlerHarness() { mediaMaxBytes: 5 * 1024 * 1024, startupMs: Date.now() - 120_000, startupGraceMs: 60_000, - dropPreStartupMessages: false, directTracker: { isDirectMessage: vi.fn().mockResolvedValue(true), }, diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index a02a0a825fb..538de6c9a80 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -590,7 +590,6 @@ describe("matrix monitor handler pairing account scope", () => { mediaMaxBytes: 10_000_000, startupMs: 0, startupGraceMs: 0, - dropPreStartupMessages: false, directTracker: { isDirectMessage: async () => false, }, diff --git a/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts b/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts index c3d42d87d11..51f5a07bdd0 100644 --- a/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts @@ -115,7 +115,6 @@ describe("createMatrixRoomMessageHandler thread root media", () => { mediaMaxBytes: 5 * 1024 * 1024, startupMs: Date.now() - 120_000, startupGraceMs: 60_000, - dropPreStartupMessages: false, directTracker: { isDirectMessage: vi.fn().mockResolvedValue(true), }, diff --git a/package.json b/package.json index 7d32a97fee5..e7142b76a54 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,14 @@ "types": "./dist/plugin-sdk/setup.d.ts", "default": "./dist/plugin-sdk/setup.js" }, + "./plugin-sdk/setup-adapter-runtime": { + "types": "./dist/plugin-sdk/setup-adapter-runtime.d.ts", + "default": "./dist/plugin-sdk/setup-adapter-runtime.js" + }, + "./plugin-sdk/setup-runtime": { + "types": "./dist/plugin-sdk/setup-runtime.d.ts", + "default": "./dist/plugin-sdk/setup-runtime.js" + }, "./plugin-sdk/channel-setup": { "types": "./dist/plugin-sdk/channel-setup.d.ts", "default": "./dist/plugin-sdk/channel-setup.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 403f9523f1d..f9c20590e4b 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -9,6 +9,8 @@ "runtime", "runtime-env", "setup", + "setup-adapter-runtime", + "setup-runtime", "channel-setup", "setup-tools", "config-runtime", diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index ad6ad0391c0..c464b7d1b19 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -1,9 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as acpSessionManager from "../acp/control-plane/manager.js"; -import type { - AcpCloseSessionInput, - AcpInitializeSessionInput, -} from "../acp/control-plane/manager.types.js"; +import type { AcpInitializeSessionInput } from "../acp/control-plane/manager.types.js"; import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot, diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index 3068f790053..cc2b8b7f34a 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -1,4 +1,4 @@ -import fs from "node:fs/promises"; +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { expect, vi } from "vitest"; @@ -7,7 +7,11 @@ import { createThreadBindingManager as createDiscordThreadBindingManager, } from "../../../../extensions/discord/runtime-api.js"; import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/api.js"; -import { createMatrixThreadBindingManager } from "../../../../extensions/matrix/api.js"; +import { + createMatrixThreadBindingManager, + resetMatrixThreadBindingsForTests, +} from "../../../../extensions/matrix/api.js"; +import { setMatrixRuntime } from "../../../../extensions/matrix/index.js"; import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/runtime-api.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { @@ -181,6 +185,12 @@ function expectClearedSessionBinding(params: { const telegramDescribeMessageToolMock = vi.fn(); const discordDescribeMessageToolMock = vi.fn(); +const sendMessageMatrixMock = vi.hoisted(() => + vi.fn(async (to: string, _message: string, opts?: { threadId?: string }) => ({ + messageId: opts?.threadId ? "$matrix-thread" : "$matrix-root", + roomId: to.replace(/^room:/, ""), + })), +); bundledChannelRuntimeSetters.setTelegramRuntime({ channel: { @@ -213,6 +223,48 @@ bundledChannelRuntimeSetters.setLineRuntime({ }, } as never); +vi.mock("../../../../extensions/matrix/src/matrix/send.js", async () => { + const actual = await vi.importActual< + typeof import("../../../../extensions/matrix/src/matrix/send.js") + >("../../../../extensions/matrix/src/matrix/send.js"); + return { + ...actual, + sendMessageMatrix: sendMessageMatrixMock, + }; +}); + +const matrixSessionBindingStateDir = fs.mkdtempSync( + path.join(os.tmpdir(), "openclaw-matrix-session-binding-contract-"), +); +const matrixSessionBindingAuth = { + accountId: "ops", + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", +} as const; + +function resetMatrixSessionBindingStateDir() { + fs.rmSync(matrixSessionBindingStateDir, { recursive: true, force: true }); + fs.mkdirSync(matrixSessionBindingStateDir, { recursive: true }); +} + +async function createContractMatrixThreadBindingManager() { + resetMatrixSessionBindingStateDir(); + setMatrixRuntime({ + state: { + resolveStateDir: () => matrixSessionBindingStateDir, + }, + } as never); + return await createMatrixThreadBindingManager({ + accountId: matrixSessionBindingAuth.accountId, + auth: matrixSessionBindingAuth, + client: {} as never, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + enableSweeper: false, + }); +} + export const pluginContractRegistry: PluginContractEntry[] = bundledChannelPlugins.map( (plugin) => ({ id: plugin.id, @@ -595,24 +647,6 @@ const baseSessionBindingCfg = { session: { mainKey: "main", scope: "per-sender" }, } satisfies OpenClawConfig; -async function createContractMatrixThreadBindingManager() { - const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "matrix-contract-thread-bindings-")); - return await createMatrixThreadBindingManager({ - accountId: "ops", - auth: { - accountId: "ops", - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "token", - }, - client: {} as never, - stateDir, - idleTimeoutMs: 24 * 60 * 60 * 1000, - maxAgeMs: 0, - enableSweeper: false, - }); -} - export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [ { id: "discord", @@ -744,47 +778,43 @@ export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [ await createContractMatrixThreadBindingManager(); return getSessionBindingService().getCapabilities({ channel: "matrix", - accountId: "ops", + accountId: matrixSessionBindingAuth.accountId, }); }, bindAndResolve: async () => { await createContractMatrixThreadBindingManager(); const service = getSessionBindingService(); const binding = await service.bind({ - targetSessionKey: "agent:matrix:subagent:child-1", + targetSessionKey: "agent:matrix:child:thread-1", targetKind: "subagent", conversation: { channel: "matrix", - accountId: "ops", - conversationId: "!room:example", + accountId: matrixSessionBindingAuth.accountId, + conversationId: "$thread", + parentConversationId: "!room:example", }, - placement: "child", + placement: "current", metadata: { label: "codex-matrix", - introText: "intro root", }, }); expectResolvedSessionBinding({ channel: "matrix", - accountId: "ops", - conversationId: "$root", - parentConversationId: "!room:example", - targetSessionKey: "agent:matrix:subagent:child-1", + accountId: matrixSessionBindingAuth.accountId, + conversationId: "$thread", + targetSessionKey: "agent:matrix:child:thread-1", }); return binding; }, unbindAndVerify: unbindAndExpectClearedSessionBinding, cleanup: async () => { - const manager = await createContractMatrixThreadBindingManager(); - manager.stop(); - expect( - getSessionBindingService().resolveByConversation({ - channel: "matrix", - accountId: "ops", - conversationId: "$root", - parentConversationId: "!room:example", - }), - ).toBeNull(); + resetMatrixThreadBindingsForTests(); + resetMatrixSessionBindingStateDir(); + expectClearedSessionBinding({ + channel: "matrix", + accountId: matrixSessionBindingAuth.accountId, + conversationId: "$thread", + }); }, }, { diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts index 7c9803ee47f..2224b13fbb7 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -485,7 +485,7 @@ export function installSessionBindingContractSuite(params: { expectedCapabilities: SessionBindingCapabilities; }) { it("registers the expected session binding capabilities", async () => { - expect(await params.getCapabilities()).toEqual(params.expectedCapabilities); + expect(await Promise.resolve(params.getCapabilities())).toEqual(params.expectedCapabilities); }); it("binds and resolves a session binding through the shared service", async () => { diff --git a/src/channels/plugins/setup-wizard-helpers.ts b/src/channels/plugins/setup-wizard-helpers.ts index 23299816f5e..0a0cd291684 100644 --- a/src/channels/plugins/setup-wizard-helpers.ts +++ b/src/channels/plugins/setup-wizard-helpers.ts @@ -1,10 +1,6 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { DmPolicy, GroupPolicy } from "../../config/types.js"; import type { SecretInput } from "../../config/types.secrets.js"; -import { - promptSecretRefForSetup, - resolveSecretInputModeForEnvSelection, -} from "../../plugins/provider-auth-input.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; import { @@ -18,6 +14,15 @@ import type { } from "./setup-wizard-types.js"; import type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry } from "./setup-wizard.js"; +let providerAuthInputPromise: + | Promise + | undefined; + +function loadProviderAuthInput() { + providerAuthInputPromise ??= import("../../plugins/provider-auth-input.js"); + return providerAuthInputPromise; +} + export const promptAccountId: PromptAccountId = async (params: PromptAccountIdParams) => { const existingIds = params.listAccountIds(params.cfg); const initial = params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID; @@ -994,6 +999,8 @@ export async function promptSingleChannelSecretInput(params: { inputPrompt: string; preferredEnvVar?: string; }): Promise { + const { promptSecretRefForSetup, resolveSecretInputModeForEnvSelection } = + await loadProviderAuthInput(); const selectedMode = await resolveSecretInputModeForEnvSelection({ prompter: params.prompter as WizardPrompter, explicitMode: params.secretInputMode, diff --git a/src/cli/command-secret-gateway.ts b/src/cli/command-secret-gateway.ts index 4e0c4d0c49a..bb428e8dc14 100644 --- a/src/cli/command-secret-gateway.ts +++ b/src/cli/command-secret-gateway.ts @@ -314,6 +314,12 @@ function isUnsupportedSecretsResolveError(err: unknown): boolean { ); } +function isDirectRuntimeWebTargetPath(path: string): boolean { + return ( + path === "tools.web.fetch.firecrawl.apiKey" || /^tools\.web\.search\.[^.]+\.apiKey$/.test(path) + ); +} + async function resolveCommandSecretRefsLocally(params: { config: OpenClawConfig; commandName: string; @@ -329,12 +335,22 @@ async function resolveCommandSecretRefsLocally(params: { env: process.env, }); const localResolutionDiagnostics: string[] = []; + const discoveredTargets = discoverConfigSecretTargetsByIds(sourceConfig, params.targetIds).filter( + (target) => !params.allowedPaths || params.allowedPaths.has(target.path), + ); + const runtimeWebTargets = discoveredTargets.filter((target) => + targetsRuntimeWebPath(target.path), + ); collectConfigAssignments({ config: structuredClone(params.config), context, }); if ( - targetsRuntimeWebResolution({ targetIds: params.targetIds, allowedPaths: params.allowedPaths }) + targetsRuntimeWebResolution({ + targetIds: params.targetIds, + allowedPaths: params.allowedPaths, + }) && + !runtimeWebTargets.every((target) => isDirectRuntimeWebTargetPath(target.path)) ) { try { await resolveRuntimeWebTools({ @@ -359,13 +375,7 @@ async function resolveCommandSecretRefsLocally(params: { ); const runtimeWebActivePaths = new Set(); const runtimeWebInactiveDiagnostics: string[] = []; - for (const target of discoverConfigSecretTargetsByIds(sourceConfig, params.targetIds)) { - if (!targetsRuntimeWebPath(target.path)) { - continue; - } - if (params.allowedPaths && !params.allowedPaths.has(target.path)) { - continue; - } + for (const target of runtimeWebTargets) { const runtimeState = classifyRuntimeWebTargetPathState({ config: sourceConfig, path: target.path, @@ -390,10 +400,7 @@ async function resolveCommandSecretRefsLocally(params: { .filter((warning) => !params.allowedPaths || params.allowedPaths.has(warning.path)) .map((warning) => warning.message); const activePaths = new Set(context.assignments.map((assignment) => assignment.path)); - for (const target of discoverConfigSecretTargetsByIds(sourceConfig, params.targetIds)) { - if (params.allowedPaths && !params.allowedPaths.has(target.path)) { - continue; - } + for (const target of discoveredTargets) { await resolveTargetSecretLocally({ target, sourceConfig, diff --git a/src/infra/outbound/message-action-runner.context.test.ts b/src/infra/outbound/message-action-runner.context.test.ts index 36dccd0534f..0819c2cfae1 100644 --- a/src/infra/outbound/message-action-runner.context.test.ts +++ b/src/infra/outbound/message-action-runner.context.test.ts @@ -1,8 +1,17 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { ChannelPlugin } from "../../channels/plugins/types.js"; +import type { + ChannelDirectoryEntryKind, + ChannelMessagingAdapter, + ChannelOutboundAdapter, + ChannelPlugin, +} from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { + createChannelTestPluginBase, + createTestRegistry, +} from "../../test-utils/channel-plugins.js"; +import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; import { runMessageAction } from "./message-action-runner.js"; const slackConfig = { @@ -52,48 +61,112 @@ const runDrySend = (params: { action: "send", }); -const createDryRunPlugin = (id: "slack" | "whatsapp" | "telegram" | "imessage"): ChannelPlugin => { - const plugin = createOutboundTestPlugin({ - id, - outbound: {} as never, - }); +type ResolvedTestTarget = { to: string; kind: ChannelDirectoryEntryKind }; - const resolveTarget: NonNullable< - NonNullable["targetResolver"] - >["resolveTarget"] = async ({ input, normalized }) => { - if (id === "slack") { - const raw = input.replace(/^#/, ""); - return { to: `channel:${raw}`, kind: "group", source: "normalized" }; - } - if (id === "telegram") { - return { to: `group:${normalized || input}`, kind: "group", source: "normalized" }; - } - if (id === "whatsapp") { - return { to: `group:${normalized || input}`, kind: "group", source: "normalized" }; - } - return { to: normalized || input, kind: "user", source: "normalized" }; +const directOutbound: ChannelOutboundAdapter = { deliveryMode: "direct" }; + +function normalizeSlackTarget(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return trimmed; + } + if (trimmed.startsWith("#")) { + return trimmed.slice(1).trim(); + } + if (/^channel:/i.test(trimmed)) { + return trimmed.replace(/^channel:/i, "").trim(); + } + if (/^user:/i.test(trimmed)) { + return trimmed.replace(/^user:/i, "").trim(); + } + const mention = trimmed.match(/^<@([A-Z0-9]+)>$/i); + if (mention?.[1]) { + return mention[1]; + } + return trimmed; +} + +function createConfiguredTestPlugin(params: { + id: "slack" | "telegram" | "whatsapp"; + isConfigured: (cfg: OpenClawConfig) => boolean; + normalizeTarget: (raw: string) => string | undefined; + resolveTarget: (input: string) => ResolvedTestTarget | null; +}): ChannelPlugin { + const messaging: ChannelMessagingAdapter = { + normalizeTarget: params.normalizeTarget, + targetResolver: { + looksLikeId: (raw) => Boolean(params.resolveTarget(raw.trim())), + hint: "", + resolveTarget: async (resolverParams) => { + const resolved = params.resolveTarget(resolverParams.input); + return resolved ? { ...resolved, source: "normalized" } : null; + }, + }, + inferTargetChatType: (inferParams) => + params.resolveTarget(inferParams.to)?.kind === "user" ? "direct" : "group", }; - return { - ...plugin, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - messaging: { - inferTargetChatType: ({ to }) => { - if (id === "imessage" && to.startsWith("imessage:")) { - return "direct"; - } - return "group"; + ...createChannelTestPluginBase({ + id: params.id, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ enabled: true }), + isConfigured: (_account, cfg) => params.isConfigured(cfg), }, - targetResolver: { - looksLikeId: () => true, - resolveTarget, - }, - }, + }), + outbound: directOutbound, + messaging, }; -}; +} + +const slackTestPlugin = createConfiguredTestPlugin({ + id: "slack", + isConfigured: (cfg) => Boolean(cfg.channels?.slack?.botToken?.trim()), + normalizeTarget: (raw) => normalizeSlackTarget(raw) || undefined, + resolveTarget: (input) => { + const normalized = normalizeSlackTarget(input); + if (!normalized) { + return null; + } + if (/^[A-Z0-9]+$/i.test(normalized)) { + const kind = /^U/i.test(normalized) ? "user" : "group"; + return { to: normalized, kind }; + } + return null; + }, +}); + +const telegramTestPlugin = createConfiguredTestPlugin({ + id: "telegram", + isConfigured: (cfg) => Boolean(cfg.channels?.telegram?.botToken?.trim()), + normalizeTarget: (raw) => raw.trim() || undefined, + resolveTarget: (input) => { + const normalized = input.trim(); + if (!normalized) { + return null; + } + return { + to: normalized.replace(/^telegram:/i, ""), + kind: normalized.startsWith("@") ? "user" : "group", + }; + }, +}); + +const whatsappTestPlugin = createConfiguredTestPlugin({ + id: "whatsapp", + isConfigured: (cfg) => Boolean(cfg.channels?.whatsapp), + normalizeTarget: (raw) => raw.trim() || undefined, + resolveTarget: (input) => { + const normalized = input.trim(); + if (!normalized) { + return null; + } + return { + to: normalized, + kind: normalized.endsWith("@g.us") ? "group" : "user", + }; + }, +}); describe("runMessageAction context isolation", () => { beforeEach(() => { @@ -102,22 +175,22 @@ describe("runMessageAction context isolation", () => { { pluginId: "slack", source: "test", - plugin: createDryRunPlugin("slack"), + plugin: slackTestPlugin, }, { pluginId: "whatsapp", source: "test", - plugin: createDryRunPlugin("whatsapp"), + plugin: whatsappTestPlugin, }, { pluginId: "telegram", source: "test", - plugin: createDryRunPlugin("telegram"), + plugin: telegramTestPlugin, }, { pluginId: "imessage", source: "test", - plugin: createDryRunPlugin("imessage"), + plugin: createIMessageTestPlugin(), }, ]), ); diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index 08ba61de0f8..f5149e715ef 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -58,7 +58,6 @@ export const MESSAGE_ACTION_TARGET_MODE: Record