import { IconLoader2 } from "@tabler/icons-react" import { useAtomValue } from "jotai" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" import { type ChannelConfig, type SupportedChannel, getAppConfig, getChannelsCatalog, patchAppConfig, } from "@/api/channels" import { getChannelDisplayName } from "@/components/channels/channel-display-name" import { DiscordForm } from "@/components/channels/channel-forms/discord-form" import { FeishuForm } from "@/components/channels/channel-forms/feishu-form" import { GenericForm } from "@/components/channels/channel-forms/generic-form" import { SlackForm } from "@/components/channels/channel-forms/slack-form" import { TelegramForm } from "@/components/channels/channel-forms/telegram-form" import { WeixinForm } from "@/components/channels/channel-forms/weixin-form" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { gatewayAtom } from "@/store/gateway" interface ChannelConfigPageProps { channelName: string } const SECRET_FIELD_MAP: Record = { token: "_token", app_secret: "_app_secret", client_secret: "_client_secret", corp_secret: "_corp_secret", channel_secret: "_channel_secret", channel_access_token: "_channel_access_token", access_token: "_access_token", bot_token: "_bot_token", app_token: "_app_token", encoding_aes_key: "_encoding_aes_key", encrypt_key: "_encrypt_key", verification_token: "_verification_token", password: "_password", nickserv_password: "_nickserv_password", sasl_password: "_sasl_password", } function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record } return {} } function asString(value: unknown): string { return typeof value === "string" ? value : "" } function asBool(value: unknown): boolean { return value === true } function buildEditConfig(config: ChannelConfig): ChannelConfig { const edit: ChannelConfig = { ...config } for (const secretKey of Object.keys(SECRET_FIELD_MAP)) { if (secretKey in config) { edit[SECRET_FIELD_MAP[secretKey]] = "" } } return edit } function normalizeConfig( channel: SupportedChannel, rawConfig: ChannelConfig, ): ChannelConfig { const config = { ...rawConfig } if (channel.name === "whatsapp_native") { config.use_native = true } if (channel.name === "whatsapp") { config.use_native = false } return config } function buildSavePayload( channel: SupportedChannel, editConfig: ChannelConfig, enabled: boolean, ): ChannelConfig { const payload: ChannelConfig = { enabled } for (const [key, value] of Object.entries(editConfig)) { if (key.startsWith("_")) continue if (key === "enabled") continue if (key in SECRET_FIELD_MAP) { const editKey = SECRET_FIELD_MAP[key] const incoming = asString(editConfig[editKey]) payload[key] = incoming !== "" ? incoming : value continue } payload[key] = value } if (channel.name === "whatsapp_native") { payload.use_native = true } if (channel.name === "whatsapp") { payload.use_native = false } return payload } function isConfigured( channel: SupportedChannel, config: ChannelConfig, ): boolean { switch (channel.name) { case "telegram": return asString(config.token) !== "" case "discord": return asString(config.token) !== "" case "slack": return asString(config.bot_token) !== "" case "feishu": return ( asString(config.app_id) !== "" && asString(config.app_secret) !== "" ) case "dingtalk": return ( asString(config.client_id) !== "" && asString(config.client_secret) !== "" ) case "line": return asString(config.channel_access_token) !== "" case "qq": return ( asString(config.app_id) !== "" && asString(config.app_secret) !== "" ) case "onebot": return asString(config.ws_url) !== "" case "weixin": return asString(config.account_id) !== "" case "wecom": return asString(config.token) !== "" case "wecom_app": return ( asString(config.corp_id) !== "" && asString(config.corp_secret) !== "" ) case "wecom_aibot": return asString(config.token) !== "" case "whatsapp": return asString(config.bridge_url) !== "" case "whatsapp_native": return asBool(config.use_native) case "pico": return asString(config.token) !== "" case "maixcam": return asString(config.host) !== "" case "matrix": return ( asString(config.homeserver) !== "" && asString(config.user_id) !== "" && asString(config.access_token) !== "" ) case "irc": return asString(config.server) !== "" default: return false } } function getRequiredFieldKeys(channelName: string): string[] { switch (channelName) { case "telegram": return ["token"] case "discord": return ["token"] case "slack": return ["bot_token"] case "feishu": return ["app_id", "app_secret"] case "dingtalk": return ["client_id", "client_secret"] case "line": return ["channel_secret", "channel_access_token"] case "qq": return ["app_id", "app_secret"] case "onebot": return ["ws_url"] case "wecom": return ["token"] case "wecom_app": return ["corp_id", "corp_secret"] case "wecom_aibot": return ["token"] case "whatsapp": return ["bridge_url"] case "pico": return ["token"] case "maixcam": return ["host"] case "matrix": return ["homeserver", "user_id", "access_token"] case "irc": return ["server"] default: return [] } } function isMissingRequiredValue(value: unknown): boolean { if (value === null || value === undefined) { return true } if (typeof value === "string") { return value.trim() === "" } if (Array.isArray(value)) { return value.length === 0 } return false } function getChannelDocSlug(channelName: string): string { return channelName.replaceAll("_", "-") } const CHANNELS_WITHOUT_DOCS = new Set([ "pico", "wecom", "matrix", "irc", "whatsapp", "whatsapp_native", ]) export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { const { t, i18n } = useTranslation() const gateway = useAtomValue(gatewayAtom) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [fetchError, setFetchError] = useState("") const [serverError, setServerError] = useState("") const [fieldErrors, setFieldErrors] = useState>({}) const [channel, setChannel] = useState(null) const [baseConfig, setBaseConfig] = useState({}) const [editConfig, setEditConfig] = useState({}) const [enabled, setEnabled] = useState(false) const loadData = useCallback(async (silent = false) => { if (!silent) setLoading(true) try { const [catalog, appConfig] = await Promise.all([ getChannelsCatalog(), getAppConfig(), ]) const matched = catalog.channels.find((item) => item.name === channelName) ?? null if (!matched) { setChannel(null) setFetchError( t("channels.page.notFound", { name: channelName, }), ) return } const channelsConfig = asRecord(asRecord(appConfig).channels) const raw = asRecord(channelsConfig[matched.config_key]) const normalized = normalizeConfig(matched, raw) setChannel(matched) setBaseConfig(normalized) setEditConfig(buildEditConfig(normalized)) setEnabled(asBool(normalized.enabled)) setFetchError("") setServerError("") setFieldErrors({}) } catch (e) { setFetchError(e instanceof Error ? e.message : t("channels.loadError")) } finally { if (!silent) setLoading(false) } }, [channelName, t]) useEffect(() => { loadData() }, [loadData]) const previousGatewayStatusRef = useRef(gateway.status) useEffect(() => { const previousStatus = previousGatewayStatusRef.current if (previousStatus !== "running" && gateway.status === "running") { void loadData() } previousGatewayStatusRef.current = gateway.status }, [gateway.status, loadData]) const savePayload = useMemo(() => { if (!channel) return null return buildSavePayload(channel, editConfig, enabled) }, [channel, editConfig, enabled]) const configured = useMemo(() => { if (!channel || !savePayload) return false return isConfigured(channel, savePayload) }, [channel, savePayload]) const docsUrl = useMemo(() => { if (!channel) return "" if (CHANNELS_WITHOUT_DOCS.has(channel.name)) return "" const language = ( i18n.resolvedLanguage ?? i18n.language ?? "" ).toLowerCase() const base = language.startsWith("zh") ? "https://docs.picoclaw.io/zh-Hans/docs/channels" : "https://docs.picoclaw.io/docs/channels" return `${base}/${getChannelDocSlug(channel.name)}` }, [channel, i18n.language, i18n.resolvedLanguage]) const channelDisplayName = useMemo(() => { if (!channel) return channelName return getChannelDisplayName(channel, t) }, [channel, channelName, t]) const hiddenKeys = useMemo(() => { if (!channel) return [] if (channel.name === "whatsapp") { return ["use_native"] } if (channel.name === "whatsapp_native") { return ["use_native", "bridge_url"] } return [] }, [channel]) const requiredKeys = useMemo( () => getRequiredFieldKeys(channelName), [channelName], ) const handleChange = useCallback((key: string, value: unknown) => { const normalizedKey = key.startsWith("_") ? key.slice(1) : key setEditConfig((prev) => ({ ...prev, [key]: value })) setFieldErrors((prev) => { if (!(key in prev) && !(normalizedKey in prev)) { return prev } const next = { ...prev } delete next[key] delete next[normalizedKey] return next }) }, []) const handleReset = () => { setEditConfig(buildEditConfig(baseConfig)) setEnabled(asBool(baseConfig.enabled)) setServerError("") setFieldErrors({}) } const handleSave = async () => { if (!channel || !savePayload) return const missingRequiredFields = requiredKeys.filter((key) => isMissingRequiredValue(savePayload[key]), ) if (missingRequiredFields.length > 0) { const requiredFieldError = t("channels.validation.requiredField") const nextFieldErrors: Record = {} for (const key of missingRequiredFields) { nextFieldErrors[key] = requiredFieldError } setFieldErrors(nextFieldErrors) setServerError("") return } setSaving(true) setServerError("") setFieldErrors({}) try { await patchAppConfig({ channels: { [channel.config_key]: savePayload, }, }) toast.success(t("channels.page.saveSuccess")) await loadData() } catch (e) { const message = e instanceof Error ? e.message : t("channels.page.saveError") setServerError(message) toast.error(message) } finally { setSaving(false) } } const renderForm = () => { if (!channel) return null const isEdit = configured switch (channel.name) { case "telegram": return ( ) case "discord": return ( ) case "slack": return ( ) case "feishu": return ( ) case "weixin": return ( void loadData(true)} /> ) default: return ( ) } } return (
{enabled ? ( {t("channels.page.enabled")} ) : configured ? ( {t("channels.status.configured")} ) : null}
) : undefined } />
{loading ? (
) : fetchError ? (
{fetchError}
) : (

{t("channels.edit", { name: channelDisplayName, })}

{channel && docsUrl && ( {t("channels.page.docLink")} )}

{t("channels.page.enableLabel")}

{renderForm()} {serverError && (

{serverError}

)}
)}
) }