// Modified by the independent AgentDesk fork for support reliability and availability. "use client" import { useEffect, useMemo, useState, useSyncExternalStore } from "react" import { zodResolver } from "@hookform/resolvers/zod" import { Controller, Resolver, useForm, useWatch } from "react-hook-form" import { z } from "zod/v4" import { CopyIcon, ExternalLinkIcon, RotateCcwIcon } from "lucide-react" import { toast } from "sonner" import { TestEmailConnectionButton } from "@/app/(dashboard)/dashboard/channels/_components/test-email-connection" import { getWidgetDemoPath } from "@/components/support-chat/demo-navigation" import { OptionCombobox } from "@/components/option-combobox" import { ProjectDialog } from "@/components/project-dialog" import { Button } from "@/components/ui/button" import { Field, FieldContent, FieldError, FieldLabel, } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { type AIAgent, type AdminChannel, type CreateAdminChannelPayload, fetchAIAgentsAll, fetchChannel, rollbackChannelAIAgentRollout, resetChannelUserTokenSecret, } from "@/lib/api/admin" import { useI18n } from "@/i18n/provider" type ChannelFormDialogProps = { open: boolean saving: boolean itemId: number | null onOpenChange: (open: boolean) => void onSubmit: (payload: CreateAdminChannelPayload) => Promise } type Translate = (key: string, values?: Record) => string type WebChannelConfig = { title?: string subtitle?: string themeColor?: string position?: "left" | "right" width?: string userTokenSecret?: string } type TelegramChannelConfig = { botToken?: string botUsername?: string webhookSecret?: string } type ZaloOAChannelConfig = { appId?: string oaId?: string secretKey?: string accessToken?: string refreshToken?: string webhookSecret?: string } type EmailChannelConfig = { emailAddress?: string senderName?: string provider?: string apiKey?: string smtpHost?: string smtpPort?: number smtpUser?: string smtpPassword?: string smtpUseTls?: boolean smtpAllowInsecure?: boolean webhookSecret?: string } type SlackChannelConfig = { botToken?: string signingSecret?: string appId?: string teamId?: string teamName?: string defaultChannel?: string webhookSecret?: string } type DiscordChannelConfig = { guildId?: string guildName?: string botToken?: string webhookSecret?: string } function getDefaultWebChannelConfig(t: Translate): Required { return { title: t("channel.defaultTitleWeb"), subtitle: t("channel.defaultSubtitle"), themeColor: "#2563eb", position: "right", width: "380px", userTokenSecret: "", } } function createSchema(t: Translate) { return z .object({ channelType: z.enum(["web", "telegram", "zalo_oa", "email", "slack", "discord"], t("channel.typeRequired")), aiAgentId: z.string().trim().regex(/^\d+$/, t("channel.agentRequired")), aiAgentRolloutPercent: z.coerce.number().int().min(1).max(100), name: z.string().trim().min(1, t("channel.nameRequired")), botToken: z.string().trim(), botUsername: z.string().trim(), webhookSecret: z.string().trim(), zaloAppId: z.string().trim(), zaloOaId: z.string().trim(), zaloAccessToken: z.string().trim(), zaloSecretKey: z.string().trim(), emailAddress: z.string().trim(), senderName: z.string().trim(), emailProvider: z.string().trim(), emailApiKey: z.string().trim(), smtpHost: z.string().trim(), smtpPort: z.coerce.number().int().optional(), smtpUser: z.string().trim(), smtpPassword: z.string().trim(), smtpUseTls: z.boolean(), smtpAllowInsecure: z.boolean(), slackBotToken: z.string().trim(), slackSigningSecret: z.string().trim(), slackAppId: z.string().trim(), slackTeamId: z.string().trim(), slackTeamName: z.string().trim(), slackDefaultChannel: z.string().trim(), discordGuildId: z.string().trim(), discordGuildName: z.string().trim(), discordBotToken: z.string().trim(), widgetTitle: z.string().trim(), widgetSubtitle: z.string().trim(), widgetThemeColor: z.string().trim(), widgetPosition: z.enum(["left", "right"]), widgetWidth: z.string().trim(), userTokenSecret: z.string().trim(), remark: z.string().trim(), }) .superRefine((values, ctx) => { if (values.channelType === "email" && !values.emailAddress.trim()) { ctx.addIssue({ code: "custom", path: ["emailAddress"], message: t("channel.emailAddressRequired"), }) } if (values.channelType === "telegram" && !values.botToken.trim()) { ctx.addIssue({ code: "custom", path: ["botToken"], message: "Telegram Bot Token is required", }) } if (values.channelType === "zalo_oa" && !values.zaloAccessToken.trim()) { ctx.addIssue({ code: "custom", path: ["zaloAccessToken"], message: "Zalo OA Access Token is required", }) } if (values.channelType === "slack" && !values.slackBotToken.trim()) { ctx.addIssue({ code: "custom", path: ["slackBotToken"], message: t("channel.slackBotTokenRequired"), }) } }) } type EditForm = { channelType: "web" | "telegram" | "zalo_oa" | "email" | "slack" | "discord" aiAgentId: string aiAgentRolloutPercent: number name: string botToken: string botUsername: string webhookSecret: string zaloAppId: string zaloOaId: string zaloAccessToken: string zaloSecretKey: string emailAddress: string senderName: string emailProvider: string emailApiKey: string smtpHost: string smtpPort?: number smtpUser: string smtpPassword: string smtpUseTls: boolean smtpAllowInsecure: boolean slackBotToken: string slackSigningSecret: string slackAppId: string slackTeamId: string slackTeamName: string slackDefaultChannel: string discordGuildId: string discordGuildName: string discordBotToken: string widgetTitle: string widgetSubtitle: string widgetThemeColor: string widgetPosition: "left" | "right" widgetWidth: string userTokenSecret: string remark: string } function createEmptyForm(t: Translate): EditForm { const defaultWebChannelConfig = getDefaultWebChannelConfig(t) return { channelType: "web", aiAgentId: "", aiAgentRolloutPercent: 100, name: "", botToken: "", botUsername: "", webhookSecret: "", zaloAppId: "", zaloOaId: "", zaloAccessToken: "", zaloSecretKey: "", emailAddress: "", senderName: "", emailProvider: "default", emailApiKey: "", smtpHost: "", smtpPort: 587, smtpUser: "", smtpPassword: "", smtpUseTls: false, smtpAllowInsecure: false, slackBotToken: "", slackSigningSecret: "", slackAppId: "", slackTeamId: "", slackTeamName: "", slackDefaultChannel: "", discordGuildId: "", discordGuildName: "", discordBotToken: "", widgetTitle: defaultWebChannelConfig.title, widgetSubtitle: defaultWebChannelConfig.subtitle, widgetThemeColor: defaultWebChannelConfig.themeColor, widgetPosition: defaultWebChannelConfig.position, widgetWidth: defaultWebChannelConfig.width, userTokenSecret: "", remark: "", } } function parseTelegramChannelConfig(configJson: string): TelegramChannelConfig { if (!configJson.trim()) return {} try { const parsed = JSON.parse(configJson) as TelegramChannelConfig return { botToken: parsed.botToken?.trim() || "", botUsername: parsed.botUsername?.trim() || "", webhookSecret: parsed.webhookSecret?.trim() || "", } } catch { return {} } } function parseZaloOAChannelConfig(configJson: string): ZaloOAChannelConfig { if (!configJson.trim()) return {} try { const parsed = JSON.parse(configJson) as ZaloOAChannelConfig return { appId: parsed.appId?.trim() || "", oaId: parsed.oaId?.trim() || "", secretKey: parsed.secretKey?.trim() || "", accessToken: parsed.accessToken?.trim() || "", refreshToken: parsed.refreshToken?.trim() || "", webhookSecret: parsed.webhookSecret?.trim() || "", } } catch { return {} } } function parseSlackChannelConfig(configJson: string): SlackChannelConfig { if (!configJson.trim()) return {} try { const parsed = JSON.parse(configJson) as SlackChannelConfig return { botToken: parsed.botToken?.trim() || "", signingSecret: parsed.signingSecret?.trim() || "", appId: parsed.appId?.trim() || "", teamId: parsed.teamId?.trim() || "", teamName: parsed.teamName?.trim() || "", defaultChannel: parsed.defaultChannel?.trim() || "", webhookSecret: parsed.webhookSecret?.trim() || "", } } catch { return {} } } function parseDiscordChannelConfig(configJson: string): DiscordChannelConfig { if (!configJson.trim()) return {} try { const parsed = JSON.parse(configJson) as DiscordChannelConfig return { guildId: parsed.guildId?.trim() || "", guildName: parsed.guildName?.trim() || "", botToken: parsed.botToken?.trim() || "", webhookSecret: parsed.webhookSecret?.trim() || "", } } catch { return {} } } function parseEmailChannelConfig(configJson: string): EmailChannelConfig { if (!configJson.trim()) return {} try { const parsed = JSON.parse(configJson) as EmailChannelConfig return { emailAddress: parsed.emailAddress?.trim() || "", senderName: parsed.senderName?.trim() || "", provider: parsed.provider?.trim() || "", apiKey: parsed.apiKey?.trim() || "", smtpHost: parsed.smtpHost?.trim() || "", smtpPort: parsed.smtpPort, smtpUser: parsed.smtpUser?.trim() || "", smtpPassword: parsed.smtpPassword?.trim() || "", smtpUseTls: parsed.smtpUseTls === true, smtpAllowInsecure: parsed.smtpAllowInsecure === true, webhookSecret: parsed.webhookSecret?.trim() || "", } } catch { return {} } } function parseWebChannelConfig(configJson: string, t: Translate): Required { const defaultWebChannelConfig = getDefaultWebChannelConfig(t) if (!configJson.trim()) { return defaultWebChannelConfig } try { const parsed = JSON.parse(configJson) as WebChannelConfig const position = parsed.position === "left" ? "left" : "right" return { title: parsed.title?.trim() || defaultWebChannelConfig.title, subtitle: parsed.subtitle?.trim() ?? defaultWebChannelConfig.subtitle, themeColor: parsed.themeColor?.trim() || defaultWebChannelConfig.themeColor, position, width: parsed.width?.trim() || defaultWebChannelConfig.width, userTokenSecret: parsed.userTokenSecret?.trim() || "", } } catch { return defaultWebChannelConfig } } function buildForm(item: AdminChannel | null, t: Translate): EditForm { if (!item) { return createEmptyForm(t) } const isTelegram = item.channelType === "telegram" const isZaloOA = item.channelType === "zalo_oa" const isEmail = item.channelType === "email" const isSlack = item.channelType === "slack" const isDiscord = item.channelType === "discord" const webConfig = parseWebChannelConfig(item.configJson, t) const telegramConfig = isTelegram ? parseTelegramChannelConfig(item.configJson) : null const zaloConfig = isZaloOA ? parseZaloOAChannelConfig(item.configJson) : null const slackConfig = isSlack ? parseSlackChannelConfig(item.configJson) : null const discordConfig = isDiscord ? parseDiscordChannelConfig(item.configJson) : null const emailConfig = isEmail ? parseEmailChannelConfig(item.configJson) : null return { channelType: item.channelType === "email" || item.channelType === "telegram" || item.channelType === "zalo_oa" || item.channelType === "slack" || item.channelType === "discord" ? item.channelType : "web", aiAgentId: item.aiAgentId > 0 ? String(item.aiAgentId) : "", aiAgentRolloutPercent: item.aiAgentRolloutPercent || 100, name: item.name, botToken: telegramConfig?.botToken ?? "", botUsername: telegramConfig?.botUsername ?? "", webhookSecret: telegramConfig?.webhookSecret ?? zaloConfig?.webhookSecret ?? discordConfig?.webhookSecret ?? "", zaloAppId: zaloConfig?.appId ?? "", zaloOaId: zaloConfig?.oaId ?? "", zaloAccessToken: zaloConfig?.accessToken ?? "", zaloSecretKey: zaloConfig?.secretKey ?? "", emailAddress: emailConfig?.emailAddress ?? "", senderName: emailConfig?.senderName ?? "", emailProvider: emailConfig?.provider || "default", emailApiKey: emailConfig?.apiKey ?? "", smtpHost: emailConfig?.smtpHost ?? "", smtpPort: emailConfig?.smtpPort ?? 587, smtpUser: emailConfig?.smtpUser ?? "", smtpPassword: emailConfig?.smtpPassword ?? "", smtpUseTls: emailConfig?.smtpUseTls === true, smtpAllowInsecure: emailConfig?.smtpAllowInsecure === true, slackBotToken: slackConfig?.botToken ?? "", slackSigningSecret: slackConfig?.signingSecret ?? "", slackAppId: slackConfig?.appId ?? "", slackTeamId: slackConfig?.teamId ?? "", slackTeamName: slackConfig?.teamName ?? "", slackDefaultChannel: slackConfig?.defaultChannel ?? "", discordGuildId: discordConfig?.guildId ?? "", discordGuildName: discordConfig?.guildName ?? "", discordBotToken: discordConfig?.botToken ?? "", widgetTitle: webConfig.title, widgetSubtitle: webConfig.subtitle, widgetThemeColor: webConfig.themeColor, widgetPosition: webConfig.position, widgetWidth: webConfig.width, userTokenSecret: webConfig.userTokenSecret, remark: item.remark || "", } } function buildPayload(form: EditForm, status: number, t: Translate): CreateAdminChannelPayload { const channelType = form.channelType const defaultWebChannelConfig = getDefaultWebChannelConfig(t) const webLikeConfig = { title: form.widgetTitle.trim() || defaultWebChannelConfig.title, subtitle: form.widgetSubtitle.trim(), themeColor: form.widgetThemeColor.trim() || defaultWebChannelConfig.themeColor, userTokenSecret: form.userTokenSecret.trim(), } const configJson = channelType === "email" ? JSON.stringify({ emailAddress: form.emailAddress.trim(), senderName: form.senderName.trim(), provider: form.emailProvider === "default" ? "" : form.emailProvider.trim(), apiKey: form.emailApiKey.trim(), smtpHost: form.smtpHost.trim(), smtpPort: form.smtpPort || 587, smtpUser: form.smtpUser.trim(), smtpPassword: form.smtpPassword.trim(), smtpUseTls: form.smtpUseTls, smtpAllowInsecure: form.smtpAllowInsecure, webhookSecret: form.webhookSecret.trim(), }) : channelType === "telegram" ? JSON.stringify({ botToken: form.botToken.trim(), botUsername: form.botUsername.trim(), webhookSecret: form.webhookSecret.trim(), }) : channelType === "zalo_oa" ? JSON.stringify({ appId: form.zaloAppId.trim(), oaId: form.zaloOaId.trim(), accessToken: form.zaloAccessToken.trim(), secretKey: form.zaloSecretKey.trim(), webhookSecret: form.webhookSecret.trim(), }) : channelType === "slack" ? JSON.stringify({ botToken: form.slackBotToken.trim(), signingSecret: form.slackSigningSecret.trim(), appId: form.slackAppId.trim(), teamId: form.slackTeamId.trim(), teamName: form.slackTeamName.trim(), defaultChannel: form.slackDefaultChannel.trim(), webhookSecret: form.webhookSecret.trim(), }) : channelType === "discord" ? JSON.stringify({ guildId: form.discordGuildId.trim(), guildName: form.discordGuildName.trim(), botToken: form.discordBotToken.trim(), webhookSecret: form.webhookSecret.trim(), }) : JSON.stringify({ ...webLikeConfig, position: form.widgetPosition || defaultWebChannelConfig.position, width: form.widgetWidth.trim() || defaultWebChannelConfig.width, userTokenSecret: form.userTokenSecret.trim(), }) return { channelType, aiAgentId: Number(form.aiAgentId), aiAgentRolloutPercent: form.aiAgentRolloutPercent, name: form.name.trim(), configJson, status, remark: form.remark.trim(), } } function isAgentChannelBindable(agent: AIAgent | undefined) { return Boolean(agent && agent.publishedRevisionId > 0) } type ChannelFormBodyProps = Omit export function EditDialog({ open, saving, itemId, onOpenChange, onSubmit, }: ChannelFormDialogProps) { if (!open) { return null } return ( ) } function ChannelFormBody({ saving, itemId, onOpenChange, onSubmit, }: ChannelFormBodyProps) { const t = useI18n() const formId = "channel-edit-form" const emptyForm = useMemo(() => createEmptyForm(t), [t]) const schema = useMemo(() => createSchema(t), [t]) const resolver = useMemo( () => zodResolver(schema as never) as Resolver< z.input, undefined, z.output >, [schema], ) const [loading, setLoading] = useState(false) const [aiAgents, setAIAgents] = useState([]) const [channelDetail, setChannelDetail] = useState(null) const [rollingBackRollout, setRollingBackRollout] = useState(false) const [currentStatus, setCurrentStatus] = useState(0) const form = useForm< z.input, undefined, z.output >({ resolver, defaultValues: emptyForm, }) const { control, handleSubmit, register, reset, setValue, formState: { errors }, } = form const channelType = useWatch({ control, name: "channelType" }) const aiAgentId = useWatch({ control, name: "aiAgentId" }) const userTokenSecret = useWatch({ control, name: "userTokenSecret" }) const emailProvider = useWatch({ control, name: "emailProvider" }) const previousRolloutPercent = channelDetail?.previousAiAgentRolloutPercent ?? 0 async function rollbackRolloutPercent() { if (!channelDetail || previousRolloutPercent < 1) return setRollingBackRollout(true) try { await rollbackChannelAIAgentRollout(channelDetail.id) setValue("aiAgentRolloutPercent", previousRolloutPercent) setChannelDetail({ ...channelDetail, aiAgentRolloutPercent: previousRolloutPercent, previousAiAgentRolloutPercent: channelDetail.aiAgentRolloutPercent, }) toast.success(t("aiAgent.channelRolloutRestored")) } catch (error) { toast.error(error instanceof Error ? error.message : t("aiAgent.channelRolloutRestoreFailed")) } finally { setRollingBackRollout(false) } } useEffect(() => { async function loadAIAgents() { try { const data = await fetchAIAgentsAll({ status: 1 }) setAIAgents(data) } catch (error) { console.error("Failed to load AI agents:", error) } } void loadAIAgents() }, []) useEffect(() => { async function loadDetail() { if (!itemId) { setCurrentStatus(0) setChannelDetail(null) reset(emptyForm) return } setLoading(true) try { const data = await fetchChannel(itemId) setChannelDetail(data) setCurrentStatus(data.status) reset(buildForm(data, t)) } catch (error) { console.error("Failed to load channel:", error) } finally { setLoading(false) } } void loadDetail() }, [emptyForm, itemId, reset, t]) const selectedAIAgent = aiAgents.find((item) => String(item.id) === aiAgentId) const aiAgentOptions = aiAgents.map((item) => ({ value: String(item.id), label: isAgentChannelBindable(item) ? item.name : `${item.name} · ${t("aiAgent.agentNotPublishedShort")}`, disabled: !isAgentChannelBindable(item), })) const channelTypeOptions = [ { value: "web", label: t("channel.typeWeb") }, { value: "telegram", label: t("channel.typeTelegram") }, { value: "slack", label: t("channel.typeSlack") }, { value: "discord", label: t("channel.typeDiscord") }, ] as const const widgetPositionOptions = [ { value: "right", label: t("channel.positionRight") }, { value: "left", label: t("channel.positionLeft") }, ] as const async function onFormSubmit(values: EditForm) { const selected = aiAgents.find((item) => String(item.id) === values.aiAgentId) if (!isAgentChannelBindable(selected)) { toast.error(t("aiAgent.agentNotPublishedWarning")) return } await onSubmit(buildPayload(values, currentStatus, t)) } async function handleResetUserTokenSecret() { if (!itemId) { return } if (!window.confirm(t("channel.resetSecretConfirm"))) { return } try { const result = await resetChannelUserTokenSecret(itemId) setValue("userTokenSecret", result.userTokenSecret, { shouldDirty: true, }) if (channelDetail) { const parsed = JSON.parse(channelDetail.configJson || "{}") as Record parsed.userTokenSecret = result.userTokenSecret setChannelDetail({ ...channelDetail, configJson: JSON.stringify(parsed), }) } toast.success(t("channel.resetSecretSuccess")) } catch (error) { toast.error(error instanceof Error ? error.message : t("channel.resetSecretFailed")) } } async function copyUserTokenSecret() { if (!userTokenSecret) { return } try { await navigator.clipboard.writeText(userTokenSecret) toast.success(t("channel.copySecretSuccess")) } catch { toast.error(t("channel.copyFailed")) } } return ( } > {loading ? (
{t("channel.loadingDetail")}
) : (
{t("channel.name")} {t("channel.columnAgent")} ( )} /> {selectedAIAgent && !isAgentChannelBindable(selectedAIAgent) ? (
{t("aiAgent.agentNotPublishedWarning")}
) : null}
{t("aiAgent.channelRolloutTitle")}
{previousRolloutPercent > 0 ? ( ) : null}
{t("channel.channelType")} ( )} />
{t("channel.configTitle")}
{t("channel.configWebDescription")}
{channelType === "email" ? (
{t("channel.emailAddress")} * {t("channel.senderName")}
{t("channel.emailProvider")} {t("channel.webhookSecret")}
{emailProvider === "brevo" || emailProvider === "sendgrid" || emailProvider === "resend" || emailProvider === "postmark" || emailProvider === "mailgun" ? ( {t("channel.emailApiKey")} ) : emailProvider === "smtp" ? (
{t("channel.smtpHost")}
{t("channel.smtpPort")}
{t("channel.smtpUser")} {t("channel.smtpPassword")}
) : null}
{t("channel.emailAutoConnectTitle")}
{t("channel.emailAutoConnectDescription")}
{t("channel.inboundWebhookUrl")}: /api/third/email/webhook
) : null} {channelType === "zalo_oa" ? (
{t("channel.zaloAccessToken")} *
{t("channel.zaloOaId")} {t("channel.zaloAppId")}
{t("channel.zaloAutoConnectTitle")}
{t("channel.zaloAutoConnectDescription")}
) : null} {channelType === "slack" ? (
{t("channel.slackBotToken")} * {t("channel.slackSigningSecret")}

{t("channel.slackSigningSecretHint")}

{t("channel.slackTeamId")} {t("channel.slackDefaultChannel")} {t("channel.slackAppId")} {t("channel.slackTeamName")}
{t("channel.slackSetupTitle")}
{t("channel.slackSetupDescription")}
{t("channel.slackRequestUrl")}: /api/third/slack/webhook
) : null} {channelType === "discord" ? (
{t("channel.discordGuildId")} {t("channel.discordGuildName")}
{t("channel.discordBotToken")}
{t("channel.discordSetupTitle")}
{t("channel.discordSetupDescription")}
{t("channel.inboundWebhookUrl")}: /api/third/discord/webhook
) : null} {channelType === "telegram" ? (
{t("channel.botToken")} *
{t("channel.botUsername")} {t("channel.webhookSecret")}
{t("channel.telegramAutoConnectTitle")}
{t("channel.telegramAutoConnectDescription")}
) : null} {channelType === "web" ? ( <>
{t("channel.widgetTitle")} {t("channel.widgetSubtitle")} {t("channel.themeColor")} {channelType === "web" ? ( <> {t("channel.mountPosition")} ( )} /> {t("channel.widgetWidth")} ) : null}
{t("channel.userJwtSecret")}
{t("channel.userJwtSecretDescription")}
{!itemId ? (
{t("channel.secretAfterSave")}
) : ( Secret
)}
) : null}
{t("channel.remark")}