diff --git a/AGENTS.md b/AGENTS.md index d2121e9b..a025d6d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,6 +132,7 @@ Use these conventions for all UI work under `lib/public/js` and `lib/public/css` - Use the `htm` + `preact` pattern: - `const html = htm.bind(h);` - return `html\`...\`` +- In `htm` templates, be explicit with inline spacing around styled inline tags (``, ``, ``): use ` ${" "}` where needed, and verify rendered copy so words never collapse (`eventsand`) or gain double spaces. - Prefer early return for hidden states (e.g. `if (!visible) return null;`). - Use `` for tab/page headers that need a title and right-side actions. - Use card shells consistently: `bg-surface border border-border rounded-xl`. diff --git a/lib/public/assets/icons/slack.svg b/lib/public/assets/icons/slack.svg new file mode 100644 index 00000000..79fc764e --- /dev/null +++ b/lib/public/assets/icons/slack.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/lib/public/js/components/add-channel-menu.js b/lib/public/js/components/add-channel-menu.js new file mode 100644 index 00000000..44115dcd --- /dev/null +++ b/lib/public/js/components/add-channel-menu.js @@ -0,0 +1,59 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { ActionButton } from "./action-button.js"; +import { AddLineIcon } from "./icons.js"; +import { OverflowMenu, OverflowMenuItem } from "./overflow-menu.js"; + +const html = htm.bind(h); + +export const AddChannelMenu = ({ + open = false, + onClose = () => {}, + onToggle = () => {}, + triggerDisabled = false, + channelIds = [], + getChannelMeta = () => ({ label: "Channel", iconSrc: "" }), + isChannelDisabled = () => false, + onSelectChannel = () => {}, +}) => html` + <${OverflowMenu} + open=${open} + ariaLabel="Add channel" + title="Add channel" + onClose=${onClose} + onToggle=${onToggle} + renderTrigger=${({ onToggle: handleToggle, ariaLabel, title }) => html` + <${ActionButton} + onClick=${handleToggle} + disabled=${triggerDisabled} + loading=${false} + loadingMode="inline" + tone="subtle" + size="sm" + idleLabel="Add channel" + loadingLabel="Opening..." + idleIcon=${AddLineIcon} + idleIconClassName="h-3.5 w-3.5" + iconOnly=${true} + title=${title} + ariaLabel=${ariaLabel} + /> + `} + > + ${channelIds.map((channelId) => { + const channelMeta = getChannelMeta(channelId); + const disabled = !!isChannelDisabled(channelId); + return html` + <${OverflowMenuItem} + key=${channelId} + iconSrc=${channelMeta.iconSrc} + disabled=${disabled} + onClick=${() => onSelectChannel(channelId)} + > + ${channelMeta.label} + + `; + })} + +`; + diff --git a/lib/public/js/components/agents-tab/agent-bindings-section/index.js b/lib/public/js/components/agents-tab/agent-bindings-section/index.js index d028bce5..525dc216 100644 --- a/lib/public/js/components/agents-tab/agent-bindings-section/index.js +++ b/lib/public/js/components/agents-tab/agent-bindings-section/index.js @@ -1,10 +1,11 @@ import { h } from "https://esm.sh/preact"; import htm from "https://esm.sh/htm"; +import { isChannelProviderDisabledForAdd } from "../../../lib/channel-provider-availability.js"; +import { AddChannelMenu } from "../../add-channel-menu.js"; import { ActionButton } from "../../action-button.js"; import { ALL_CHANNELS, ChannelsCard, getChannelMeta } from "../../channels.js"; import { ConfirmDialog } from "../../confirm-dialog.js"; import { AddLineIcon } from "../../icons.js"; -import { OverflowMenu, OverflowMenuItem } from "../../overflow-menu.js"; import { CreateChannelModal } from "../create-channel-modal.js"; import { ChannelCardItem } from "./channel-item-trailing.js"; import { useAgentBindings } from "./use-agent-bindings.js"; @@ -50,7 +51,7 @@ export const AgentBindingsSection = ({ setShowCreateModal, showCreateModal, } = useAgentBindings({ agent, agents }); - const { hasDiscordAccount, mergedChannelItems } = useChannelItems({ + const { mergedChannelItems } = useChannelItems({ agentId, agentNameMap, channelStatus, @@ -108,48 +109,23 @@ export const AgentBindingsSection = ({ />`; }} actions=${html` - <${OverflowMenu} + <${AddChannelMenu} open=${menuOpenId === "__create_channel"} - ariaLabel="Add channel" - title="Add channel" onClose=${() => setMenuOpenId("")} onToggle=${() => setMenuOpenId((current) => current === "__create_channel" ? "" : "__create_channel", )} - renderTrigger=${({ onToggle, ariaLabel, title }) => html` - <${ActionButton} - onClick=${onToggle} - disabled=${saving} - loading=${false} - loadingMode="inline" - tone="subtle" - size="sm" - loadingLabel="Opening..." - idleIcon=${AddLineIcon} - idleIconClassName="h-3.5 w-3.5" - iconOnly=${true} - title=${title} - ariaLabel=${ariaLabel} - idleLabel="Add channel" - /> - `} - > - ${ALL_CHANNELS.map((channelId) => { - const channelMeta = getChannelMeta(channelId); - const isDisabled = channelId === "discord" && hasDiscordAccount; - return html` - <${OverflowMenuItem} - key=${channelId} - iconSrc=${channelMeta.iconSrc} - disabled=${isDisabled} - onClick=${() => openCreateChannelModal(channelId)} - > - ${channelMeta.label} - - `; - })} - + triggerDisabled=${saving} + channelIds=${ALL_CHANNELS} + getChannelMeta=${getChannelMeta} + isChannelDisabled=${(channelId) => + isChannelProviderDisabledForAdd({ + configuredChannelMap, + provider: channelId, + })} + onSelectChannel=${openCreateChannelModal} + /> `} /> diff --git a/lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js b/lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js index dc87a54c..799ae5aa 100644 --- a/lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js +++ b/lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js @@ -20,11 +20,6 @@ export const useChannelItems = ({ defaultAgentId = "", isDefaultAgent = false, }) => { - const hasDiscordAccount = useMemo(() => { - const discordChannel = configuredChannelMap.get("discord"); - return Array.isArray(discordChannel?.accounts) && discordChannel.accounts.length > 0; - }, [configuredChannelMap]); - const [showAssignedElsewhere, setShowAssignedElsewhere] = useState(false); const channelItemData = useMemo(() => { @@ -205,7 +200,6 @@ export const useChannelItems = ({ }, [assignedElsewhereItems, showAssignedElsewhere, visibleChannelItems]); return { - hasDiscordAccount, mergedChannelItems, }; }; diff --git a/lib/public/js/components/agents-tab/create-channel-modal.js b/lib/public/js/components/agents-tab/create-channel-modal.js index 29eedf91..fd689787 100644 --- a/lib/public/js/components/agents-tab/create-channel-modal.js +++ b/lib/public/js/components/agents-tab/create-channel-modal.js @@ -14,8 +14,28 @@ const html = htm.bind(h); const kChannelEnvKeys = { telegram: "TELEGRAM_BOT_TOKEN", discord: "DISCORD_BOT_TOKEN", + slack: "SLACK_BOT_TOKEN", }; +const kChannelExtraEnvKeys = { + slack: "SLACK_APP_TOKEN", +}; +const kSlackBotScopes = [ + "app_mentions:read", + "channels:history", + "channels:read", + "chat:write", + "groups:history", + "im:history", + "im:read", + "im:write", + "mpim:history", + "reactions:read", + "reactions:write", + "users:read", +]; +const kSlackInstructionsLink = "https://docs.openclaw.ai/channels/slack"; + const slugifyChannelAccountId = (value) => String(value || "") .toLowerCase() @@ -50,6 +70,7 @@ export const CreateChannelModal = ({ const [name, setName] = useState(""); const [token, setToken] = useState(""); const [initialToken, setInitialToken] = useState(""); + const [appToken, setAppToken] = useState(""); const [agentId, setAgentId] = useState(""); const [error, setError] = useState(""); const [nameEditedManually, setNameEditedManually] = useState(false); @@ -66,22 +87,23 @@ export const CreateChannelModal = ({ const nextSelectedChannel = existingChannels.find( (entry) => - String(entry?.channel || "").trim() === String(nextProvider || "").trim(), + String(entry?.channel || "").trim() === + String(nextProvider || "").trim(), ) || null; const nextProviderHasAccounts = - Array.isArray(nextSelectedChannel?.accounts) - && nextSelectedChannel.accounts.length > 0; + Array.isArray(nextSelectedChannel?.accounts) && + nextSelectedChannel.accounts.length > 0; const nextName = isEditMode ? String(account?.name || "").trim() || providerLabel : nextProviderHasAccounts ? "" : providerLabel; const nextAgentId = isEditMode - ? String(account?.ownerAgentId || "").trim() - || String(initialAgentId || "").trim() - || String(agents[0]?.id || "").trim() - : String(initialAgentId || "").trim() - || String(agents[0]?.id || "").trim(); + ? String(account?.ownerAgentId || "").trim() || + String(initialAgentId || "").trim() || + String(agents[0]?.id || "").trim() + : String(initialAgentId || "").trim() || + String(agents[0]?.id || "").trim(); setProvider(nextProvider); setName(nextName); const nextToken = isEditMode @@ -92,6 +114,7 @@ export const CreateChannelModal = ({ : ""; setToken(nextToken); setInitialToken(nextToken); + setAppToken(""); setAgentId(nextAgentId); setError(""); setNameEditedManually(isEditMode); @@ -108,13 +131,16 @@ export const CreateChannelModal = ({ const selectedChannel = useMemo( () => existingChannels.find( - (entry) => String(entry?.channel || "").trim() === String(provider || "").trim(), + (entry) => + String(entry?.channel || "").trim() === String(provider || "").trim(), ) || null, [existingChannels, provider], ); const providerHasAccounts = useMemo( - () => Array.isArray(selectedChannel?.accounts) && selectedChannel.accounts.length > 0, + () => + Array.isArray(selectedChannel?.accounts) && + selectedChannel.accounts.length > 0, [selectedChannel], ); useEffect(() => { @@ -126,7 +152,10 @@ export const CreateChannelModal = ({ } setName(providerLabel); }, [provider, providerHasAccounts, nameEditedManually, isEditMode]); - const isSingleAccountProvider = String(provider || "").trim() === "discord"; + const isSingleAccountProvider = + String(provider || "").trim() === "discord" || + String(provider || "").trim() === "slack"; + const needsAppToken = String(provider || "").trim() === "slack"; const accountId = useMemo(() => { if (isEditMode) { @@ -144,9 +173,10 @@ export const CreateChannelModal = ({ const accountExists = useMemo( () => - Array.isArray(selectedChannel?.accounts) - && selectedChannel.accounts.some( - (entry) => String(entry?.id || "").trim() === String(accountId || "").trim(), + Array.isArray(selectedChannel?.accounts) && + selectedChannel.accounts.some( + (entry) => + String(entry?.id || "").trim() === String(accountId || "").trim(), ), [selectedChannel, accountId], ); @@ -162,8 +192,10 @@ export const CreateChannelModal = ({ }); if (cancelled) return; const nextToken = String(result?.token || ""); + const nextAppToken = String(result?.appToken || ""); setToken(nextToken); setInitialToken(nextToken); + setAppToken(nextAppToken); } catch { // Keep existing fallback value. } finally { @@ -179,13 +211,14 @@ export const CreateChannelModal = ({ }, [visible, isEditMode, provider, accountId]); const canSubmit = - !!String(provider || "").trim() - && !!String(name || "").trim() - && !!String(accountId || "").trim() - && !!String(agentId || "").trim() - && (isEditMode || !!String(token || "").trim()) - && (isEditMode || !accountExists) - && !loadingToken; + !!String(provider || "").trim() && + !!String(name || "").trim() && + !!String(accountId || "").trim() && + !!String(agentId || "").trim() && + (isEditMode || !!String(token || "").trim()) && + (isEditMode || !needsAppToken || !!String(appToken || "").trim()) && + (isEditMode || !accountExists) && + !loadingToken; if (!visible) return null; @@ -202,6 +235,10 @@ export const CreateChannelModal = ({ setError("Token is required"); return; } + if (!isEditMode && needsAppToken && !String(appToken || "").trim()) { + setError("App Token is required for Slack"); + return; + } if (!String(agentId || "").trim()) { setError("Agent is required"); return; @@ -213,13 +250,18 @@ export const CreateChannelModal = ({ setError(""); const trimmedToken = String(token || "").trim(); - const tokenWasUpdated = trimmedToken && trimmedToken !== String(initialToken || "").trim(); + const tokenWasUpdated = + trimmedToken && trimmedToken !== String(initialToken || "").trim(); + const trimmedAppToken = String(appToken || "").trim(); await onSubmit({ provider, name: String(name || "").trim(), accountId, agentId, ...(tokenWasUpdated ? { token: trimmedToken } : {}), + ...(needsAppToken && trimmedAppToken + ? { appToken: trimmedAppToken } + : {}), }); }; @@ -230,9 +272,11 @@ export const CreateChannelModal = ({ panelClassName="bg-modal border border-border rounded-xl p-6 max-w-lg w-full space-y-4" > <${PageHeader} - title=${isEditMode - ? "Edit Channel" - : `Add ${getChannelMeta(provider).label || "Channel"} Channel`} + title=${ + isEditMode + ? "Edit Channel" + : `Add ${getChannelMeta(provider).label || "Channel"} Channel` + } actions=${html`