diff --git a/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-flags-unsupported-unions-1.png b/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-flags-unsupported-unions-1.png new file mode 100644 index 000000000..850d5b364 Binary files /dev/null and b/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-flags-unsupported-unions-1.png differ diff --git a/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-renders-inputs-and-patches-values-1.png b/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-renders-inputs-and-patches-values-1.png new file mode 100644 index 000000000..850d5b364 Binary files /dev/null and b/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-renders-inputs-and-patches-values-1.png differ diff --git a/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-renders-union-literals-as-select-options-1.png b/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-renders-union-literals-as-select-options-1.png new file mode 100644 index 000000000..850d5b364 Binary files /dev/null and b/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-renders-union-literals-as-select-options-1.png differ diff --git a/ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png b/ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png new file mode 100644 index 000000000..eae372b60 Binary files /dev/null and b/ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png differ diff --git a/ui/src/ui/app-events.ts b/ui/src/ui/app-events.ts new file mode 100644 index 000000000..c058cf73e --- /dev/null +++ b/ui/src/ui/app-events.ts @@ -0,0 +1,6 @@ +export type EventLogEntry = { + ts: number; + event: string; + payload?: unknown; +}; + diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts new file mode 100644 index 000000000..ad1d6c24a --- /dev/null +++ b/ui/src/ui/app-render.helpers.ts @@ -0,0 +1,223 @@ +import { html } from "lit"; + +import type { AppViewState } from "./app-view-state"; +import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation"; +import { loadChatHistory } from "./controllers/chat"; +import type { SessionsListResult } from "./types"; +import type { ThemeMode } from "./theme"; +import type { ThemeTransitionContext } from "./theme-transition"; + +export function renderTab(state: AppViewState, tab: Tab) { + const href = pathForTab(tab, state.basePath); + return html` + { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + event.preventDefault(); + state.setTab(tab); + }} + title=${titleForTab(tab)} + > + + ${titleForTab(tab)} + + `; +} + +export function renderChatControls(state: AppViewState) { + const sessionOptions = resolveSessionOptions(state.sessionKey, state.sessionsResult); + // Icon for list view (legacy) + const listIcon = html``; + // Icon for grouped view + const groupIcon = html``; + // Refresh icon + const refreshIcon = html``; + const focusIcon = html``; + return html` +
+ + + | + + +
+ `; +} + +function resolveSessionOptions(sessionKey: string, sessions: SessionsListResult | null) { + const seen = new Set(); + const options: Array<{ key: string; displayName?: string }> = []; + + // Add current session key first + seen.add(sessionKey); + options.push({ key: sessionKey }); + + // Add sessions from the result + if (sessions?.sessions) { + for (const s of sessions.sessions) { + if (!seen.has(s.key)) { + seen.add(s.key); + options.push({ key: s.key, displayName: s.displayName }); + } + } + } + + return options; +} + +const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"]; + +export function renderThemeToggle(state: AppViewState) { + const index = Math.max(0, THEME_ORDER.indexOf(state.theme)); + const applyTheme = (next: ThemeMode) => (event: MouseEvent) => { + const element = event.currentTarget as HTMLElement; + const context: ThemeTransitionContext = { element }; + if (event.clientX || event.clientY) { + context.pointerClientX = event.clientX; + context.pointerClientY = event.clientY; + } + state.setTheme(next, context); + }; + + return html` +
+
+ + + + +
+
+ `; +} + +function renderSunIcon() { + return html` + + `; +} + +function renderMoonIcon() { + return html` + + `; +} + +function renderMonitorIcon() { + return html` + + `; +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 7face4b37..77e7a71d6 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1,6 +1,7 @@ import { html, nothing } from "lit"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway"; +import type { AppViewState } from "./app-view-state"; import { TAB_GROUPS, iconForTab, @@ -46,6 +47,7 @@ import { renderNodes } from "./views/nodes"; import { renderOverview } from "./views/overview"; import { renderSessions } from "./views/sessions"; import { renderSkills } from "./views/skills"; +import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers"; import { loadChannels, updateDiscordForm, @@ -77,137 +79,6 @@ import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } fr import { loadDebug, callDebugMethod } from "./controllers/debug"; import { loadLogs } from "./controllers/logs"; -export type EventLogEntry = { - ts: number; - event: string; - payload?: unknown; -}; - -export type AppViewState = { - settings: UiSettings; - password: string; - tab: Tab; - basePath: string; - connected: boolean; - theme: ThemeMode; - themeResolved: "light" | "dark"; - hello: GatewayHelloOk | null; - lastError: string | null; - eventLog: EventLogEntry[]; - sessionKey: string; - chatLoading: boolean; - chatSending: boolean; - chatMessage: string; - chatMessages: unknown[]; - chatToolMessages: unknown[]; - chatStream: string | null; - chatRunId: string | null; - chatThinkingLevel: string | null; - chatQueue: ChatQueueItem[]; - nodesLoading: boolean; - nodes: Array>; - configLoading: boolean; - configRaw: string; - configValid: boolean | null; - configIssues: unknown[]; - configSaving: boolean; - configApplying: boolean; - updateRunning: boolean; - configSnapshot: ConfigSnapshot | null; - configSchema: unknown | null; - configSchemaLoading: boolean; - configUiHints: Record; - configForm: Record | null; - configFormMode: "form" | "raw"; - channelsLoading: boolean; - channelsSnapshot: ChannelsStatusSnapshot | null; - channelsError: string | null; - channelsLastSuccess: number | null; - whatsappLoginMessage: string | null; - whatsappLoginQrDataUrl: string | null; - whatsappLoginConnected: boolean | null; - whatsappBusy: boolean; - telegramForm: TelegramForm; - telegramSaving: boolean; - telegramTokenLocked: boolean; - telegramConfigStatus: string | null; - discordForm: DiscordForm; - discordSaving: boolean; - discordTokenLocked: boolean; - discordConfigStatus: string | null; - slackForm: SlackForm; - slackSaving: boolean; - slackTokenLocked: boolean; - slackAppTokenLocked: boolean; - slackConfigStatus: string | null; - signalForm: SignalForm; - signalSaving: boolean; - signalConfigStatus: string | null; - imessageForm: IMessageForm; - imessageSaving: boolean; - imessageConfigStatus: string | null; - presenceLoading: boolean; - presenceEntries: PresenceEntry[]; - presenceError: string | null; - presenceStatus: string | null; - sessionsLoading: boolean; - sessionsResult: SessionsListResult | null; - sessionsError: string | null; - sessionsFilterActive: string; - sessionsFilterLimit: string; - sessionsIncludeGlobal: boolean; - sessionsIncludeUnknown: boolean; - cronLoading: boolean; - cronJobs: CronJob[]; - cronStatus: CronStatus | null; - cronError: string | null; - cronForm: CronFormState; - cronRunsJobId: string | null; - cronRuns: CronRunLogEntry[]; - cronBusy: boolean; - skillsLoading: boolean; - skillsReport: SkillStatusReport | null; - skillsError: string | null; - skillsFilter: string; - skillEdits: Record; - skillMessages: Record; - skillsBusyKey: string | null; - debugLoading: boolean; - debugStatus: StatusSummary | null; - debugHealth: HealthSnapshot | null; - debugModels: unknown[]; - debugHeartbeat: unknown | null; - debugCallMethod: string; - debugCallParams: string; - debugCallResult: string | null; - debugCallError: string | null; - logsLoading: boolean; - logsError: string | null; - logsFile: string | null; - logsEntries: LogEntry[]; - logsFilterText: string; - logsLevelFilters: Record; - logsAutoFollow: boolean; - logsTruncated: boolean; - client: GatewayBrowserClient | null; - connect: () => void; - setTab: (tab: Tab) => void; - setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void; - applySettings: (next: UiSettings) => void; - loadOverview: () => Promise; - loadCron: () => Promise; - handleWhatsAppStart: (force: boolean) => Promise; - handleWhatsAppWait: () => Promise; - handleWhatsAppLogout: () => Promise; - handleTelegramSave: () => Promise; - handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise; - handleAbortChat: () => Promise; - removeQueuedMessage: (id: string) => void; - resetToolStream: () => void; - handleLogsScroll: (event: Event) => void; - exportLogs: (lines: string[], label: string) => void; -}; - export function renderApp(state: AppViewState) { const presenceCount = state.presenceEntries.length; const sessionsCount = state.sessionsResult?.count ?? null; @@ -589,218 +460,3 @@ export function renderApp(state: AppViewState) { `; } - -function renderTab(state: AppViewState, tab: Tab) { - const href = pathForTab(tab, state.basePath); - return html` - { - if ( - event.defaultPrevented || - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) { - return; - } - event.preventDefault(); - state.setTab(tab); - }} - title=${titleForTab(tab)} - > - - ${titleForTab(tab)} - - `; -} - -function renderChatControls(state: AppViewState) { - const sessionOptions = resolveSessionOptions(state.sessionKey, state.sessionsResult); - // Icon for list view (legacy) - const listIcon = html``; - // Icon for grouped view - const groupIcon = html``; - // Refresh icon - const refreshIcon = html``; - const focusIcon = html``; - return html` -
- - - | - - -
- `; -} - -function resolveSessionOptions(sessionKey: string, sessions: SessionsListResult | null) { - const seen = new Set(); - const options: Array<{ key: string; displayName?: string }> = []; - - // Add current session key first - seen.add(sessionKey); - options.push({ key: sessionKey }); - - // Add sessions from the result - if (sessions?.sessions) { - for (const s of sessions.sessions) { - if (!seen.has(s.key)) { - seen.add(s.key); - options.push({ key: s.key, displayName: s.displayName }); - } - } - } - - return options; -} - -const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"]; - -function renderThemeToggle(state: AppViewState) { - const index = Math.max(0, THEME_ORDER.indexOf(state.theme)); - const applyTheme = (next: ThemeMode) => (event: MouseEvent) => { - const element = event.currentTarget as HTMLElement; - const context: ThemeTransitionContext = { element }; - if (event.clientX || event.clientY) { - context.pointerClientX = event.clientX; - context.pointerClientY = event.clientY; - } - state.setTheme(next, context); - }; - - return html` -
-
- - - - -
-
- `; -} - -function renderSunIcon() { - return html` - - `; -} - -function renderMoonIcon() { - return html` - - `; -} - -function renderMonitorIcon() { - return html` - - `; -} diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts new file mode 100644 index 000000000..4d4750793 --- /dev/null +++ b/ui/src/ui/app-view-state.ts @@ -0,0 +1,197 @@ +import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway"; +import type { Tab } from "./navigation"; +import type { UiSettings } from "./storage"; +import type { ThemeMode } from "./theme"; +import type { ThemeTransitionContext } from "./theme-transition"; +import type { + ChannelsStatusSnapshot, + ConfigSnapshot, + CronJob, + CronRunLogEntry, + CronStatus, + HealthSnapshot, + LogEntry, + LogLevel, + PresenceEntry, + SessionsListResult, + SkillStatusReport, + StatusSummary, +} from "./types"; +import type { + ChatQueueItem, + CronFormState, + DiscordForm, + IMessageForm, + SlackForm, + SignalForm, + TelegramForm, +} from "./ui-types"; +import type { EventLogEntry } from "./app-events"; +import type { SkillMessage } from "./controllers/skills"; + +export type AppViewState = { + settings: UiSettings; + password: string; + tab: Tab; + basePath: string; + connected: boolean; + theme: ThemeMode; + themeResolved: "light" | "dark"; + hello: GatewayHelloOk | null; + lastError: string | null; + eventLog: EventLogEntry[]; + sessionKey: string; + chatLoading: boolean; + chatSending: boolean; + chatMessage: string; + chatMessages: unknown[]; + chatToolMessages: unknown[]; + chatStream: string | null; + chatRunId: string | null; + chatThinkingLevel: string | null; + chatQueue: ChatQueueItem[]; + nodesLoading: boolean; + nodes: Array>; + configLoading: boolean; + configRaw: string; + configValid: boolean | null; + configIssues: unknown[]; + configSaving: boolean; + configApplying: boolean; + updateRunning: boolean; + configSnapshot: ConfigSnapshot | null; + configSchema: unknown | null; + configSchemaLoading: boolean; + configUiHints: Record; + configForm: Record | null; + configFormMode: "form" | "raw"; + channelsLoading: boolean; + channelsSnapshot: ChannelsStatusSnapshot | null; + channelsError: string | null; + channelsLastSuccess: number | null; + whatsappLoginMessage: string | null; + whatsappLoginQrDataUrl: string | null; + whatsappLoginConnected: boolean | null; + whatsappBusy: boolean; + telegramForm: TelegramForm; + telegramSaving: boolean; + telegramTokenLocked: boolean; + telegramConfigStatus: string | null; + discordForm: DiscordForm; + discordSaving: boolean; + discordTokenLocked: boolean; + discordConfigStatus: string | null; + slackForm: SlackForm; + slackSaving: boolean; + slackTokenLocked: boolean; + slackAppTokenLocked: boolean; + slackConfigStatus: string | null; + signalForm: SignalForm; + signalSaving: boolean; + signalConfigStatus: string | null; + imessageForm: IMessageForm; + imessageSaving: boolean; + imessageConfigStatus: string | null; + presenceLoading: boolean; + presenceEntries: PresenceEntry[]; + presenceError: string | null; + presenceStatus: string | null; + sessionsLoading: boolean; + sessionsResult: SessionsListResult | null; + sessionsError: string | null; + sessionsFilterActive: string; + sessionsFilterLimit: string; + sessionsIncludeGlobal: boolean; + sessionsIncludeUnknown: boolean; + cronLoading: boolean; + cronJobs: CronJob[]; + cronStatus: CronStatus | null; + cronError: string | null; + cronForm: CronFormState; + cronRunsJobId: string | null; + cronRuns: CronRunLogEntry[]; + cronBusy: boolean; + skillsLoading: boolean; + skillsReport: SkillStatusReport | null; + skillsError: string | null; + skillsFilter: string; + skillEdits: Record; + skillMessages: Record; + skillsBusyKey: string | null; + debugLoading: boolean; + debugStatus: StatusSummary | null; + debugHealth: HealthSnapshot | null; + debugModels: unknown[]; + debugHeartbeat: unknown | null; + debugCallMethod: string; + debugCallParams: string; + debugCallResult: string | null; + debugCallError: string | null; + logsLoading: boolean; + logsError: string | null; + logsFile: string | null; + logsEntries: LogEntry[]; + logsFilterText: string; + logsLevelFilters: Record; + logsAutoFollow: boolean; + logsTruncated: boolean; + client: GatewayBrowserClient | null; + connect: () => void; + setTab: (tab: Tab) => void; + setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void; + applySettings: (next: UiSettings) => void; + loadOverview: () => Promise; + loadCron: () => Promise; + handleWhatsAppStart: (force: boolean) => Promise; + handleWhatsAppWait: () => Promise; + handleWhatsAppLogout: () => Promise; + handleTelegramSave: () => Promise; + handleDiscordSave: () => Promise; + handleSlackSave: () => Promise; + handleSignalSave: () => Promise; + handleIMessageSave: () => Promise; + handleConfigLoad: () => Promise; + handleConfigSave: () => Promise; + handleConfigApply: () => Promise; + handleConfigFormUpdate: (path: string, value: unknown) => void; + handleConfigFormModeChange: (mode: "form" | "raw") => void; + handleConfigRawChange: (raw: string) => void; + handleInstallSkill: (key: string) => Promise; + handleUpdateSkill: (key: string) => Promise; + handleToggleSkillEnabled: (key: string, enabled: boolean) => Promise; + handleUpdateSkillEdit: (key: string, value: string) => void; + handleSaveSkillApiKey: (key: string, apiKey: string) => Promise; + handleCronToggle: (jobId: string, enabled: boolean) => Promise; + handleCronRun: (jobId: string) => Promise; + handleCronRemove: (jobId: string) => Promise; + handleCronAdd: () => Promise; + handleCronRunsLoad: (jobId: string) => Promise; + handleCronFormUpdate: (path: string, value: unknown) => void; + handleSessionsLoad: () => Promise; + handleSessionsPatch: (key: string, patch: unknown) => Promise; + handleLoadNodes: () => Promise; + handleLoadPresence: () => Promise; + handleLoadSkills: () => Promise; + handleLoadDebug: () => Promise; + handleLoadLogs: () => Promise; + handleDebugCall: () => Promise; + handleRunUpdate: () => Promise; + setPassword: (next: string) => void; + setSessionKey: (next: string) => void; + setChatMessage: (next: string) => void; + handleChatSend: () => Promise; + handleChatAbort: () => Promise; + handleChatSelectQueueItem: (id: string) => void; + handleChatDropQueueItem: (id: string) => void; + handleChatClearQueue: () => void; + handleLogsFilterChange: (next: string) => void; + handleLogsLevelFilterToggle: (level: LogLevel) => void; + handleLogsAutoFollowToggle: (next: boolean) => void; + handleCallDebugMethod: (method: string, params: string) => Promise; + handleUpdateDiscordForm: (path: string, value: unknown) => void; + handleUpdateSlackForm: (path: string, value: unknown) => void; + handleUpdateSignalForm: (path: string, value: unknown) => void; + handleUpdateTelegramForm: (path: string, value: unknown) => void; + handleUpdateIMessageForm: (path: string, value: unknown) => void; +}; + diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 02b7ed6a5..42d879c82 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -85,12 +85,7 @@ import { } from "./controllers/skills"; import { loadDebug } from "./controllers/debug"; import { loadLogs } from "./controllers/logs"; - -type EventLogEntry = { - ts: number; - event: string; - payload?: unknown; -}; +import type { EventLogEntry } from "./app-events"; const TOOL_STREAM_LIMIT = 50; const TOOL_STREAM_THROTTLE_MS = 80; diff --git a/ui/src/ui/controllers/connections.save-discord.ts b/ui/src/ui/controllers/connections.save-discord.ts new file mode 100644 index 000000000..f00981698 --- /dev/null +++ b/ui/src/ui/controllers/connections.save-discord.ts @@ -0,0 +1,179 @@ +import { parseList } from "../format"; +import { + defaultDiscordActions, + type DiscordActionForm, + type DiscordGuildChannelForm, + type DiscordGuildForm, +} from "../ui-types"; +import type { ConnectionsState } from "./connections.types"; + +export async function saveDiscordConfig(state: ConnectionsState) { + if (!state.client || !state.connected) return; + if (state.discordSaving) return; + state.discordSaving = true; + state.discordConfigStatus = null; + try { + const base = state.configSnapshot?.config ?? {}; + const config = { ...base } as Record; + const discord = { ...(config.discord ?? {}) } as Record; + const form = state.discordForm; + + if (form.enabled) { + delete discord.enabled; + } else { + discord.enabled = false; + } + + if (!state.discordTokenLocked) { + const token = form.token.trim(); + if (token) discord.token = token; + else delete discord.token; + } + + const allowFrom = parseList(form.allowFrom); + const groupChannels = parseList(form.groupChannels); + const dm = { ...(discord.dm ?? {}) } as Record; + if (form.dmEnabled) delete dm.enabled; + else dm.enabled = false; + if (allowFrom.length > 0) dm.allowFrom = allowFrom; + else delete dm.allowFrom; + if (form.groupEnabled) dm.groupEnabled = true; + else delete dm.groupEnabled; + if (groupChannels.length > 0) dm.groupChannels = groupChannels; + else delete dm.groupChannels; + if (Object.keys(dm).length > 0) discord.dm = dm; + else delete discord.dm; + + const mediaMaxMb = Number(form.mediaMaxMb); + if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { + discord.mediaMaxMb = mediaMaxMb; + } else { + delete discord.mediaMaxMb; + } + + const historyLimitRaw = form.historyLimit.trim(); + if (historyLimitRaw.length === 0) { + delete discord.historyLimit; + } else { + const historyLimit = Number(historyLimitRaw); + if (Number.isFinite(historyLimit) && historyLimit >= 0) { + discord.historyLimit = historyLimit; + } else { + delete discord.historyLimit; + } + } + + const chunkLimitRaw = form.textChunkLimit.trim(); + if (chunkLimitRaw.length === 0) { + delete discord.textChunkLimit; + } else { + const chunkLimit = Number(chunkLimitRaw); + if (Number.isFinite(chunkLimit) && chunkLimit > 0) { + discord.textChunkLimit = chunkLimit; + } else { + delete discord.textChunkLimit; + } + } + + if (form.replyToMode === "off") { + delete discord.replyToMode; + } else { + discord.replyToMode = form.replyToMode; + } + + const guildsForm = Array.isArray(form.guilds) ? form.guilds : []; + const guilds: Record = {}; + guildsForm.forEach((guild: DiscordGuildForm) => { + const key = String(guild.key ?? "").trim(); + if (!key) return; + const entry: Record = {}; + const slug = String(guild.slug ?? "").trim(); + if (slug) entry.slug = slug; + if (guild.requireMention) entry.requireMention = true; + if ( + guild.reactionNotifications === "off" || + guild.reactionNotifications === "all" || + guild.reactionNotifications === "own" || + guild.reactionNotifications === "allowlist" + ) { + entry.reactionNotifications = guild.reactionNotifications; + } + const users = parseList(guild.users); + if (users.length > 0) entry.users = users; + const channels: Record = {}; + const channelForms = Array.isArray(guild.channels) ? guild.channels : []; + channelForms.forEach((channel: DiscordGuildChannelForm) => { + const channelKey = String(channel.key ?? "").trim(); + if (!channelKey) return; + const channelEntry: Record = {}; + if (channel.allow === false) channelEntry.allow = false; + if (channel.requireMention) channelEntry.requireMention = true; + channels[channelKey] = channelEntry; + }); + if (Object.keys(channels).length > 0) entry.channels = channels; + guilds[key] = entry; + }); + if (Object.keys(guilds).length > 0) discord.guilds = guilds; + else delete discord.guilds; + + const actions: Partial = {}; + const applyAction = (key: keyof DiscordActionForm) => { + const value = form.actions[key]; + if (value !== defaultDiscordActions[key]) actions[key] = value; + }; + applyAction("reactions"); + applyAction("stickers"); + applyAction("polls"); + applyAction("permissions"); + applyAction("messages"); + applyAction("threads"); + applyAction("pins"); + applyAction("search"); + applyAction("memberInfo"); + applyAction("roleInfo"); + applyAction("channelInfo"); + applyAction("voiceStatus"); + applyAction("events"); + applyAction("roles"); + applyAction("moderation"); + if (Object.keys(actions).length > 0) { + discord.actions = actions; + } else { + delete discord.actions; + } + + const slash = { ...(discord.slashCommand ?? {}) } as Record; + if (form.slashEnabled) { + slash.enabled = true; + } else { + delete slash.enabled; + } + if (form.slashName.trim()) slash.name = form.slashName.trim(); + else delete slash.name; + if (form.slashSessionPrefix.trim()) + slash.sessionPrefix = form.slashSessionPrefix.trim(); + else delete slash.sessionPrefix; + if (form.slashEphemeral) { + delete slash.ephemeral; + } else { + slash.ephemeral = false; + } + if (Object.keys(slash).length > 0) discord.slashCommand = slash; + else delete discord.slashCommand; + + if (Object.keys(discord).length > 0) { + config.discord = discord; + } else { + delete config.discord; + } + + const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; + await state.client.request("config.set", { raw }); + state.discordConfigStatus = "Saved. Restart gateway if needed."; + } catch (err) { + state.discordConfigStatus = String(err); + } finally { + state.discordSaving = false; + } +} + diff --git a/ui/src/ui/controllers/connections.save-imessage.ts b/ui/src/ui/controllers/connections.save-imessage.ts new file mode 100644 index 000000000..70eed860b --- /dev/null +++ b/ui/src/ui/controllers/connections.save-imessage.ts @@ -0,0 +1,68 @@ +import { parseList } from "../format"; +import type { ConnectionsState } from "./connections.types"; + +export async function saveIMessageConfig(state: ConnectionsState) { + if (!state.client || !state.connected) return; + if (state.imessageSaving) return; + state.imessageSaving = true; + state.imessageConfigStatus = null; + try { + const base = state.configSnapshot?.config ?? {}; + const config = { ...base } as Record; + const imessage = { ...(config.imessage ?? {}) } as Record; + const form = state.imessageForm; + + if (form.enabled) { + delete imessage.enabled; + } else { + imessage.enabled = false; + } + + const cliPath = form.cliPath.trim(); + if (cliPath) imessage.cliPath = cliPath; + else delete imessage.cliPath; + + const dbPath = form.dbPath.trim(); + if (dbPath) imessage.dbPath = dbPath; + else delete imessage.dbPath; + + if (form.service === "auto") { + delete imessage.service; + } else { + imessage.service = form.service; + } + + const region = form.region.trim(); + if (region) imessage.region = region; + else delete imessage.region; + + const allowFrom = parseList(form.allowFrom); + if (allowFrom.length > 0) imessage.allowFrom = allowFrom; + else delete imessage.allowFrom; + + if (form.includeAttachments) imessage.includeAttachments = true; + else delete imessage.includeAttachments; + + const mediaMaxMb = Number(form.mediaMaxMb); + if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { + imessage.mediaMaxMb = mediaMaxMb; + } else { + delete imessage.mediaMaxMb; + } + + if (Object.keys(imessage).length > 0) { + config.imessage = imessage; + } else { + delete config.imessage; + } + + const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; + await state.client.request("config.set", { raw }); + state.imessageConfigStatus = "Saved. Restart gateway if needed."; + } catch (err) { + state.imessageConfigStatus = String(err); + } finally { + state.imessageSaving = false; + } +} + diff --git a/ui/src/ui/controllers/connections.save-signal.ts b/ui/src/ui/controllers/connections.save-signal.ts new file mode 100644 index 000000000..e07a71f99 --- /dev/null +++ b/ui/src/ui/controllers/connections.save-signal.ts @@ -0,0 +1,89 @@ +import { parseList } from "../format"; +import type { ConnectionsState } from "./connections.types"; + +export async function saveSignalConfig(state: ConnectionsState) { + if (!state.client || !state.connected) return; + if (state.signalSaving) return; + state.signalSaving = true; + state.signalConfigStatus = null; + try { + const base = state.configSnapshot?.config ?? {}; + const config = { ...base } as Record; + const signal = { ...(config.signal ?? {}) } as Record; + const form = state.signalForm; + + if (form.enabled) { + delete signal.enabled; + } else { + signal.enabled = false; + } + + const account = form.account.trim(); + if (account) signal.account = account; + else delete signal.account; + + const httpUrl = form.httpUrl.trim(); + if (httpUrl) signal.httpUrl = httpUrl; + else delete signal.httpUrl; + + const httpHost = form.httpHost.trim(); + if (httpHost) signal.httpHost = httpHost; + else delete signal.httpHost; + + const httpPort = Number(form.httpPort); + if (Number.isFinite(httpPort) && httpPort > 0) { + signal.httpPort = httpPort; + } else { + delete signal.httpPort; + } + + const cliPath = form.cliPath.trim(); + if (cliPath) signal.cliPath = cliPath; + else delete signal.cliPath; + + if (form.autoStart) { + delete signal.autoStart; + } else { + signal.autoStart = false; + } + + if (form.receiveMode === "on-start" || form.receiveMode === "manual") { + signal.receiveMode = form.receiveMode; + } else { + delete signal.receiveMode; + } + + if (form.ignoreAttachments) signal.ignoreAttachments = true; + else delete signal.ignoreAttachments; + if (form.ignoreStories) signal.ignoreStories = true; + else delete signal.ignoreStories; + if (form.sendReadReceipts) signal.sendReadReceipts = true; + else delete signal.sendReadReceipts; + + const allowFrom = parseList(form.allowFrom); + if (allowFrom.length > 0) signal.allowFrom = allowFrom; + else delete signal.allowFrom; + + const mediaMaxMb = Number(form.mediaMaxMb); + if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { + signal.mediaMaxMb = mediaMaxMb; + } else { + delete signal.mediaMaxMb; + } + + if (Object.keys(signal).length > 0) { + config.signal = signal; + } else { + delete config.signal; + } + + const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; + await state.client.request("config.set", { raw }); + state.signalConfigStatus = "Saved. Restart gateway if needed."; + } catch (err) { + state.signalConfigStatus = String(err); + } finally { + state.signalSaving = false; + } +} + diff --git a/ui/src/ui/controllers/connections.save-slack.ts b/ui/src/ui/controllers/connections.save-slack.ts new file mode 100644 index 000000000..19345505e --- /dev/null +++ b/ui/src/ui/controllers/connections.save-slack.ts @@ -0,0 +1,143 @@ +import { parseList } from "../format"; +import { defaultSlackActions, type SlackActionForm } from "../ui-types"; +import type { ConnectionsState } from "./connections.types"; + +export async function saveSlackConfig(state: ConnectionsState) { + if (!state.client || !state.connected) return; + if (state.slackSaving) return; + state.slackSaving = true; + state.slackConfigStatus = null; + try { + const base = state.configSnapshot?.config ?? {}; + const config = { ...base } as Record; + const slack = { ...(config.slack ?? {}) } as Record; + const form = state.slackForm; + + if (form.enabled) { + delete slack.enabled; + } else { + slack.enabled = false; + } + + if (!state.slackTokenLocked) { + const token = form.botToken.trim(); + if (token) slack.botToken = token; + else delete slack.botToken; + } + if (!state.slackAppTokenLocked) { + const token = form.appToken.trim(); + if (token) slack.appToken = token; + else delete slack.appToken; + } + + const dm = { ...(slack.dm ?? {}) } as Record; + dm.enabled = form.dmEnabled; + const allowFrom = parseList(form.allowFrom); + if (allowFrom.length > 0) dm.allowFrom = allowFrom; + else delete dm.allowFrom; + if (form.groupEnabled) { + dm.groupEnabled = true; + } else { + delete dm.groupEnabled; + } + const groupChannels = parseList(form.groupChannels); + if (groupChannels.length > 0) dm.groupChannels = groupChannels; + else delete dm.groupChannels; + if (Object.keys(dm).length > 0) slack.dm = dm; + else delete slack.dm; + + const mediaMaxMb = Number.parseFloat(form.mediaMaxMb); + if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { + slack.mediaMaxMb = mediaMaxMb; + } else { + delete slack.mediaMaxMb; + } + + const textChunkLimit = Number.parseInt(form.textChunkLimit, 10); + if (Number.isFinite(textChunkLimit) && textChunkLimit > 0) { + slack.textChunkLimit = textChunkLimit; + } else { + delete slack.textChunkLimit; + } + + if (form.reactionNotifications === "own") { + delete slack.reactionNotifications; + } else { + slack.reactionNotifications = form.reactionNotifications; + } + const reactionAllowlist = parseList(form.reactionAllowlist); + if (reactionAllowlist.length > 0) { + slack.reactionAllowlist = reactionAllowlist; + } else { + delete slack.reactionAllowlist; + } + + const slash = { ...(slack.slashCommand ?? {}) } as Record; + if (form.slashEnabled) { + slash.enabled = true; + } else { + delete slash.enabled; + } + if (form.slashName.trim()) slash.name = form.slashName.trim(); + else delete slash.name; + if (form.slashSessionPrefix.trim()) + slash.sessionPrefix = form.slashSessionPrefix.trim(); + else delete slash.sessionPrefix; + if (form.slashEphemeral) { + delete slash.ephemeral; + } else { + slash.ephemeral = false; + } + if (Object.keys(slash).length > 0) slack.slashCommand = slash; + else delete slack.slashCommand; + + const actions: Partial = {}; + const applyAction = (key: keyof SlackActionForm) => { + const value = form.actions[key]; + if (value !== defaultSlackActions[key]) actions[key] = value; + }; + applyAction("reactions"); + applyAction("messages"); + applyAction("pins"); + applyAction("memberInfo"); + applyAction("emojiList"); + if (Object.keys(actions).length > 0) { + slack.actions = actions; + } else { + delete slack.actions; + } + + const channels = form.channels + .map((entry): [string, Record] | null => { + const key = entry.key.trim(); + if (!key) return null; + const record: Record = { + allow: entry.allow, + requireMention: entry.requireMention, + }; + return [key, record]; + }) + .filter((value): value is [string, Record] => + Boolean(value), + ); + if (channels.length > 0) { + slack.channels = Object.fromEntries(channels); + } else { + delete slack.channels; + } + + if (Object.keys(slack).length > 0) { + config.slack = slack; + } else { + delete config.slack; + } + + const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; + await state.client.request("config.set", { raw }); + state.slackConfigStatus = "Saved. Restart gateway if needed."; + } catch (err) { + state.slackConfigStatus = String(err); + } finally { + state.slackSaving = false; + } +} diff --git a/ui/src/ui/controllers/connections.ts b/ui/src/ui/controllers/connections.ts index 2bff0b017..50e2472b4 100644 --- a/ui/src/ui/controllers/connections.ts +++ b/ui/src/ui/controllers/connections.ts @@ -1,52 +1,20 @@ -import type { GatewayBrowserClient } from "../gateway"; import { parseList } from "../format"; -import type { ChannelsStatusSnapshot, ConfigSnapshot } from "../types"; +import type { ChannelsStatusSnapshot } from "../types"; import { - defaultDiscordActions, - defaultSlackActions, - type DiscordActionForm, type DiscordForm, - type DiscordGuildChannelForm, - type DiscordGuildForm, type IMessageForm, - type SlackActionForm, type SlackForm, type SignalForm, type TelegramForm, } from "../ui-types"; +import type { ConnectionsState } from "./connections.types"; -export type ConnectionsState = { - client: GatewayBrowserClient | null; - connected: boolean; - channelsLoading: boolean; - channelsSnapshot: ChannelsStatusSnapshot | null; - channelsError: string | null; - channelsLastSuccess: number | null; - whatsappLoginMessage: string | null; - whatsappLoginQrDataUrl: string | null; - whatsappLoginConnected: boolean | null; - whatsappBusy: boolean; - telegramForm: TelegramForm; - telegramSaving: boolean; - telegramTokenLocked: boolean; - telegramConfigStatus: string | null; - discordForm: DiscordForm; - discordSaving: boolean; - discordTokenLocked: boolean; - discordConfigStatus: string | null; - slackForm: SlackForm; - slackSaving: boolean; - slackTokenLocked: boolean; - slackAppTokenLocked: boolean; - slackConfigStatus: string | null; - signalForm: SignalForm; - signalSaving: boolean; - signalConfigStatus: string | null; - imessageForm: IMessageForm; - imessageSaving: boolean; - imessageConfigStatus: string | null; - configSnapshot: ConfigSnapshot | null; -}; +export { saveDiscordConfig } from "./connections.save-discord"; +export { saveIMessageConfig } from "./connections.save-imessage"; +export { saveSlackConfig } from "./connections.save-slack"; +export { saveSignalConfig } from "./connections.save-signal"; + +export type { ConnectionsState }; export async function loadChannels(state: ConnectionsState, probe: boolean) { if (!state.client || !state.connected) return; @@ -254,462 +222,3 @@ export async function saveTelegramConfig(state: ConnectionsState) { state.telegramSaving = false; } } - -export async function saveDiscordConfig(state: ConnectionsState) { - if (!state.client || !state.connected) return; - if (state.discordSaving) return; - state.discordSaving = true; - state.discordConfigStatus = null; - try { - const base = state.configSnapshot?.config ?? {}; - const config = { ...base } as Record; - const discord = { ...(config.discord ?? {}) } as Record; - const form = state.discordForm; - - if (form.enabled) { - delete discord.enabled; - } else { - discord.enabled = false; - } - - if (!state.discordTokenLocked) { - const token = form.token.trim(); - if (token) discord.token = token; - else delete discord.token; - } - - const allowFrom = parseList(form.allowFrom); - const groupChannels = parseList(form.groupChannels); - const dm = { ...(discord.dm ?? {}) } as Record; - if (form.dmEnabled) delete dm.enabled; - else dm.enabled = false; - if (allowFrom.length > 0) dm.allowFrom = allowFrom; - else delete dm.allowFrom; - if (form.groupEnabled) dm.groupEnabled = true; - else delete dm.groupEnabled; - if (groupChannels.length > 0) dm.groupChannels = groupChannels; - else delete dm.groupChannels; - if (Object.keys(dm).length > 0) discord.dm = dm; - else delete discord.dm; - - const mediaMaxMb = Number(form.mediaMaxMb); - if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { - discord.mediaMaxMb = mediaMaxMb; - } else { - delete discord.mediaMaxMb; - } - - const historyLimitRaw = form.historyLimit.trim(); - if (historyLimitRaw.length === 0) { - delete discord.historyLimit; - } else { - const historyLimit = Number(historyLimitRaw); - if (Number.isFinite(historyLimit) && historyLimit >= 0) { - discord.historyLimit = historyLimit; - } else { - delete discord.historyLimit; - } - } - - const chunkLimitRaw = form.textChunkLimit.trim(); - if (chunkLimitRaw.length === 0) { - delete discord.textChunkLimit; - } else { - const chunkLimit = Number(chunkLimitRaw); - if (Number.isFinite(chunkLimit) && chunkLimit > 0) { - discord.textChunkLimit = chunkLimit; - } else { - delete discord.textChunkLimit; - } - } - - if (form.replyToMode === "off") { - delete discord.replyToMode; - } else { - discord.replyToMode = form.replyToMode; - } - - const guildsForm = Array.isArray(form.guilds) ? form.guilds : []; - const guilds: Record = {}; - guildsForm.forEach((guild: DiscordGuildForm) => { - const key = String(guild.key ?? "").trim(); - if (!key) return; - const entry: Record = {}; - const slug = String(guild.slug ?? "").trim(); - if (slug) entry.slug = slug; - if (guild.requireMention) entry.requireMention = true; - if ( - guild.reactionNotifications === "off" || - guild.reactionNotifications === "all" || - guild.reactionNotifications === "own" || - guild.reactionNotifications === "allowlist" - ) { - entry.reactionNotifications = guild.reactionNotifications; - } - const users = parseList(guild.users); - if (users.length > 0) entry.users = users; - const channels: Record = {}; - const channelForms = Array.isArray(guild.channels) ? guild.channels : []; - channelForms.forEach((channel: DiscordGuildChannelForm) => { - const channelKey = String(channel.key ?? "").trim(); - if (!channelKey) return; - const channelEntry: Record = {}; - if (channel.allow === false) channelEntry.allow = false; - if (channel.requireMention) channelEntry.requireMention = true; - channels[channelKey] = channelEntry; - }); - if (Object.keys(channels).length > 0) entry.channels = channels; - guilds[key] = entry; - }); - if (Object.keys(guilds).length > 0) discord.guilds = guilds; - else delete discord.guilds; - - const actions: Partial = {}; - const applyAction = (key: keyof DiscordActionForm) => { - const value = form.actions[key]; - if (value !== defaultDiscordActions[key]) actions[key] = value; - }; - applyAction("reactions"); - applyAction("stickers"); - applyAction("polls"); - applyAction("permissions"); - applyAction("messages"); - applyAction("threads"); - applyAction("pins"); - applyAction("search"); - applyAction("memberInfo"); - applyAction("roleInfo"); - applyAction("channelInfo"); - applyAction("voiceStatus"); - applyAction("events"); - applyAction("roles"); - applyAction("moderation"); - if (Object.keys(actions).length > 0) { - discord.actions = actions; - } else { - delete discord.actions; - } - - const slash = { ...(discord.slashCommand ?? {}) } as Record; - if (form.slashEnabled) { - slash.enabled = true; - } else { - delete slash.enabled; - } - if (form.slashName.trim()) slash.name = form.slashName.trim(); - else delete slash.name; - if (form.slashSessionPrefix.trim()) - slash.sessionPrefix = form.slashSessionPrefix.trim(); - else delete slash.sessionPrefix; - if (form.slashEphemeral) { - delete slash.ephemeral; - } else { - slash.ephemeral = false; - } - if (Object.keys(slash).length > 0) discord.slashCommand = slash; - else delete discord.slashCommand; - - if (Object.keys(discord).length > 0) { - config.discord = discord; - } else { - delete config.discord; - } - - const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; - await state.client.request("config.set", { raw }); - state.discordConfigStatus = "Saved. Restart gateway if needed."; - } catch (err) { - state.discordConfigStatus = String(err); - } finally { - state.discordSaving = false; - } -} - -export async function saveSlackConfig(state: ConnectionsState) { - if (!state.client || !state.connected) return; - if (state.slackSaving) return; - state.slackSaving = true; - state.slackConfigStatus = null; - try { - const base = state.configSnapshot?.config ?? {}; - const config = { ...base } as Record; - const slack = { ...(config.slack ?? {}) } as Record; - const form = state.slackForm; - - if (form.enabled) { - delete slack.enabled; - } else { - slack.enabled = false; - } - - if (!state.slackTokenLocked) { - const token = form.botToken.trim(); - if (token) slack.botToken = token; - else delete slack.botToken; - } - if (!state.slackAppTokenLocked) { - const token = form.appToken.trim(); - if (token) slack.appToken = token; - else delete slack.appToken; - } - - const dm = { ...(slack.dm ?? {}) } as Record; - dm.enabled = form.dmEnabled; - const allowFrom = parseList(form.allowFrom); - if (allowFrom.length > 0) dm.allowFrom = allowFrom; - else delete dm.allowFrom; - if (form.groupEnabled) { - dm.groupEnabled = true; - } else { - delete dm.groupEnabled; - } - const groupChannels = parseList(form.groupChannels); - if (groupChannels.length > 0) dm.groupChannels = groupChannels; - else delete dm.groupChannels; - if (Object.keys(dm).length > 0) slack.dm = dm; - else delete slack.dm; - - const mediaMaxMb = Number.parseFloat(form.mediaMaxMb); - if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { - slack.mediaMaxMb = mediaMaxMb; - } else { - delete slack.mediaMaxMb; - } - - const textChunkLimit = Number.parseInt(form.textChunkLimit, 10); - if (Number.isFinite(textChunkLimit) && textChunkLimit > 0) { - slack.textChunkLimit = textChunkLimit; - } else { - delete slack.textChunkLimit; - } - - if (form.reactionNotifications === "own") { - delete slack.reactionNotifications; - } else { - slack.reactionNotifications = form.reactionNotifications; - } - const reactionAllowlist = parseList(form.reactionAllowlist); - if (reactionAllowlist.length > 0) { - slack.reactionAllowlist = reactionAllowlist; - } else { - delete slack.reactionAllowlist; - } - - const slash = { ...(slack.slashCommand ?? {}) } as Record; - if (form.slashEnabled) { - slash.enabled = true; - } else { - delete slash.enabled; - } - if (form.slashName.trim()) slash.name = form.slashName.trim(); - else delete slash.name; - if (form.slashSessionPrefix.trim()) - slash.sessionPrefix = form.slashSessionPrefix.trim(); - else delete slash.sessionPrefix; - if (form.slashEphemeral) { - delete slash.ephemeral; - } else { - slash.ephemeral = false; - } - if (Object.keys(slash).length > 0) slack.slashCommand = slash; - else delete slack.slashCommand; - - const actions: Partial = {}; - const applyAction = (key: keyof SlackActionForm) => { - const value = form.actions[key]; - if (value !== defaultSlackActions[key]) actions[key] = value; - }; - applyAction("reactions"); - applyAction("messages"); - applyAction("pins"); - applyAction("memberInfo"); - applyAction("emojiList"); - if (Object.keys(actions).length > 0) { - slack.actions = actions; - } else { - delete slack.actions; - } - - const channels = form.channels - .map((entry): [string, Record] | null => { - const key = entry.key.trim(); - if (!key) return null; - const record: Record = { - allow: entry.allow, - requireMention: entry.requireMention, - }; - return [key, record]; - }) - .filter((value): value is [string, Record] => Boolean(value)); - if (channels.length > 0) { - slack.channels = Object.fromEntries(channels); - } else { - delete slack.channels; - } - - if (Object.keys(slack).length > 0) { - config.slack = slack; - } else { - delete config.slack; - } - - const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; - await state.client.request("config.set", { raw }); - state.slackConfigStatus = "Saved. Restart gateway if needed."; - } catch (err) { - state.slackConfigStatus = String(err); - } finally { - state.slackSaving = false; - } -} - -export async function saveSignalConfig(state: ConnectionsState) { - if (!state.client || !state.connected) return; - if (state.signalSaving) return; - state.signalSaving = true; - state.signalConfigStatus = null; - try { - const base = state.configSnapshot?.config ?? {}; - const config = { ...base } as Record; - const signal = { ...(config.signal ?? {}) } as Record; - const form = state.signalForm; - - if (form.enabled) { - delete signal.enabled; - } else { - signal.enabled = false; - } - - const account = form.account.trim(); - if (account) signal.account = account; - else delete signal.account; - - const httpUrl = form.httpUrl.trim(); - if (httpUrl) signal.httpUrl = httpUrl; - else delete signal.httpUrl; - - const httpHost = form.httpHost.trim(); - if (httpHost) signal.httpHost = httpHost; - else delete signal.httpHost; - - const httpPort = Number(form.httpPort); - if (Number.isFinite(httpPort) && httpPort > 0) { - signal.httpPort = httpPort; - } else { - delete signal.httpPort; - } - - const cliPath = form.cliPath.trim(); - if (cliPath) signal.cliPath = cliPath; - else delete signal.cliPath; - - if (form.autoStart) { - delete signal.autoStart; - } else { - signal.autoStart = false; - } - - if (form.receiveMode === "on-start" || form.receiveMode === "manual") { - signal.receiveMode = form.receiveMode; - } else { - delete signal.receiveMode; - } - - if (form.ignoreAttachments) signal.ignoreAttachments = true; - else delete signal.ignoreAttachments; - if (form.ignoreStories) signal.ignoreStories = true; - else delete signal.ignoreStories; - if (form.sendReadReceipts) signal.sendReadReceipts = true; - else delete signal.sendReadReceipts; - - const allowFrom = parseList(form.allowFrom); - if (allowFrom.length > 0) signal.allowFrom = allowFrom; - else delete signal.allowFrom; - - const mediaMaxMb = Number(form.mediaMaxMb); - if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { - signal.mediaMaxMb = mediaMaxMb; - } else { - delete signal.mediaMaxMb; - } - - if (Object.keys(signal).length > 0) { - config.signal = signal; - } else { - delete config.signal; - } - - const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; - await state.client.request("config.set", { raw }); - state.signalConfigStatus = "Saved. Restart gateway if needed."; - } catch (err) { - state.signalConfigStatus = String(err); - } finally { - state.signalSaving = false; - } -} - -export async function saveIMessageConfig(state: ConnectionsState) { - if (!state.client || !state.connected) return; - if (state.imessageSaving) return; - state.imessageSaving = true; - state.imessageConfigStatus = null; - try { - const base = state.configSnapshot?.config ?? {}; - const config = { ...base } as Record; - const imessage = { ...(config.imessage ?? {}) } as Record; - const form = state.imessageForm; - - if (form.enabled) { - delete imessage.enabled; - } else { - imessage.enabled = false; - } - - const cliPath = form.cliPath.trim(); - if (cliPath) imessage.cliPath = cliPath; - else delete imessage.cliPath; - - const dbPath = form.dbPath.trim(); - if (dbPath) imessage.dbPath = dbPath; - else delete imessage.dbPath; - - if (form.service === "auto") { - delete imessage.service; - } else { - imessage.service = form.service; - } - - const region = form.region.trim(); - if (region) imessage.region = region; - else delete imessage.region; - - const allowFrom = parseList(form.allowFrom); - if (allowFrom.length > 0) imessage.allowFrom = allowFrom; - else delete imessage.allowFrom; - - if (form.includeAttachments) imessage.includeAttachments = true; - else delete imessage.includeAttachments; - - const mediaMaxMb = Number(form.mediaMaxMb); - if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { - imessage.mediaMaxMb = mediaMaxMb; - } else { - delete imessage.mediaMaxMb; - } - - if (Object.keys(imessage).length > 0) { - config.imessage = imessage; - } else { - delete config.imessage; - } - - const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; - await state.client.request("config.set", { raw }); - state.imessageConfigStatus = "Saved. Restart gateway if needed."; - } catch (err) { - state.imessageConfigStatus = String(err); - } finally { - state.imessageSaving = false; - } -} diff --git a/ui/src/ui/controllers/connections.types.ts b/ui/src/ui/controllers/connections.types.ts new file mode 100644 index 000000000..d01bb428c --- /dev/null +++ b/ui/src/ui/controllers/connections.types.ts @@ -0,0 +1,43 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { ChannelsStatusSnapshot, ConfigSnapshot } from "../types"; +import type { + DiscordForm, + IMessageForm, + SlackForm, + SignalForm, + TelegramForm, +} from "../ui-types"; + +export type ConnectionsState = { + client: GatewayBrowserClient | null; + connected: boolean; + channelsLoading: boolean; + channelsSnapshot: ChannelsStatusSnapshot | null; + channelsError: string | null; + channelsLastSuccess: number | null; + whatsappLoginMessage: string | null; + whatsappLoginQrDataUrl: string | null; + whatsappLoginConnected: boolean | null; + whatsappBusy: boolean; + telegramForm: TelegramForm; + telegramSaving: boolean; + telegramTokenLocked: boolean; + telegramConfigStatus: string | null; + discordForm: DiscordForm; + discordSaving: boolean; + discordTokenLocked: boolean; + discordConfigStatus: string | null; + slackForm: SlackForm; + slackSaving: boolean; + slackTokenLocked: boolean; + slackAppTokenLocked: boolean; + slackConfigStatus: string | null; + signalForm: SignalForm; + signalSaving: boolean; + signalConfigStatus: string | null; + imessageForm: IMessageForm; + imessageSaving: boolean; + imessageConfigStatus: string | null; + configSnapshot: ConfigSnapshot | null; +}; + diff --git a/ui/src/ui/views/config-form.analyze.ts b/ui/src/ui/views/config-form.analyze.ts new file mode 100644 index 000000000..4153fe058 --- /dev/null +++ b/ui/src/ui/views/config-form.analyze.ts @@ -0,0 +1,121 @@ +import { pathKey, schemaType, type JsonSchema } from "./config-form.shared"; + +export type ConfigSchemaAnalysis = { + schema: JsonSchema | null; + unsupportedPaths: string[]; +}; + +export function analyzeConfigSchema(raw: unknown): ConfigSchemaAnalysis { + if (!raw || typeof raw !== "object") { + return { schema: null, unsupportedPaths: [""] }; + } + return normalizeSchemaNode(raw as JsonSchema, []); +} + +function normalizeSchemaNode( + schema: JsonSchema, + path: Array, +): ConfigSchemaAnalysis { + const unsupportedPaths: string[] = []; + const normalized: JsonSchema = { ...schema }; + const pathLabel = pathKey(path) || ""; + + if (schema.anyOf || schema.oneOf || schema.allOf) { + const union = normalizeUnion(schema, path); + if (union) return union; + unsupportedPaths.push(pathLabel); + return { schema, unsupportedPaths }; + } + + const nullable = Array.isArray(schema.type) && schema.type.includes("null"); + const type = + schemaType(schema) ?? + (schema.properties || schema.additionalProperties ? "object" : undefined); + normalized.type = type ?? schema.type; + + if (nullable && !normalized.nullable) { + normalized.nullable = true; + } + + if (type === "object") { + const properties = schema.properties ?? {}; + const normalizedProps: Record = {}; + for (const [key, value] of Object.entries(properties)) { + const res = normalizeSchemaNode(value, [...path, key]); + normalizedProps[key] = res.schema ?? value; + unsupportedPaths.push(...res.unsupportedPaths); + } + normalized.properties = normalizedProps; + + if ( + schema.additionalProperties && + typeof schema.additionalProperties === "object" + ) { + const res = normalizeSchemaNode( + schema.additionalProperties as JsonSchema, + [...path, "*"], + ); + normalized.additionalProperties = + res.schema ?? schema.additionalProperties; + unsupportedPaths.push(...res.unsupportedPaths); + } + } + + if (type === "array" && schema.items && !Array.isArray(schema.items)) { + const res = normalizeSchemaNode(schema.items, [...path, 0]); + normalized.items = res.schema ?? schema.items; + unsupportedPaths.push(...res.unsupportedPaths); + } + + return { schema: normalized, unsupportedPaths }; +} + +function normalizeUnion( + schema: JsonSchema, + path: Array, +): ConfigSchemaAnalysis | null { + const union = schema.anyOf ?? schema.oneOf ?? schema.allOf ?? []; + const pathLabel = pathKey(path) || ""; + if (union.length === 0) return null; + + const nonNull = union.filter( + (v) => + !( + v.type === "null" || + (Array.isArray(v.type) && v.type.includes("null")) + ), + ); + + if (nonNull.length === 1) { + const res = normalizeSchemaNode(nonNull[0], path); + return { + schema: { ...(res.schema ?? nonNull[0]), nullable: true }, + unsupportedPaths: res.unsupportedPaths, + }; + } + + const literals = nonNull + .map((v) => { + if (v.const !== undefined) return v.const; + if (v.enum && v.enum.length === 1) return v.enum[0]; + return undefined; + }) + .filter((v) => v !== undefined); + + if (literals.length === nonNull.length) { + return { + schema: { + ...schema, + anyOf: undefined, + oneOf: undefined, + allOf: undefined, + type: "string", + enum: literals as unknown[], + }, + unsupportedPaths: [], + }; + } + + return { schema, unsupportedPaths: [pathLabel] }; +} + diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts new file mode 100644 index 000000000..f2563827c --- /dev/null +++ b/ui/src/ui/views/config-form.node.ts @@ -0,0 +1,338 @@ +import { html, nothing, type TemplateResult } from "lit"; +import type { ConfigUiHints } from "../types"; +import { + defaultValue, + hintForPath, + humanize, + isSensitivePath, + pathKey, + schemaType, + type JsonSchema, +} from "./config-form.shared"; + +export function renderNode(params: { + schema: JsonSchema; + value: unknown; + path: Array; + hints: ConfigUiHints; + unsupported: Set; + disabled: boolean; + showLabel?: boolean; + onPatch: (path: Array, value: unknown) => void; +}): TemplateResult | typeof nothing { + const { schema, value, path, hints, unsupported, disabled, onPatch } = params; + const showLabel = params.showLabel ?? true; + const type = schemaType(schema); + const hint = hintForPath(path, hints); + const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1))); + const help = hint?.help ?? schema.description; + const key = pathKey(path); + + if (unsupported.has(key)) { + return html`
+ ${label}: unsupported schema node. Use Raw. +
`; + } + + if (schema.anyOf || schema.oneOf) { + const variants = schema.anyOf ?? schema.oneOf ?? []; + const nonNull = variants.filter( + (v) => + !( + v.type === "null" || + (Array.isArray(v.type) && v.type.includes("null")) + ), + ); + + if (nonNull.length === 1) { + return renderNode({ ...params, schema: nonNull[0] }); + } + + const extractLiteral = (v: JsonSchema): unknown | undefined => { + if (v.const !== undefined) return v.const; + if (v.enum && v.enum.length === 1) return v.enum[0]; + return undefined; + }; + const literals = nonNull.map(extractLiteral); + const allLiterals = literals.every((v) => v !== undefined); + + if (allLiterals && literals.length > 0) { + const currentIndex = literals.findIndex( + (lit) => lit === value || String(lit) === String(value), + ); + return html` + + `; + } + } + + if (type === "object") { + const obj = (value ?? {}) as Record; + const props = schema.properties ?? {}; + const entries = Object.entries(props); + const sorted = entries.sort((a, b) => { + const orderA = hintForPath([...path, a[0]], hints)?.order ?? 0; + const orderB = hintForPath([...path, b[0]], hints)?.order ?? 0; + if (orderA !== orderB) return orderA - orderB; + return a[0].localeCompare(b[0]); + }); + const reserved = new Set(Object.keys(props)); + const additional = schema.additionalProperties; + const allowExtra = Boolean(additional) && typeof additional === "object"; + + return html` +
+ ${showLabel ? html`
${label}
` : nothing} + ${help ? html`
${help}
` : nothing} + + ${sorted.map(([propKey, node]) => + renderNode({ + schema: node, + value: obj[propKey], + path: [...path, propKey], + hints, + unsupported, + disabled, + onPatch, + }), + )} + + ${allowExtra + ? renderMapField({ + schema: additional as JsonSchema, + value: obj, + path, + hints, + unsupported, + disabled, + reservedKeys: reserved, + onPatch, + }) + : nothing} +
+ `; + } + + if (type === "array") { + const itemsSchema = Array.isArray(schema.items) + ? schema.items[0] + : schema.items; + if (!itemsSchema) { + return html`
+ ${showLabel ? html`${label}` : nothing} +
Unsupported array schema. Use Raw.
+
`; + } + const arr = Array.isArray(value) ? value : []; + return html` +
+ ${showLabel ? html`${label}` : nothing} + ${help ? html`
${help}
` : nothing} +
+ ${arr.map((item, idx) => { + const itemPath = [...path, idx]; + return html`
+
+ ${renderNode({ + schema: itemsSchema, + value: item, + path: itemPath, + hints, + unsupported, + disabled, + showLabel: false, + onPatch, + })} +
+ +
`; + })} + +
+
+ `; + } + + if (type === "boolean") { + return html` + + `; + } + + if (type === "number" || type === "integer") { + return html` + + `; + } + + if (type === "string") { + const isSensitive = hint?.sensitive ?? isSensitivePath(path); + const placeholder = hint?.placeholder ?? (isSensitive ? "••••" : ""); + return html` + + `; + } + + return html`
+ ${showLabel ? html`${label}` : nothing} +
Unsupported type. Use Raw.
+
`; +} + +function renderMapField(params: { + schema: JsonSchema; + value: Record; + path: Array; + hints: ConfigUiHints; + unsupported: Set; + disabled: boolean; + reservedKeys: Set; + onPatch: (path: Array, value: unknown) => void; +}): TemplateResult { + const { schema, value, path, hints, unsupported, disabled, reservedKeys, onPatch } = + params; + const entries = Object.entries(value ?? {}).filter( + ([key]) => !reservedKeys.has(key), + ); + return html` +
+
+ Extra entries + +
+ ${entries.length === 0 + ? html`
No entries yet.
` + : entries.map(([key, entryValue]) => { + const valuePath = [...path, key]; + return html`
+ { + const nextKey = (e.target as HTMLInputElement).value.trim(); + if (!nextKey || nextKey === key) return; + const next = { ...(value ?? {}) }; + if (nextKey in next) return; + next[nextKey] = next[key]; + delete next[key]; + onPatch(path, next); + }} + /> +
+ ${renderNode({ + schema, + value: entryValue, + path: valuePath, + hints, + unsupported, + disabled, + showLabel: false, + onPatch, + })} +
+ +
`; + })} +
+ `; +} + diff --git a/ui/src/ui/views/config-form.render.ts b/ui/src/ui/views/config-form.render.ts new file mode 100644 index 000000000..c21c276fc --- /dev/null +++ b/ui/src/ui/views/config-form.render.ts @@ -0,0 +1,49 @@ +import { html } from "lit"; +import type { ConfigUiHints } from "../types"; +import { hintForPath, schemaType, type JsonSchema } from "./config-form.shared"; +import { renderNode } from "./config-form.node"; + +export type ConfigFormProps = { + schema: JsonSchema | null; + uiHints: ConfigUiHints; + value: Record | null; + disabled?: boolean; + unsupportedPaths?: string[]; + onPatch: (path: Array, value: unknown) => void; +}; + +export function renderConfigForm(props: ConfigFormProps) { + if (!props.schema) { + return html`
Schema unavailable.
`; + } + const schema = props.schema; + const value = props.value ?? {}; + if (schemaType(schema) !== "object" || !schema.properties) { + return html`
Unsupported schema. Use Raw.
`; + } + const unsupported = new Set(props.unsupportedPaths ?? []); + const entries = Object.entries(schema.properties); + const sorted = entries.sort((a, b) => { + const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 0; + const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 0; + if (orderA !== orderB) return orderA - orderB; + return a[0].localeCompare(b[0]); + }); + + return html` +
+ ${sorted.map(([key, node]) => + renderNode({ + schema: node, + value: (value as Record)[key], + path: [key], + hints: props.uiHints, + unsupported, + disabled: props.disabled ?? false, + onPatch: props.onPatch, + }), + )} +
+ `; +} + diff --git a/ui/src/ui/views/config-form.shared.ts b/ui/src/ui/views/config-form.shared.ts new file mode 100644 index 000000000..b37969a93 --- /dev/null +++ b/ui/src/ui/views/config-form.shared.ts @@ -0,0 +1,92 @@ +import type { ConfigUiHints } from "../types"; + +export type JsonSchema = { + type?: string | string[]; + title?: string; + description?: string; + properties?: Record; + items?: JsonSchema | JsonSchema[]; + additionalProperties?: JsonSchema | boolean; + enum?: unknown[]; + const?: unknown; + default?: unknown; + anyOf?: JsonSchema[]; + oneOf?: JsonSchema[]; + allOf?: JsonSchema[]; + nullable?: boolean; +}; + +export function schemaType(schema: JsonSchema): string | undefined { + if (!schema) return undefined; + if (Array.isArray(schema.type)) { + const filtered = schema.type.filter((t) => t !== "null"); + return filtered[0] ?? schema.type[0]; + } + return schema.type; +} + +export function defaultValue(schema?: JsonSchema): unknown { + if (!schema) return ""; + if (schema.default !== undefined) return schema.default; + const type = schemaType(schema); + switch (type) { + case "object": + return {}; + case "array": + return []; + case "boolean": + return false; + case "number": + case "integer": + return 0; + case "string": + return ""; + default: + return ""; + } +} + +export function pathKey(path: Array): string { + return path.filter((segment) => typeof segment === "string").join("."); +} + +export function hintForPath(path: Array, hints: ConfigUiHints) { + const key = pathKey(path); + const direct = hints[key]; + if (direct) return direct; + const segments = key.split("."); + for (const [hintKey, hint] of Object.entries(hints)) { + if (!hintKey.includes("*")) continue; + const hintSegments = hintKey.split("."); + if (hintSegments.length !== segments.length) continue; + let match = true; + for (let i = 0; i < segments.length; i += 1) { + if (hintSegments[i] !== "*" && hintSegments[i] !== segments[i]) { + match = false; + break; + } + } + if (match) return hint; + } + return undefined; +} + +export function humanize(raw: string) { + return raw + .replace(/_/g, " ") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/\s+/g, " ") + .replace(/^./, (m) => m.toUpperCase()); +} + +export function isSensitivePath(path: Array): boolean { + const key = pathKey(path).toLowerCase(); + return ( + key.includes("token") || + key.includes("password") || + key.includes("secret") || + key.includes("apikey") || + key.endsWith("key") + ); +} + diff --git a/ui/src/ui/views/config-form.ts b/ui/src/ui/views/config-form.ts index 09d3f82c3..d62222b20 100644 --- a/ui/src/ui/views/config-form.ts +++ b/ui/src/ui/views/config-form.ts @@ -1,711 +1,7 @@ -import { html, nothing, type TemplateResult } from "lit"; -import type { ConfigUiHint, ConfigUiHints } from "../types"; +export { renderConfigForm, type ConfigFormProps } from "./config-form.render"; +export { + analyzeConfigSchema, + type ConfigSchemaAnalysis, +} from "./config-form.analyze"; +export type { JsonSchema } from "./config-form.shared"; -export type ConfigFormProps = { - schema: JsonSchema | null; - uiHints: ConfigUiHints; - value: Record | null; - disabled?: boolean; - unsupportedPaths?: string[]; - onPatch: (path: Array, value: unknown) => void; -}; - -type JsonSchema = { - type?: string | string[]; - title?: string; - description?: string; - properties?: Record; - items?: JsonSchema | JsonSchema[]; - additionalProperties?: JsonSchema | boolean; - enum?: unknown[]; - const?: unknown; - default?: unknown; - anyOf?: JsonSchema[]; - oneOf?: JsonSchema[]; - allOf?: JsonSchema[]; - nullable?: boolean; -}; - -export function renderConfigForm(props: ConfigFormProps) { - if (!props.schema) { - return html`
Schema unavailable.
`; - } - const schema = props.schema; - const value = props.value ?? {}; - if (schemaType(schema) !== "object" || !schema.properties) { - return html`
Unsupported schema. Use Raw.
`; - } - const unsupported = new Set(props.unsupportedPaths ?? []); - const entries = Object.entries(schema.properties); - const sorted = entries.sort((a, b) => { - const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 0; - const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 0; - if (orderA !== orderB) return orderA - orderB; - return a[0].localeCompare(b[0]); - }); - - return html` -
- ${sorted.map(([key, node]) => - renderNode({ - schema: node, - value: (value as Record)[key], - path: [key], - hints: props.uiHints, - unsupported, - disabled: props.disabled ?? false, - onPatch: props.onPatch, - }), - )} -
- `; -} - -function renderNode(params: { - schema: JsonSchema; - value: unknown; - path: Array; - hints: ConfigUiHints; - unsupported: Set; - disabled: boolean; - showLabel?: boolean; - onPatch: (path: Array, value: unknown) => void; -}): TemplateResult | typeof nothing { - const { schema, value, path, hints, unsupported, disabled, onPatch } = params; - const showLabel = params.showLabel ?? true; - const type = schemaType(schema); - const hint = hintForPath(path, hints); - const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1))); - const help = hint?.help ?? schema.description; - const key = pathKey(path); - - if (unsupported.has(key)) { - return html`
- ${label}: unsupported schema node. Use Raw. -
`; - } - - if (schema.anyOf || schema.oneOf) { - const variants = schema.anyOf ?? schema.oneOf ?? []; - const nonNull = variants.filter( - (v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null"))), - ); - - if (nonNull.length === 1) { - return renderNode({ ...params, schema: nonNull[0] }); - } - - const extractLiteral = (v: JsonSchema): unknown | undefined => { - if (v.const !== undefined) return v.const; - if (v.enum && v.enum.length === 1) return v.enum[0]; - return undefined; - }; - const literals = nonNull.map(extractLiteral); - const allLiterals = literals.every((v) => v !== undefined); - - if (allLiterals && literals.length > 0) { - const currentIndex = literals.findIndex( - (lit) => lit === value || String(lit) === String(value), - ); - return html` - - `; - } - - const primitiveTypes = ["string", "number", "integer", "boolean"]; - const allPrimitive = nonNull.every((v) => v.type && primitiveTypes.includes(String(v.type))); - if (allPrimitive) { - const typeHint = nonNull.map((v) => v.type).join(" | "); - const hasBoolean = nonNull.some((v) => v.type === "boolean"); - const hasNumber = nonNull.some((v) => v.type === "number" || v.type === "integer"); - const isInteger = nonNull.every((v) => v.type !== "number"); - return html` - - `; - } - - return html`
- ${label}: unsupported schema node. Use Raw. -
`; - } - - if (schema.allOf) { - return html`
- ${label}: unsupported schema node. Use Raw. -
`; - } - - if (type === "object") { - const props = schema.properties ?? {}; - const entries = Object.entries(props); - const hasMap = - schema.additionalProperties && - typeof schema.additionalProperties === "object"; - if (entries.length === 0 && !hasMap) return nothing; - const reservedKeys = new Set(entries.map(([key]) => key)); - return html` -
- ${label} - ${help ? html`
${help}
` : nothing} - ${entries.map(([key, node]) => - renderNode({ - schema: node, - value: value && typeof value === "object" ? (value as any)[key] : undefined, - path: [...path, key], - hints, - unsupported, - onPatch, - disabled, - }), - )} - ${hasMap - ? renderMapField({ - schema: schema.additionalProperties as JsonSchema, - value: value && typeof value === "object" ? (value as any) : {}, - path, - hints, - unsupported, - disabled, - reservedKeys, - onPatch, - }) - : nothing} -
- `; - } - - if (type === "array") { - const itemSchema = Array.isArray(schema.items) - ? schema.items[0] - : schema.items; - const arr = Array.isArray(value) ? value : []; - return html` -
-
- ${showLabel ? html`${label}` : nothing} - -
- ${help ? html`
${help}
` : nothing} - ${arr.map((entry, index) => - html`
- ${itemSchema - ? renderNode({ - schema: itemSchema, - value: entry, - path: [...path, index], - hints, - unsupported, - disabled, - onPatch, - }) - : nothing} - -
`, - )} -
- `; - } - - if (schema.enum) { - const enumValues = schema.enum; - const currentIndex = enumValues.findIndex( - (v) => v === value || String(v) === String(value), - ); - const unsetValue = "__unset__"; - return html` - - `; - } - - if (type === "boolean") { - return html` - - `; - } - - if (type === "number" || type === "integer") { - return html` - - `; - } - - if (type === "string") { - const isSensitive = hint?.sensitive ?? isSensitivePath(path); - const placeholder = hint?.placeholder ?? (isSensitive ? "••••" : ""); - return html` - - `; - } - - return html`
- ${showLabel ? html`${label}` : nothing} -
Unsupported type. Use Raw.
-
`; -} - -function schemaType(schema: JsonSchema): string | undefined { - if (!schema) return undefined; - if (Array.isArray(schema.type)) { - const filtered = schema.type.filter((t) => t !== "null"); - return filtered[0] ?? schema.type[0]; - } - return schema.type; -} - -function defaultValue(schema?: JsonSchema): unknown { - if (!schema) return ""; - if (schema.default !== undefined) return schema.default; - const type = schemaType(schema); - switch (type) { - case "object": - return {}; - case "array": - return []; - case "boolean": - return false; - case "number": - case "integer": - return 0; - case "string": - return ""; - default: - return ""; - } -} - -function hintForPath(path: Array, hints: ConfigUiHints) { - const key = pathKey(path); - const direct = hints[key]; - if (direct) return direct; - const segments = key.split("."); - for (const [hintKey, hint] of Object.entries(hints)) { - if (!hintKey.includes("*")) continue; - const hintSegments = hintKey.split("."); - if (hintSegments.length !== segments.length) continue; - let match = true; - for (let i = 0; i < segments.length; i += 1) { - if (hintSegments[i] !== "*" && hintSegments[i] !== segments[i]) { - match = false; - break; - } - } - if (match) return hint; - } - return undefined; -} - -function pathKey(path: Array): string { - return path.filter((segment) => typeof segment === "string").join("."); -} - -function humanize(raw: string) { - return raw - .replace(/_/g, " ") - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .replace(/\s+/g, " ") - .replace(/^./, (m) => m.toUpperCase()); -} - -function isSensitivePath(path: Array): boolean { - const key = pathKey(path).toLowerCase(); - return ( - key.includes("token") || - key.includes("password") || - key.includes("secret") || - key.includes("apikey") || - key.endsWith("key") - ); -} - -function renderMapField(params: { - schema: JsonSchema; - value: Record; - path: Array; - hints: ConfigUiHints; - unsupported: Set; - disabled: boolean; - reservedKeys: Set; - onPatch: (path: Array, value: unknown) => void; -}): TemplateResult { - const { - schema, - value, - path, - hints, - unsupported, - disabled, - reservedKeys, - onPatch, - } = params; - const entries = Object.entries(value ?? {}).filter( - ([key]) => !reservedKeys.has(key), - ); - return html` -
-
- Extra entries - -
- ${entries.length === 0 - ? html`
No entries yet.
` - : entries.map(([key, entryValue]) => { - const valuePath = [...path, key]; - return html`
- { - const nextKey = (e.target as HTMLInputElement).value.trim(); - if (!nextKey || nextKey === key) return; - const next = { ...(value ?? {}) }; - if (nextKey in next) return; - next[nextKey] = next[key]; - delete next[key]; - onPatch(path, next); - }} - /> -
- ${renderNode({ - schema, - value: entryValue, - path: valuePath, - hints, - unsupported, - disabled, - showLabel: false, - onPatch, - })} -
- -
`; - })} -
- `; -} - -export type ConfigSchemaAnalysis = { - schema: JsonSchema | null; - unsupportedPaths: string[]; -}; - -export function analyzeConfigSchema(raw: unknown): ConfigSchemaAnalysis { - if (!raw || typeof raw !== "object") { - return { schema: null, unsupportedPaths: [""] }; - } - const result = normalizeSchemaNode(raw as JsonSchema, []); - return result; -} - -function normalizeSchemaNode( - schema: JsonSchema, - path: Array, -): ConfigSchemaAnalysis { - const unsupportedPaths: string[] = []; - const normalized = { ...schema }; - const pathLabel = pathKey(path) || ""; - - if (schema.anyOf || schema.oneOf || schema.allOf) { - const union = normalizeUnion(schema, path); - if (union) return union; - unsupportedPaths.push(pathLabel); - return { schema, unsupportedPaths }; - } - - const nullable = - Array.isArray(schema.type) && schema.type.includes("null"); - const type = - schemaType(schema) ?? - (schema.properties || schema.additionalProperties ? "object" : undefined); - normalized.type = type ?? schema.type; - normalized.nullable = nullable || schema.nullable; - - if (normalized.enum) { - const { enumValues, nullable: enumNullable } = normalizeEnumValues( - normalized.enum, - ); - normalized.enum = enumValues; - if (enumNullable) normalized.nullable = true; - if (enumValues.length === 0) { - unsupportedPaths.push(pathLabel); - } - } - - if (type === "object") { - const props = schema.properties ?? {}; - const normalizedProps: Record = {}; - for (const [key, child] of Object.entries(props)) { - const result = normalizeSchemaNode(child, [...path, key]); - if (result.schema) normalizedProps[key] = result.schema; - unsupportedPaths.push(...result.unsupportedPaths); - } - normalized.properties = normalizedProps; - - if (schema.additionalProperties === true) { - unsupportedPaths.push(pathLabel); - } else if (schema.additionalProperties === false) { - normalized.additionalProperties = false; - } else if (schema.additionalProperties) { - const result = normalizeSchemaNode( - schema.additionalProperties, - [...path, "*"], - ); - normalized.additionalProperties = result.schema ?? schema.additionalProperties; - if (result.unsupportedPaths.length > 0) { - unsupportedPaths.push(pathLabel); - } - } - } else if (type === "array") { - const itemSchema = Array.isArray(schema.items) - ? schema.items[0] - : schema.items; - if (!itemSchema) { - unsupportedPaths.push(pathLabel); - } else { - const result = normalizeSchemaNode(itemSchema, [...path, "*"]); - normalized.items = result.schema ?? itemSchema; - if (result.unsupportedPaths.length > 0) { - unsupportedPaths.push(pathLabel); - } - } - } else if ( - type === "string" || - type === "number" || - type === "integer" || - type === "boolean" - ) { - // ok - } else if (!normalized.enum) { - unsupportedPaths.push(pathLabel); - } - - return { - schema: normalized, - unsupportedPaths: Array.from(new Set(unsupportedPaths)), - }; -} - -function normalizeUnion( - schema: JsonSchema, - path: Array, -): ConfigSchemaAnalysis | null { - if (schema.allOf) return null; - const variants = schema.anyOf ?? schema.oneOf; - if (!variants) return null; - const values: unknown[] = []; - const nonLiteral: JsonSchema[] = []; - let nullable = false; - for (const variant of variants) { - if (!variant || typeof variant !== "object") return null; - if (Array.isArray(variant.enum)) { - const { enumValues, nullable: enumNullable } = normalizeEnumValues( - variant.enum, - ); - values.push(...enumValues); - if (enumNullable) nullable = true; - continue; - } - if ("const" in variant) { - if (variant.const === null || variant.const === undefined) { - nullable = true; - continue; - } - values.push(variant.const); - continue; - } - if (schemaType(variant) === "null") { - nullable = true; - continue; - } - nonLiteral.push(variant); - } - - if (values.length > 0 && nonLiteral.length === 0) { - const unique: unknown[] = []; - for (const value of values) { - if (!unique.some((entry) => Object.is(entry, value))) unique.push(value); - } - return { - schema: { - ...schema, - enum: unique, - nullable, - anyOf: undefined, - oneOf: undefined, - allOf: undefined, - }, - unsupportedPaths: [], - }; - } - - if (nonLiteral.length === 1) { - const result = normalizeSchemaNode(nonLiteral[0], path); - if (result.schema) { - result.schema.nullable = nullable || result.schema.nullable; - } - return result; - } - - const primitiveTypes = ["string", "number", "integer", "boolean"]; - const allPrimitive = nonLiteral.every( - (v) => v.type && primitiveTypes.includes(String(v.type)), - ); - if (allPrimitive && nonLiteral.length > 0 && values.length === 0) { - return { - schema: { ...schema, nullable }, - unsupportedPaths: [], - }; - } - - return null; -} - -function normalizeEnumValues(values: unknown[]) { - const filtered = values.filter((value) => value !== null && value !== undefined); - const nullable = filtered.length !== values.length; - const unique: unknown[] = []; - for (const value of filtered) { - if (!unique.some((entry) => Object.is(entry, value))) unique.push(value); - } - return { enumValues: unique, nullable }; -} diff --git a/ui/src/ui/views/connections.discord.actions.ts b/ui/src/ui/views/connections.discord.actions.ts new file mode 100644 index 000000000..283f3ff19 --- /dev/null +++ b/ui/src/ui/views/connections.discord.actions.ts @@ -0,0 +1,31 @@ +import { html } from "lit"; + +import type { ConnectionsProps } from "./connections.types"; +import { discordActionOptions } from "./connections.action-options"; + +export function renderDiscordActionsSection(props: ConnectionsProps) { + return html` +
Tool actions
+
+ ${discordActionOptions.map( + (action) => html``, + )} +
+ `; +} + diff --git a/ui/src/ui/views/connections.discord.guilds.ts b/ui/src/ui/views/connections.discord.guilds.ts new file mode 100644 index 000000000..2d4fd32d1 --- /dev/null +++ b/ui/src/ui/views/connections.discord.guilds.ts @@ -0,0 +1,262 @@ +import { html, nothing } from "lit"; + +import type { ConnectionsProps } from "./connections.types"; + +export function renderDiscordGuildsEditor(props: ConnectionsProps) { + return html` +
+ Guilds +
+ Add each guild (id or slug) and optional channel rules. Empty channel + entries still allow that channel. +
+
+ ${props.discordForm.guilds.map( + (guild, guildIndex) => html` +
+
+
+ + + + + +
+ ${guild.channels.length + ? html` +
+ ${guild.channels.map( + (channel, channelIndex) => html` + + + + + `, + )} +
+ ` + : nothing} +
+
+ Channels + + +
+
+ `, + )} +
+ +
+ `; +} + diff --git a/ui/src/ui/views/connections.discord.ts b/ui/src/ui/views/connections.discord.ts new file mode 100644 index 000000000..54a84e316 --- /dev/null +++ b/ui/src/ui/views/connections.discord.ts @@ -0,0 +1,261 @@ +import { html, nothing } from "lit"; + +import { formatAgo } from "../format"; +import type { DiscordStatus } from "../types"; +import type { ConnectionsProps } from "./connections.types"; +import { renderDiscordActionsSection } from "./connections.discord.actions"; +import { renderDiscordGuildsEditor } from "./connections.discord.guilds"; + +export function renderDiscordCard(params: { + props: ConnectionsProps; + discord: DiscordStatus | null; + accountCountLabel: unknown; +}) { + const { props, discord, accountCountLabel } = params; + const botName = discord?.probe?.bot?.username; + + return html` +
+
Discord
+
Bot connection and probe status.
+ ${accountCountLabel} + +
+
+ Configured + ${discord?.configured ? "Yes" : "No"} +
+
+ Running + ${discord?.running ? "Yes" : "No"} +
+
+ Bot + ${botName ? `@${botName}` : "n/a"} +
+
+ Last start + ${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"} +
+
+ Last probe + ${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"} +
+
+ + ${discord?.lastError + ? html`
+ ${discord.lastError} +
` + : nothing} + + ${discord?.probe + ? html`
+ Probe ${discord.probe.ok ? "ok" : "failed"} · + ${discord.probe.status ?? ""} ${discord.probe.error ?? ""} +
` + : nothing} + +
+ + + + + + + + + + + ${renderDiscordGuildsEditor(props)} + + + + +
+ + ${renderDiscordActionsSection(props)} + + ${props.discordTokenLocked + ? html`
+ DISCORD_BOT_TOKEN is set in the environment. Config edits will not + override it. +
` + : nothing} + + ${props.discordStatus + ? html`
+ ${props.discordStatus} +
` + : nothing} + +
+ + +
+
+ `; +} diff --git a/ui/src/ui/views/connections.imessage.ts b/ui/src/ui/views/connections.imessage.ts new file mode 100644 index 000000000..fcdbf0b12 --- /dev/null +++ b/ui/src/ui/views/connections.imessage.ts @@ -0,0 +1,184 @@ +import { html, nothing } from "lit"; + +import { formatAgo } from "../format"; +import type { IMessageStatus } from "../types"; +import type { ConnectionsProps } from "./connections.types"; + +export function renderIMessageCard(params: { + props: ConnectionsProps; + imessage: IMessageStatus | null; + accountCountLabel: unknown; +}) { + const { props, imessage, accountCountLabel } = params; + + return html` +
+
iMessage
+
imsg CLI and database availability.
+ ${accountCountLabel} + +
+
+ Configured + ${imessage?.configured ? "Yes" : "No"} +
+
+ Running + ${imessage?.running ? "Yes" : "No"} +
+
+ CLI + ${imessage?.cliPath ?? "n/a"} +
+
+ DB + ${imessage?.dbPath ?? "n/a"} +
+
+ Last start + + ${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"} + +
+
+ Last probe + + ${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"} + +
+
+ + ${imessage?.lastError + ? html`
+ ${imessage.lastError} +
` + : nothing} + + ${imessage?.probe && !imessage.probe.ok + ? html`
+ Probe failed · ${imessage.probe.error ?? "unknown error"} +
` + : nothing} + +
+ + + + + + + + +
+ + ${props.imessageStatus + ? html`
+ ${props.imessageStatus} +
` + : nothing} + +
+ + +
+
+ `; +} + diff --git a/ui/src/ui/views/connections.signal.ts b/ui/src/ui/views/connections.signal.ts new file mode 100644 index 000000000..ca04d6bc9 --- /dev/null +++ b/ui/src/ui/views/connections.signal.ts @@ -0,0 +1,237 @@ +import { html, nothing } from "lit"; + +import { formatAgo } from "../format"; +import type { SignalStatus } from "../types"; +import type { ConnectionsProps } from "./connections.types"; + +export function renderSignalCard(params: { + props: ConnectionsProps; + signal: SignalStatus | null; + accountCountLabel: unknown; +}) { + const { props, signal, accountCountLabel } = params; + + return html` +
+
Signal
+
REST daemon status and probe details.
+ ${accountCountLabel} + +
+
+ Configured + ${signal?.configured ? "Yes" : "No"} +
+
+ Running + ${signal?.running ? "Yes" : "No"} +
+
+ Base URL + ${signal?.baseUrl ?? "n/a"} +
+
+ Last start + ${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"} +
+
+ Last probe + ${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"} +
+
+ + ${signal?.lastError + ? html`
+ ${signal.lastError} +
` + : nothing} + + ${signal?.probe + ? html`
+ Probe ${signal.probe.ok ? "ok" : "failed"} · + ${signal.probe.status ?? ""} ${signal.probe.error ?? ""} +
` + : nothing} + +
+ + + + + + + + + + + + + +
+ + ${props.signalStatus + ? html`
+ ${props.signalStatus} +
` + : nothing} + +
+ + +
+
+ `; +} + diff --git a/ui/src/ui/views/connections.slack.ts b/ui/src/ui/views/connections.slack.ts new file mode 100644 index 000000000..4116c649d --- /dev/null +++ b/ui/src/ui/views/connections.slack.ts @@ -0,0 +1,391 @@ +import { html, nothing } from "lit"; + +import { formatAgo } from "../format"; +import type { SlackStatus } from "../types"; +import type { ConnectionsProps } from "./connections.types"; +import { slackActionOptions } from "./connections.action-options"; + +export function renderSlackCard(params: { + props: ConnectionsProps; + slack: SlackStatus | null; + accountCountLabel: unknown; +}) { + const { props, slack, accountCountLabel } = params; + const botName = slack?.probe?.bot?.name; + const teamName = slack?.probe?.team?.name; + + return html` +
+
Slack
+
Socket mode status and bot details.
+ ${accountCountLabel} + +
+
+ Configured + ${slack?.configured ? "Yes" : "No"} +
+
+ Running + ${slack?.running ? "Yes" : "No"} +
+
+ Bot + ${botName ? botName : "n/a"} +
+
+ Team + ${teamName ? teamName : "n/a"} +
+
+ Last start + ${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"} +
+
+ Last probe + ${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"} +
+
+ + ${slack?.lastError + ? html`
+ ${slack.lastError} +
` + : nothing} + + ${slack?.probe + ? html`
+ Probe ${slack.probe.ok ? "ok" : "failed"} · ${slack.probe.status ?? ""} + ${slack.probe.error ?? ""} +
` + : nothing} + +
+ + + + + + + + + + + +
+ +
Slash command
+
+ + + + +
+ +
Channels
+
Add channel ids or #names and optionally require mentions.
+
+ ${props.slackForm.channels.map( + (channel, channelIndex) => html` +
+
+
+ + + + +
+
+
+ `, + )} +
+ + +
Tool actions
+
+ ${slackActionOptions.map( + (action) => html``, + )} +
+ + ${props.slackTokenLocked || props.slackAppTokenLocked + ? html`
+ ${props.slackTokenLocked ? "SLACK_BOT_TOKEN " : ""} + ${props.slackAppTokenLocked ? "SLACK_APP_TOKEN " : ""} is set in the + environment. Config edits will not override it. +
` + : nothing} + + ${props.slackStatus + ? html`
+ ${props.slackStatus} +
` + : nothing} + +
+ + +
+
+ `; +} + diff --git a/ui/src/ui/views/connections.ts b/ui/src/ui/views/connections.ts index 42bbaf602..2c7aef25e 100644 --- a/ui/src/ui/views/connections.ts +++ b/ui/src/ui/views/connections.ts @@ -2,8 +2,6 @@ import { html, nothing } from "lit"; import { formatAgo } from "../format"; import type { - ChannelAccountSnapshot, - ChannelsStatusSnapshot, DiscordStatus, IMessageStatus, SignalStatus, @@ -11,20 +9,16 @@ import type { TelegramStatus, WhatsAppStatus, } from "../types"; -import type { - DiscordForm, - IMessageForm, - SlackForm, - SignalForm, - TelegramForm, -} from "../ui-types"; import type { ChannelKey, ConnectionsChannelData, ConnectionsProps, } from "./connections.types"; -import { channelEnabled, formatDuration, renderChannelAccountCount } from "./connections.shared"; -import { discordActionOptions, slackActionOptions } from "./connections.action-options"; +import { channelEnabled, renderChannelAccountCount } from "./connections.shared"; +import { renderDiscordCard } from "./connections.discord"; +import { renderIMessageCard } from "./connections.imessage"; +import { renderSignalCard } from "./connections.signal"; +import { renderSlackCard } from "./connections.slack"; import { renderTelegramCard } from "./connections.telegram"; import { renderWhatsAppCard } from "./connections.whatsapp"; @@ -117,1318 +111,30 @@ function renderChannel( telegramAccounts: data.channelAccounts?.telegram ?? [], accountCountLabel, }); - case "discord": { - const discord = data.discord; - const botName = discord?.probe?.bot?.username; - return html` -
-
Discord
-
Bot connection and probe status.
- ${accountCountLabel} - -
-
- Configured - ${discord?.configured ? "Yes" : "No"} -
-
- Running - ${discord?.running ? "Yes" : "No"} -
-
- Bot - ${botName ? `@${botName}` : "n/a"} -
-
- Last start - ${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"} -
-
- Last probe - ${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"} -
-
- - ${discord?.lastError - ? html`
- ${discord.lastError} -
` - : nothing} - - ${discord?.probe - ? html`
- Probe ${discord.probe.ok ? "ok" : "failed"} · - ${discord.probe.status ?? ""} - ${discord.probe.error ?? ""} -
` - : nothing} - -
- - - - - - - - - - -
- Guilds -
- Add each guild (id or slug) and optional channel rules. Empty channel - entries still allow that channel. -
-
- ${props.discordForm.guilds.map( - (guild, guildIndex) => html` -
-
-
- - - - - -
- ${guild.channels.length - ? html` -
- ${guild.channels.map( - (channel, channelIndex) => html` - - - - - `, - )} -
- ` - : nothing} -
-
- Channels - - -
-
- `, - )} -
- -
- - - - -
- -
Tool actions
-
- ${discordActionOptions.map( - (action) => html``, - )} -
- - ${props.discordTokenLocked - ? html`
- DISCORD_BOT_TOKEN is set in the environment. Config edits will not override it. -
` - : nothing} - - ${props.discordStatus - ? html`
- ${props.discordStatus} -
` - : nothing} - -
- - -
-
- `; - } - case "slack": { - const slack = data.slack; - const botName = slack?.probe?.bot?.name; - const teamName = slack?.probe?.team?.name; - return html` -
-
Slack
-
Socket mode status and bot details.
- ${accountCountLabel} - -
-
- Configured - ${slack?.configured ? "Yes" : "No"} -
-
- Running - ${slack?.running ? "Yes" : "No"} -
-
- Bot - ${botName ? botName : "n/a"} -
-
- Team - ${teamName ? teamName : "n/a"} -
-
- Last start - ${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"} -
-
- Last probe - ${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"} -
-
- - ${slack?.lastError - ? html`
- ${slack.lastError} -
` - : nothing} - - ${slack?.probe - ? html`
- Probe ${slack.probe.ok ? "ok" : "failed"} · - ${slack.probe.status ?? ""} - ${slack.probe.error ?? ""} -
` - : nothing} - -
- - - - - - - - - - - -
- -
Slash command
-
- - - - -
- -
Channels
-
- Add channel ids or #names and optionally require mentions. -
-
- ${props.slackForm.channels.map( - (channel, channelIndex) => html` -
-
-
- - - - -
-
-
- `, - )} -
- - -
Tool actions
-
- ${slackActionOptions.map( - (action) => html``, - )} -
- - ${props.slackTokenLocked || props.slackAppTokenLocked - ? html`
- ${props.slackTokenLocked ? "SLACK_BOT_TOKEN " : ""} - ${props.slackAppTokenLocked ? "SLACK_APP_TOKEN " : ""} - is set in the environment. Config edits will not override it. -
` - : nothing} - - ${props.slackStatus - ? html`
- ${props.slackStatus} -
` - : nothing} - -
- - -
-
- `; - } - case "signal": { - const signal = data.signal; - return html` -
-
Signal
-
REST daemon status and probe details.
- ${accountCountLabel} - -
-
- Configured - ${signal?.configured ? "Yes" : "No"} -
-
- Running - ${signal?.running ? "Yes" : "No"} -
-
- Base URL - ${signal?.baseUrl ?? "n/a"} -
-
- Last start - ${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"} -
-
- Last probe - ${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"} -
-
- - ${signal?.lastError - ? html`
- ${signal.lastError} -
` - : nothing} - - ${signal?.probe - ? html`
- Probe ${signal.probe.ok ? "ok" : "failed"} · - ${signal.probe.status ?? ""} - ${signal.probe.error ?? ""} -
` - : nothing} - -
- - - - - - - - - - - - - -
- - ${props.signalStatus - ? html`
- ${props.signalStatus} -
` - : nothing} - -
- - -
-
- `; - } - case "imessage": { - const imessage = data.imessage; - return html` -
-
iMessage
-
imsg CLI and database availability.
- ${accountCountLabel} - -
-
- Configured - ${imessage?.configured ? "Yes" : "No"} -
-
- Running - ${imessage?.running ? "Yes" : "No"} -
-
- CLI - ${imessage?.cliPath ?? "n/a"} -
-
- DB - ${imessage?.dbPath ?? "n/a"} -
-
- Last start - - ${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"} - -
-
- Last probe - - ${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"} - -
-
- - ${imessage?.lastError - ? html`
- ${imessage.lastError} -
` - : nothing} - - ${imessage?.probe && !imessage.probe.ok - ? html`
- Probe failed · ${imessage.probe.error ?? "unknown error"} -
` - : nothing} - -
- - - - - - - - -
- - ${props.imessageStatus - ? html`
- ${props.imessageStatus} -
` - : nothing} - -
- - -
-
- `; - } + case "discord": + return renderDiscordCard({ + props, + discord: data.discord, + accountCountLabel, + }); + case "slack": + return renderSlackCard({ + props, + slack: data.slack, + accountCountLabel, + }); + case "signal": + return renderSignalCard({ + props, + signal: data.signal, + accountCountLabel, + }); + case "imessage": + return renderIMessageCard({ + props, + imessage: data.imessage, + accountCountLabel, + }); default: return nothing; } diff --git a/ui/src/ui/views/debug.ts b/ui/src/ui/views/debug.ts index f1284145c..35e2e1af2 100644 --- a/ui/src/ui/views/debug.ts +++ b/ui/src/ui/views/debug.ts @@ -1,12 +1,7 @@ import { html, nothing } from "lit"; import { formatEventPayload } from "../presenter"; - -type EventLogEntry = { - ts: number; - event: string; - payload?: unknown; -}; +import type { EventLogEntry } from "../app-events"; export type DebugProps = { loading: boolean; @@ -126,4 +121,3 @@ export function renderDebug(props: DebugProps) { `; } -