feat(gui): first-class Messaging page + gateway menu redesign
- Add Messaging page to the desktop app with per-platform setup, status, and inline guidance. Catalog derives from gateway.config Platform enum + plugin registry, so every messaging adapter the CLI supports (Telegram, Discord, Slack, Mattermost, Matrix, WhatsApp, Signal, BlueBubbles, Home Assistant, Email, SMS, DingTalk, Feishu, WeCom, Weixin, QQ, Yuanbao, API server, Webhooks, plugins) shows up without per-platform code. - New REST endpoints: GET /api/messaging/platforms, PUT and POST /test on the same path. Secrets go through the existing .env pipeline; enable/disable writes config.yaml. - Replace gateway statusbar dropdown with a richer panel: status row, icon-only restart + system-panel actions, recent activity (with timestamps trimmed in display, full text on hover), platform list. - Auto-poll the messaging page every 6s (paused when hidden) so status updates without a manual check. - Drop Settings / Command Center from the sidebar nav (still reachable via shortcuts and the titlebar cog). - Flatten top corners on Messaging/Skills/Artifacts/Chat panes. - Share new StatusDot component across messaging + gateway menu. - Fix gateway/config.py so an explicit platforms.<name>.enabled=false in config.yaml is honored when env tokens are present. - pb-9 on the chat content area for breathing room above the composer.
This commit is contained in:
@@ -497,13 +497,13 @@ export function ArtifactsView({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background">
|
||||
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
|
||||
<header className={titlebarHeaderBaseClass}>
|
||||
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Artifacts</h2>
|
||||
<span className="pointer-events-auto text-xs text-muted-foreground">{counts.all} found</span>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.0625rem] border border-border/50 bg-background/85">
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
|
||||
<div className="border-b border-border/50 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterButton
|
||||
|
||||
@@ -247,7 +247,7 @@ export function ChatView({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent',
|
||||
'relative flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-transparent',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -278,7 +278,7 @@ export function ChatView({
|
||||
|
||||
<NotificationStack />
|
||||
|
||||
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden rounded-[1.0625rem] bg-transparent contain-[layout_paint]">
|
||||
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden rounded-b-[1.0625rem] bg-transparent contain-[layout_paint]">
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread
|
||||
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from '@/components/ui/sidebar'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { Brain, ChevronDown, Command, Layers3, Pin, Plus, RefreshCw, Settings } from '@/lib/icons'
|
||||
import { Brain, ChevronDown, Layers3, MessageCircle, Pin, Plus, RefreshCw } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$pinnedSessionIds,
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from '@/store/layout'
|
||||
import { $selectedStoredSessionId, $sessions, $sessionsLoading, $workingSessionIds } from '@/store/session'
|
||||
|
||||
import { type AppView, ARTIFACTS_ROUTE, COMMAND_CENTER_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../../routes'
|
||||
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
|
||||
import type { SidebarNavItem } from '../../types'
|
||||
|
||||
import { SidebarSessionRow } from './session-row'
|
||||
@@ -41,10 +41,9 @@ const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
icon: Plus,
|
||||
action: 'new-session'
|
||||
},
|
||||
{ id: 'command-center', label: 'Command Center', icon: Command, route: COMMAND_CENTER_ROUTE },
|
||||
{ id: 'skills', label: 'Skills', icon: Brain, route: SKILLS_ROUTE },
|
||||
{ id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings, route: SETTINGS_ROUTE }
|
||||
{ id: 'messaging', label: 'Messaging', icon: MessageCircle, route: MESSAGING_ROUTE },
|
||||
{ id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE }
|
||||
]
|
||||
|
||||
const sidebarNavItemClass =
|
||||
@@ -124,9 +123,8 @@ export function ChatSidebar({
|
||||
const isInteractive = Boolean(item.action) || Boolean(item.route)
|
||||
|
||||
const active =
|
||||
(item.id === 'command-center' && currentView === 'command-center') ||
|
||||
(item.id === 'settings' && currentView === 'settings') ||
|
||||
(item.id === 'skills' && currentView === 'skills') ||
|
||||
(item.id === 'messaging' && currentView === 'messaging') ||
|
||||
(item.id === 'artifacts' && currentView === 'artifacts')
|
||||
|
||||
return (
|
||||
|
||||
@@ -43,7 +43,7 @@ import { OverlayActionButton, OverlayCard, overlayCardClass, OverlayIconButton }
|
||||
import { OverlaySearchInput } from '../overlays/overlay-search-input'
|
||||
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
import { ARTIFACTS_ROUTE, NEW_CHAT_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../routes'
|
||||
import { ARTIFACTS_ROUTE, MESSAGING_ROUTE, NEW_CHAT_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../routes'
|
||||
|
||||
export type CommandCenterSection = 'models' | 'sessions' | 'system'
|
||||
|
||||
@@ -86,6 +86,7 @@ const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
|
||||
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New chat', detail: 'Start a fresh session' },
|
||||
{ id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },
|
||||
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },
|
||||
{ id: 'nav-messaging', route: MESSAGING_ROUTE, title: 'Messaging', detail: 'Set up Telegram, Slack, Discord, and more' },
|
||||
{ id: 'nav-artifacts', route: ARTIFACTS_ROUTE, title: 'Artifacts', detail: 'Browse generated outputs' }
|
||||
]
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ import { useGroupRegistry } from './shell/use-group-registry'
|
||||
const AgentsView = lazy(async () => ({ default: (await import('./agents')).AgentsView }))
|
||||
const ArtifactsView = lazy(async () => ({ default: (await import('./artifacts')).ArtifactsView }))
|
||||
const CommandCenterView = lazy(async () => ({ default: (await import('./command-center')).CommandCenterView }))
|
||||
const MessagingView = lazy(async () => ({ default: (await import('./messaging')).MessagingView }))
|
||||
const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView }))
|
||||
const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView }))
|
||||
|
||||
@@ -507,6 +508,17 @@ export function DesktopController() {
|
||||
}
|
||||
path="skills"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Suspense fallback={null}>
|
||||
<MessagingView
|
||||
setStatusbarItemGroup={setStatusbarItemGroup}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
path="messaging"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -0,0 +1,755 @@
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { StatusDot, type StatusTone } from '@/components/status-dot'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
getMessagingPlatforms,
|
||||
type MessagingEnvVarInfo,
|
||||
type MessagingPlatformInfo,
|
||||
updateMessagingPlatform
|
||||
} from '@/hermes'
|
||||
import { AlertTriangle, ChevronDown, ExternalLink, RefreshCw, Save, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
import { titlebarHeaderBaseClass } from '../shell/titlebar'
|
||||
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
|
||||
|
||||
interface MessagingViewProps extends React.ComponentProps<'section'> {
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
type EditMap = Record<string, Record<string, string>>
|
||||
|
||||
const STATE_LABELS: Record<string, string> = {
|
||||
connected: 'Connected',
|
||||
connecting: 'Connecting',
|
||||
disabled: 'Disabled',
|
||||
fatal: 'Error',
|
||||
gateway_stopped: 'Gateway stopped',
|
||||
not_configured: 'Needs setup',
|
||||
pending_restart: 'Restart needed',
|
||||
retrying: 'Retrying',
|
||||
startup_failed: 'Startup failed'
|
||||
}
|
||||
|
||||
const PLATFORM_TINTS: Record<string, string> = {
|
||||
telegram: 'bg-sky-500/15 text-sky-600 dark:text-sky-300',
|
||||
discord: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-300',
|
||||
slack: 'bg-violet-500/15 text-violet-600 dark:text-violet-300',
|
||||
mattermost: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
|
||||
matrix: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
|
||||
signal: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-300',
|
||||
whatsapp: 'bg-green-500/15 text-green-600 dark:text-green-300',
|
||||
bluebubbles: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
|
||||
homeassistant: 'bg-teal-500/15 text-teal-600 dark:text-teal-300',
|
||||
email: 'bg-amber-500/15 text-amber-600 dark:text-amber-300',
|
||||
sms: 'bg-rose-500/15 text-rose-600 dark:text-rose-300',
|
||||
dingtalk: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
|
||||
feishu: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-300',
|
||||
wecom: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
|
||||
wecom_callback: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
|
||||
weixin: 'bg-green-500/15 text-green-600 dark:text-green-300',
|
||||
qqbot: 'bg-amber-500/15 text-amber-600 dark:text-amber-300',
|
||||
yuanbao: 'bg-orange-500/15 text-orange-600 dark:text-orange-300',
|
||||
api_server: 'bg-slate-500/15 text-slate-600 dark:text-slate-300',
|
||||
webhook: 'bg-zinc-500/15 text-zinc-600 dark:text-zinc-300'
|
||||
}
|
||||
|
||||
const PILL_TONE: Record<StatusTone, string> = {
|
||||
good: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
|
||||
bad: 'bg-destructive/10 text-destructive'
|
||||
}
|
||||
|
||||
const HINT_BY_STATE: Record<string, string> = {
|
||||
pending_restart: 'Restart the gateway from the status bar to apply this change.',
|
||||
gateway_stopped: 'Start the gateway from the status bar to connect.'
|
||||
}
|
||||
|
||||
const stateLabel = (state?: null | string) => (state ? STATE_LABELS[state] || state.replace(/_/g, ' ') : 'Unknown')
|
||||
|
||||
function stateTone({ enabled, state }: MessagingPlatformInfo): StatusTone {
|
||||
if (!enabled) {return 'muted'}
|
||||
|
||||
if (state === 'connected') {return 'good'}
|
||||
|
||||
if (state === 'fatal' || state === 'startup_failed') {return 'bad'}
|
||||
|
||||
return 'warn'
|
||||
}
|
||||
|
||||
const trimEdits = (edits: Record<string, string>): Record<string, string> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(edits)
|
||||
.map(([k, v]) => [k, v.trim()])
|
||||
.filter(([, v]) => v)
|
||||
)
|
||||
|
||||
const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: string; placeholder?: string }> = {
|
||||
TELEGRAM_BOT_TOKEN: {
|
||||
label: 'Bot token',
|
||||
help: 'Create a bot with @BotFather, then paste the token it gives you.',
|
||||
placeholder: '123456:ABC...'
|
||||
},
|
||||
TELEGRAM_ALLOWED_USERS: {
|
||||
label: 'Allowed Telegram user IDs',
|
||||
help: 'Recommended. Comma-separated numeric IDs from @userinfobot. Without this, anyone can DM your bot.'
|
||||
},
|
||||
TELEGRAM_PROXY: {
|
||||
label: 'Proxy URL',
|
||||
help: 'Only needed on networks where Telegram is blocked.',
|
||||
advanced: true
|
||||
},
|
||||
DISCORD_BOT_TOKEN: {
|
||||
label: 'Bot token',
|
||||
help: 'Create an application in the Discord Developer Portal, add a bot, then paste its token.'
|
||||
},
|
||||
DISCORD_ALLOWED_USERS: {
|
||||
label: 'Allowed Discord user IDs',
|
||||
help: 'Recommended. Comma-separated Discord user IDs.'
|
||||
},
|
||||
DISCORD_REPLY_TO_MODE: {
|
||||
label: 'Reply style',
|
||||
help: 'first, all, or off.',
|
||||
advanced: true
|
||||
},
|
||||
SLACK_BOT_TOKEN: {
|
||||
label: 'Slack bot token',
|
||||
help: 'Starts with xoxb-. Found under OAuth & Permissions after installing your Slack app.',
|
||||
placeholder: 'xoxb-...'
|
||||
},
|
||||
SLACK_APP_TOKEN: {
|
||||
label: 'Slack app token',
|
||||
help: 'Starts with xapp-. Required for Socket Mode.',
|
||||
placeholder: 'xapp-...'
|
||||
},
|
||||
SLACK_ALLOWED_USERS: {
|
||||
label: 'Allowed Slack user IDs',
|
||||
help: 'Recommended. Comma-separated Slack user IDs.'
|
||||
},
|
||||
MATTERMOST_URL: {
|
||||
label: 'Server URL',
|
||||
placeholder: 'https://mattermost.example.com'
|
||||
},
|
||||
MATTERMOST_TOKEN: {
|
||||
label: 'Bot token'
|
||||
},
|
||||
MATTERMOST_ALLOWED_USERS: {
|
||||
label: 'Allowed user IDs',
|
||||
help: 'Recommended. Comma-separated Mattermost user IDs.'
|
||||
},
|
||||
MATRIX_HOMESERVER: {
|
||||
label: 'Homeserver URL',
|
||||
placeholder: 'https://matrix.org'
|
||||
},
|
||||
MATRIX_ACCESS_TOKEN: {
|
||||
label: 'Access token'
|
||||
},
|
||||
MATRIX_USER_ID: {
|
||||
label: 'Bot user ID',
|
||||
placeholder: '@hermes:example.org'
|
||||
},
|
||||
MATRIX_ALLOWED_USERS: {
|
||||
label: 'Allowed Matrix user IDs',
|
||||
help: 'Recommended. Comma-separated user IDs in @user:server format.'
|
||||
},
|
||||
SIGNAL_HTTP_URL: {
|
||||
label: 'Signal bridge URL',
|
||||
placeholder: 'http://127.0.0.1:8080',
|
||||
help: 'URL of a running signal-cli REST bridge.'
|
||||
},
|
||||
SIGNAL_ACCOUNT: {
|
||||
label: 'Phone number',
|
||||
help: 'The number registered with your signal-cli bridge.'
|
||||
},
|
||||
SIGNAL_ALLOWED_USERS: {
|
||||
label: 'Allowed Signal users',
|
||||
help: 'Recommended. Comma-separated Signal identifiers.'
|
||||
},
|
||||
WHATSAPP_ENABLED: {
|
||||
label: 'Enable WhatsApp bridge',
|
||||
help: 'Set automatically by the toggle below. Leave alone unless you know you need it.',
|
||||
advanced: true
|
||||
},
|
||||
WHATSAPP_MODE: {
|
||||
label: 'Bridge mode',
|
||||
advanced: true
|
||||
},
|
||||
WHATSAPP_ALLOWED_USERS: {
|
||||
label: 'Allowed WhatsApp users',
|
||||
help: 'Recommended. Comma-separated phone numbers or WhatsApp IDs.'
|
||||
}
|
||||
}
|
||||
|
||||
function fieldCopy(field: MessagingEnvVarInfo) {
|
||||
const copy = FIELD_COPY[field.key] || {}
|
||||
|
||||
return {
|
||||
label: copy.label || field.prompt || field.key,
|
||||
help: copy.help || field.description,
|
||||
placeholder: copy.placeholder || field.prompt,
|
||||
advanced: Boolean(copy.advanced || field.advanced)
|
||||
}
|
||||
}
|
||||
|
||||
export function MessagingView({
|
||||
setStatusbarItemGroup: _setStatusbarItemGroup,
|
||||
setTitlebarToolGroup,
|
||||
...props
|
||||
}: MessagingViewProps) {
|
||||
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [edits, setEdits] = useState<EditMap>({})
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
|
||||
const refreshPlatforms = useCallback(async (silent = false) => {
|
||||
if (!silent) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getMessagingPlatforms()
|
||||
setPlatforms(result.platforms)
|
||||
setSelectedId(current => current || result.platforms[0]?.id || null)
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
notifyError(err, 'Messaging platforms failed to load')
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshPlatforms()
|
||||
}, [refreshPlatforms])
|
||||
|
||||
// Auto-poll while the user is on the messaging page so connection status
|
||||
// updates without a manual "check" click. Pause when the tab is hidden.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
function tick() {
|
||||
if (cancelled || document.hidden) {
|
||||
return
|
||||
}
|
||||
|
||||
void refreshPlatforms(true)
|
||||
}
|
||||
|
||||
const id = window.setInterval(tick, 6000)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, [refreshPlatforms])
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTitlebarToolGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
setTitlebarToolGroup('messaging', [
|
||||
{
|
||||
disabled: refreshing,
|
||||
icon: <RefreshCw className={cn(refreshing && 'animate-spin')} />,
|
||||
id: 'refresh-messaging',
|
||||
label: refreshing ? 'Refreshing messaging' : 'Refresh messaging',
|
||||
onSelect: () => void refreshPlatforms()
|
||||
}
|
||||
])
|
||||
|
||||
return () => setTitlebarToolGroup('messaging', [])
|
||||
}, [refreshPlatforms, refreshing, setTitlebarToolGroup])
|
||||
|
||||
const selected = useMemo(() => {
|
||||
if (!platforms) {
|
||||
return null
|
||||
}
|
||||
|
||||
return platforms.find(platform => platform.id === selectedId) || platforms[0] || null
|
||||
}, [platforms, selectedId])
|
||||
|
||||
const enabledCount = platforms?.filter(platform => platform.enabled).length || 0
|
||||
|
||||
async function handleToggle(platform: MessagingPlatformInfo, enabled: boolean) {
|
||||
setSaving(`enabled:${platform.id}`)
|
||||
|
||||
try {
|
||||
await updateMessagingPlatform(platform.id, { enabled })
|
||||
setPlatforms(
|
||||
current =>
|
||||
current?.map(row =>
|
||||
row.id === platform.id
|
||||
? {
|
||||
...row,
|
||||
enabled,
|
||||
state: enabled ? (row.configured ? 'pending_restart' : 'not_configured') : 'disabled'
|
||||
}
|
||||
: row
|
||||
) ?? current
|
||||
)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? `${platform.name} enabled` : `${platform.name} disabled`,
|
||||
message: 'Restart the gateway for this change to take effect.'
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to update ${platform.name}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(platform: MessagingPlatformInfo) {
|
||||
const env = trimEdits(edits[platform.id] || {})
|
||||
|
||||
if (Object.keys(env).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(`env:${platform.id}`)
|
||||
|
||||
try {
|
||||
await updateMessagingPlatform(platform.id, { env })
|
||||
setEdits(current => ({ ...current, [platform.id]: {} }))
|
||||
await refreshPlatforms()
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: `${platform.name} setup saved`,
|
||||
message: 'Restart the gateway to reconnect with the new credentials.'
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${platform.name}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(platform: MessagingPlatformInfo, key: string) {
|
||||
setSaving(`clear:${key}`)
|
||||
|
||||
try {
|
||||
await updateMessagingPlatform(platform.id, { clear_env: [key] })
|
||||
setEdits(current => ({
|
||||
...current,
|
||||
[platform.id]: {
|
||||
...(current[platform.id] || {}),
|
||||
[key]: ''
|
||||
}
|
||||
}))
|
||||
await refreshPlatforms()
|
||||
notify({ kind: 'success', title: `${key} cleared`, message: `${platform.name} setup was updated.` })
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to clear ${key}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
|
||||
<header className={titlebarHeaderBaseClass}>
|
||||
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Messaging</h2>
|
||||
<span className="pointer-events-auto text-xs text-muted-foreground">
|
||||
{enabledCount === 0 ? 'No platforms enabled' : `${enabledCount} enabled`}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
|
||||
{!platforms ? (
|
||||
<PageLoader label="Loading messaging platforms..." />
|
||||
) : (
|
||||
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[16rem_minmax(0,1fr)]">
|
||||
<aside className="min-h-0 overflow-y-auto border-b border-border/50 p-2 lg:border-b-0 lg:border-r">
|
||||
<ul className="space-y-1">
|
||||
{platforms.map(platform => (
|
||||
<li key={platform.id}>
|
||||
<PlatformRow
|
||||
active={selected?.id === platform.id}
|
||||
onSelect={() => setSelectedId(platform.id)}
|
||||
platform={platform}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main className="min-h-0 overflow-hidden">
|
||||
{selected && (
|
||||
<PlatformDetail
|
||||
edits={edits[selected.id] || {}}
|
||||
onClear={key => void handleClear(selected, key)}
|
||||
onEdit={(key, value) =>
|
||||
setEdits(current => ({
|
||||
...current,
|
||||
[selected.id]: {
|
||||
...(current[selected.id] || {}),
|
||||
[key]: value
|
||||
}
|
||||
}))
|
||||
}
|
||||
onSave={() => void handleSave(selected)}
|
||||
onToggle={enabled => void handleToggle(selected, enabled)}
|
||||
platform={selected}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function PlatformRow({
|
||||
active,
|
||||
onSelect,
|
||||
platform
|
||||
}: {
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
platform: MessagingPlatformInfo
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors',
|
||||
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
|
||||
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium">{platform.name}</span>
|
||||
<StatusDot tone={stateTone(platform)} />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PlatformAvatar({ platformId, platformName }: { platformId: string; platformName: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-7 shrink-0 items-center justify-center rounded-md text-sm font-semibold',
|
||||
PLATFORM_TINTS[platformId] || 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{platformName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function PlatformDetail({
|
||||
edits,
|
||||
onClear,
|
||||
onEdit,
|
||||
onSave,
|
||||
onToggle,
|
||||
platform,
|
||||
saving
|
||||
}: {
|
||||
edits: Record<string, string>
|
||||
onClear: (key: string) => void
|
||||
onEdit: (key: string, value: string) => void
|
||||
onSave: () => void
|
||||
onToggle: (enabled: boolean) => void
|
||||
platform: MessagingPlatformInfo
|
||||
saving: string | null
|
||||
}) {
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
const hasEdits = Object.keys(trimEdits(edits)).length > 0
|
||||
const requiredFields = platform.env_vars.filter(field => field.required)
|
||||
const optionalFields = platform.env_vars.filter(field => !field.required && !fieldCopy(field).advanced)
|
||||
const advancedFields = platform.env_vars.filter(field => !field.required && fieldCopy(field).advanced)
|
||||
const hiddenCount = advancedFields.length
|
||||
const isSavingEnv = saving === `env:${platform.id}`
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-2xl space-y-7 px-6 py-6">
|
||||
<header className="flex items-start gap-4">
|
||||
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-xl font-semibold tracking-tight">{platform.name}</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">{platform.description}</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state)}</StatePill>
|
||||
<SetupPill active={platform.configured}>
|
||||
{platform.configured ? 'Credentials set' : 'Needs setup'}
|
||||
</SetupPill>
|
||||
{!platform.gateway_running && <SetupPill active={false}>Gateway stopped</SetupPill>}
|
||||
</div>
|
||||
<PlatformHint platform={platform} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{platform.error_message && (
|
||||
<div className="flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-xs leading-5 text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{platform.error_message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<SectionTitle>Get your credentials</SectionTitle>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
{introCopy(platform)}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={platform.docs_url} rel="noreferrer" target="_blank">
|
||||
Open setup guide
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle>Required</SectionTitle>
|
||||
<div className="mt-3 space-y-4">
|
||||
{requiredFields.length > 0 ? (
|
||||
requiredFields.map(field => (
|
||||
<MessagingField
|
||||
edits={edits}
|
||||
field={field}
|
||||
key={field.key}
|
||||
onClear={onClear}
|
||||
onEdit={onEdit}
|
||||
saving={saving}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
This platform does not need a token here. Use the setup guide above, then enable it below.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{optionalFields.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle>Recommended</SectionTitle>
|
||||
<div className="mt-3 space-y-4">
|
||||
{optionalFields.map(field => (
|
||||
<MessagingField
|
||||
edits={edits}
|
||||
field={field}
|
||||
key={field.key}
|
||||
onClear={onClear}
|
||||
onEdit={onEdit}
|
||||
saving={saving}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hiddenCount > 0 && (
|
||||
<section>
|
||||
<button
|
||||
className="flex w-full items-center justify-between gap-2 rounded-lg px-1 py-1 text-left text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowAdvanced(value => !value)}
|
||||
type="button"
|
||||
>
|
||||
<span>Advanced ({hiddenCount})</span>
|
||||
<ChevronDown
|
||||
className={cn('size-3.5 transition-transform', !showAdvanced && '-rotate-90')}
|
||||
/>
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-3 space-y-4">
|
||||
{advancedFields.map(field => (
|
||||
<MessagingField
|
||||
edits={edits}
|
||||
field={field}
|
||||
key={field.key}
|
||||
onClear={onClear}
|
||||
onEdit={onEdit}
|
||||
saving={saving}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-border/50 bg-background/95 px-6 py-3 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">
|
||||
<label className="flex shrink-0 items-center gap-2 rounded-lg border border-border/50 bg-muted/25 px-3 py-1.5 text-sm">
|
||||
<Switch
|
||||
aria-label={platform.enabled ? `Disable ${platform.name}` : `Enable ${platform.name}`}
|
||||
checked={platform.enabled}
|
||||
disabled={saving === `enabled:${platform.id}`}
|
||||
onCheckedChange={onToggle}
|
||||
/>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{platform.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{hasEdits && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
|
||||
<Button disabled={!hasEdits || isSavingEnv} onClick={onSave} size="sm">
|
||||
<Save />
|
||||
{isSavingEnv ? 'Saving...' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PLATFORM_INTRO: Record<string, string> = {
|
||||
telegram:
|
||||
'In Telegram, talk to @BotFather, run /newbot, and copy the token it gives you. Then grab your numeric user ID from @userinfobot.',
|
||||
discord:
|
||||
'Open the Discord Developer Portal, create an application, add a Bot, then copy its token. Invite the bot to your server with the right scopes.',
|
||||
slack:
|
||||
'Create a Slack app, enable Socket Mode, install it to your workspace, then copy the Bot token (xoxb-) and App-level token (xapp-).',
|
||||
mattermost:
|
||||
'On your Mattermost server, create a bot account or personal access token, then paste the server URL and token here.',
|
||||
matrix: 'Sign in to your homeserver with the bot account, then copy the access token, user ID, and homeserver URL.',
|
||||
signal: 'Run a signal-cli REST bridge somewhere reachable, then point Hermes at the URL and the registered phone number.',
|
||||
whatsapp:
|
||||
'Start the WhatsApp bridge that ships with Hermes, scan the QR code on first run, then enable the platform.',
|
||||
bluebubbles:
|
||||
'Run BlueBubbles Server on a Mac with iMessage, expose its API, then point Hermes at the URL with the server password.',
|
||||
homeassistant:
|
||||
'In Home Assistant, open your profile and create a long-lived access token. Paste it here along with your HA URL.',
|
||||
email:
|
||||
'Use a dedicated mailbox. For Gmail/Workspace, create an app password and use imap.gmail.com / smtp.gmail.com.',
|
||||
sms: 'Get your Twilio Account SID and Auth Token from the Twilio console, plus a phone number that can send SMS.',
|
||||
dingtalk:
|
||||
'Create a DingTalk app in the developer console, then copy the Client ID (App key) and Client Secret here.',
|
||||
feishu:
|
||||
'Create a Feishu / Lark app, configure the bot capability, and copy the App ID, App secret, and event encryption keys.',
|
||||
wecom:
|
||||
'Add a group robot in WeCom and copy its webhook key as WECOM_BOT_ID. Send-only — use the WeCom (app) option for two-way.',
|
||||
wecom_callback:
|
||||
'Set up a WeCom self-built app, expose its callback URL, and provide the corp ID, secret, agent ID, and AES key.',
|
||||
weixin:
|
||||
'Sign in to the WeChat Official Account platform, copy the AppID and Token, and point the message callback URL at Hermes.',
|
||||
qqbot: 'Register an app on the QQ Open Platform (q.qq.com) and copy the App ID and Client Secret.',
|
||||
api_server:
|
||||
'Expose Hermes as an OpenAI-compatible API. Set an auth key, then point Open WebUI / LobeChat / etc. at the host:port.',
|
||||
webhook: 'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.'
|
||||
}
|
||||
|
||||
const introCopy = (platform: MessagingPlatformInfo) => PLATFORM_INTRO[platform.id] || platform.description
|
||||
|
||||
function MessagingField({
|
||||
edits,
|
||||
field,
|
||||
onClear,
|
||||
onEdit,
|
||||
saving
|
||||
}: {
|
||||
edits: Record<string, string>
|
||||
field: MessagingEnvVarInfo
|
||||
onClear: (key: string) => void
|
||||
onEdit: (key: string, value: string) => void
|
||||
saving: string | null
|
||||
}) {
|
||||
const copy = fieldCopy(field)
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor={`messaging-field-${field.key}`}>
|
||||
{copy.label}
|
||||
</label>
|
||||
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="h-9 rounded-lg font-mono text-sm"
|
||||
id={`messaging-field-${field.key}`}
|
||||
onChange={event => onEdit(field.key, event.target.value)}
|
||||
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder}
|
||||
type={field.is_password ? 'password' : 'text'}
|
||||
value={edits[field.key] || ''}
|
||||
/>
|
||||
{field.url && (
|
||||
<Button asChild size="icon-sm" title="Open docs" variant="ghost">
|
||||
<a href={field.url} rel="noreferrer" target="_blank">
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{field.is_set && (
|
||||
<Button
|
||||
disabled={saving === `clear:${field.key}`}
|
||||
onClick={() => onClear(field.key)}
|
||||
size="icon-sm"
|
||||
title={`Clear ${field.key}`}
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{copy.help && <p className="text-xs leading-5 text-muted-foreground">{copy.help}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h4 className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{children}</h4>
|
||||
)
|
||||
}
|
||||
|
||||
function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
|
||||
if (!platform.enabled || platform.state === 'connected') {return null}
|
||||
|
||||
const hint =
|
||||
HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped)
|
||||
|
||||
return hint ? <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p> : null
|
||||
}
|
||||
|
||||
function StatePill({ children, tone }: { children: string; tone: StatusTone }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
|
||||
PILL_TONE[tone]
|
||||
)}
|
||||
>
|
||||
<StatusDot tone={tone} />
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SetupPill({ active, children }: { active: boolean; children: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-[0.66rem] font-medium', PILL_TONE[active ? 'good' : 'muted'])}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -3,12 +3,13 @@ export const NEW_CHAT_ROUTE = '/'
|
||||
export const SETTINGS_ROUTE = '/settings'
|
||||
export const COMMAND_CENTER_ROUTE = '/command-center'
|
||||
export const SKILLS_ROUTE = '/skills'
|
||||
export const MESSAGING_ROUTE = '/messaging'
|
||||
export const ARTIFACTS_ROUTE = '/artifacts'
|
||||
export const AGENTS_ROUTE = '/agents'
|
||||
|
||||
export type AppView = 'chat' | 'settings' | 'command-center' | 'skills' | 'artifacts' | 'agents'
|
||||
export type AppView = 'chat' | 'settings' | 'command-center' | 'skills' | 'messaging' | 'artifacts' | 'agents'
|
||||
|
||||
export type AppRouteId = 'new' | 'settings' | 'command-center' | 'skills' | 'artifacts' | 'agents'
|
||||
export type AppRouteId = 'new' | 'settings' | 'command-center' | 'skills' | 'messaging' | 'artifacts' | 'agents'
|
||||
|
||||
export interface AppRoute {
|
||||
id: AppRouteId
|
||||
@@ -21,6 +22,7 @@ export const APP_ROUTES = [
|
||||
{ id: 'settings', path: SETTINGS_ROUTE, view: 'settings' },
|
||||
{ id: 'command-center', path: COMMAND_CENTER_ROUTE, view: 'command-center' },
|
||||
{ id: 'skills', path: SKILLS_ROUTE, view: 'skills' },
|
||||
{ id: 'messaging', path: MESSAGING_ROUTE, view: 'messaging' },
|
||||
{ id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' },
|
||||
{ id: 'agents', path: AGENTS_ROUTE, view: 'agents' }
|
||||
] as const satisfies readonly AppRoute[]
|
||||
|
||||
@@ -32,7 +32,12 @@ function rawHashLooksLikeSession(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
return !hash.startsWith('/settings') && !hash.startsWith('/skills') && !hash.startsWith('/artifacts')
|
||||
return (
|
||||
!hash.startsWith('/settings') &&
|
||||
!hash.startsWith('/skills') &&
|
||||
!hash.startsWith('/messaging') &&
|
||||
!hash.startsWith('/artifacts')
|
||||
)
|
||||
}
|
||||
|
||||
export function useRouteResume({
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { IconLayoutDashboard } from '@tabler/icons-react'
|
||||
|
||||
import { StatusDot, type StatusTone } from '@/components/status-dot'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Activity, AlertCircle, RefreshCw } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
interface GatewayMenuPanelProps {
|
||||
logLines: readonly string[]
|
||||
onOpenSystem: () => void
|
||||
onRestart: () => void
|
||||
restarting: boolean
|
||||
statusSnapshot: StatusResponse | null
|
||||
}
|
||||
|
||||
const PLATFORM_TONE: Record<string, StatusTone> = {
|
||||
connected: 'good',
|
||||
connecting: 'warn',
|
||||
retrying: 'warn',
|
||||
pending_restart: 'warn',
|
||||
startup_failed: 'bad',
|
||||
fatal: 'bad'
|
||||
}
|
||||
|
||||
const prettyState = (state: string) => state.replace(/_/g, ' ').replace(/^./, c => c.toUpperCase())
|
||||
|
||||
// Strip leading "YYYY-MM-DD HH:MM:SS,mmm " and "[runtime_id] " prefixes from
|
||||
// log lines so they don't dominate the display. Full text preserved on hover.
|
||||
const TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}[,.\d]*\s+/
|
||||
const RUNTIME_BRACKET_RE = /^\[[^\]]+]\s+/
|
||||
const trimLogLine = (raw: string) => raw.trim().replace(TIMESTAMP_RE, '').replace(RUNTIME_BRACKET_RE, '')
|
||||
|
||||
export function GatewayMenuPanel({
|
||||
logLines,
|
||||
onOpenSystem,
|
||||
onRestart,
|
||||
restarting,
|
||||
statusSnapshot
|
||||
}: GatewayMenuPanelProps) {
|
||||
const gatewayRunning = Boolean(statusSnapshot?.gateway_running)
|
||||
const platforms = Object.entries(statusSnapshot?.gateway_platforms || {}).sort(([l], [r]) => l.localeCompare(r))
|
||||
const stateLabel = gatewayRunning ? prettyState(statusSnapshot?.gateway_state || 'online') : 'Offline'
|
||||
const recentLogs = logLines.slice(-5)
|
||||
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{gatewayRunning ? (
|
||||
<Activity className="size-3.5 text-primary" />
|
||||
) : (
|
||||
<AlertCircle className="size-3.5 text-destructive" />
|
||||
)}
|
||||
<span className="font-medium">Gateway</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<StatusDot tone={gatewayRunning ? 'good' : 'bad'} />
|
||||
{stateLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
aria-label={restarting ? 'Restarting gateway' : 'Restart gateway'}
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
disabled={restarting}
|
||||
onClick={onRestart}
|
||||
size="icon-sm"
|
||||
title={restarting ? 'Restarting gateway' : 'Restart gateway'}
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw className={cn(restarting && 'animate-spin')} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Open system panel"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenSystem}
|
||||
size="icon-sm"
|
||||
title="Open system panel"
|
||||
variant="ghost"
|
||||
>
|
||||
<IconLayoutDashboard />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recentLogs.length > 0 && (
|
||||
<div className="border-t border-border/50 px-3 py-2">
|
||||
<SectionLabel>Recent activity</SectionLabel>
|
||||
<ul className="mt-1.5 space-y-0.5">
|
||||
{recentLogs.map((line, index) => (
|
||||
<li
|
||||
className="truncate font-mono text-[0.68rem] text-muted-foreground/85"
|
||||
key={`${index}:${line}`}
|
||||
title={line.trim()}
|
||||
>
|
||||
{trimLogLine(line) || '\u00A0'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
className="mt-1.5 text-[0.66rem] font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenSystem}
|
||||
type="button"
|
||||
>
|
||||
View all logs →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{platforms.length > 0 && (
|
||||
<div className="border-t border-border/50 px-3 py-2">
|
||||
<SectionLabel>Platforms</SectionLabel>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{platforms.map(([name, platform]) => (
|
||||
<li className="flex items-center justify-between gap-2 text-xs" key={name}>
|
||||
<span className="truncate capitalize">{name}</span>
|
||||
<span className="flex items-center gap-1.5 text-[0.66rem] text-muted-foreground">
|
||||
<StatusDot tone={PLATFORM_TONE[platform.state] || 'muted'} />
|
||||
{prettyState(platform.state)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="text-[0.62rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground/80">{children}</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
import { buildGatewayLogItems } from '@/lib/gateway-events'
|
||||
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
|
||||
import { restartGateway } from '@/hermes'
|
||||
import { Activity, AlertCircle, Command, Cpu, FolderOpen, GitBranch, Loader2, Sparkles } from '@/lib/icons'
|
||||
import { compactPath, contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopActionTasks } from '@/store/activity'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $previewServerRestartStatus } from '@/store/preview'
|
||||
import {
|
||||
$busy,
|
||||
@@ -22,7 +24,7 @@ import {
|
||||
} from '@/store/session'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
import type { StatusbarItem, StatusbarMenuItem } from '../statusbar-controls'
|
||||
import type { StatusbarItem } from '../statusbar-controls'
|
||||
|
||||
interface StatusbarItemsOptions {
|
||||
agentsOpen: boolean
|
||||
@@ -64,21 +66,40 @@ export function useStatusbarItems({
|
||||
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
|
||||
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
|
||||
|
||||
const platformMenuItems = useMemo<readonly StatusbarMenuItem[]>(
|
||||
() =>
|
||||
Object.entries(statusSnapshot?.gateway_platforms || {})
|
||||
.sort(([l], [r]) => l.localeCompare(r))
|
||||
.map(([name, platform]) => ({ disabled: true, id: `platform:${name}`, label: `${name} · ${platform.state}` })),
|
||||
[statusSnapshot?.gateway_platforms]
|
||||
)
|
||||
const [restartingGateway, setRestartingGateway] = useState(false)
|
||||
|
||||
const gatewayMenuItems = useMemo<readonly StatusbarMenuItem[]>(
|
||||
() => [
|
||||
{ id: 'gateway:open-system', label: 'Open system panel', onSelect: () => openCommandCenterSection('system') },
|
||||
...buildGatewayLogItems(gatewayLogLines),
|
||||
...platformMenuItems
|
||||
],
|
||||
[gatewayLogLines, openCommandCenterSection, platformMenuItems]
|
||||
const handleRestartGateway = useCallback(async () => {
|
||||
if (restartingGateway) {
|
||||
return
|
||||
}
|
||||
|
||||
setRestartingGateway(true)
|
||||
|
||||
try {
|
||||
await restartGateway()
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'Gateway restart requested',
|
||||
message: 'Status will update once the gateway reconnects.'
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to restart gateway')
|
||||
} finally {
|
||||
setRestartingGateway(false)
|
||||
}
|
||||
}, [restartingGateway])
|
||||
|
||||
const gatewayMenuContent = useMemo(
|
||||
() => (
|
||||
<GatewayMenuPanel
|
||||
logLines={gatewayLogLines}
|
||||
onOpenSystem={() => openCommandCenterSection('system')}
|
||||
onRestart={() => void handleRestartGateway()}
|
||||
restarting={restartingGateway}
|
||||
statusSnapshot={statusSnapshot}
|
||||
/>
|
||||
),
|
||||
[gatewayLogLines, handleRestartGateway, openCommandCenterSection, restartingGateway, statusSnapshot]
|
||||
)
|
||||
|
||||
const { bgFailed, bgRunning } = useMemo(() => {
|
||||
@@ -109,8 +130,8 @@ export function useStatusbarItems({
|
||||
icon: gatewayUp ? <Activity className="size-3" /> : <AlertCircle className="size-3" />,
|
||||
id: 'gateway-health',
|
||||
label: 'Gateway',
|
||||
menuClassName: 'w-96',
|
||||
menuItems: gatewayMenuItems,
|
||||
menuClassName: 'w-72',
|
||||
menuContent: gatewayMenuContent,
|
||||
title: 'Gateway and platform health',
|
||||
variant: 'menu'
|
||||
},
|
||||
@@ -140,7 +161,7 @@ export function useStatusbarItems({
|
||||
bgFailed,
|
||||
bgRunning,
|
||||
commandCenterOpen,
|
||||
gatewayMenuItems,
|
||||
gatewayMenuContent,
|
||||
gatewayUp,
|
||||
openAgents,
|
||||
statusSnapshot?.gateway_state,
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface StatusbarItem {
|
||||
hidden?: boolean
|
||||
href?: string
|
||||
menuClassName?: string
|
||||
menuContent?: ReactNode
|
||||
menuItems?: readonly StatusbarMenuItem[]
|
||||
onSelect?: () => void
|
||||
title?: string
|
||||
@@ -85,7 +86,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
|
||||
const title = item.title ?? (typeof item.label === 'string' ? item.label : undefined)
|
||||
|
||||
if (item.variant === 'menu' && item.menuItems && item.menuItems.length > 0) {
|
||||
if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -98,10 +99,17 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
{content}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className={cn('w-56', item.menuClassName)} side="top" sideOffset={8}>
|
||||
{item.menuItems
|
||||
.filter(menuItem => !menuItem.hidden)
|
||||
.map(menuItem => (
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={cn('w-56', item.menuContent && 'p-0', item.menuClassName)}
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
>
|
||||
{item.menuContent
|
||||
? item.menuContent
|
||||
: (item.menuItems ?? [])
|
||||
.filter(menuItem => !menuItem.hidden)
|
||||
.map(menuItem => (
|
||||
<DropdownMenuItem
|
||||
className={cn('gap-2 text-foreground focus:bg-accent [&_svg]:size-4', menuItem.className)}
|
||||
disabled={menuItem.disabled}
|
||||
|
||||
@@ -172,7 +172,7 @@ export function SkillsView({
|
||||
}
|
||||
|
||||
return (
|
||||
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background">
|
||||
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
|
||||
<header className={titlebarHeaderBaseClass}>
|
||||
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Skills</h2>
|
||||
<span className="pointer-events-auto text-xs text-muted-foreground">
|
||||
@@ -180,7 +180,7 @@ export function SkillsView({
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.0625rem] border border-border/50 bg-background/85">
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
|
||||
<div className="border-b border-border/50 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ModeButton active={mode === 'skills'} icon={Brain} onClick={() => setMode('skills')} text="Skills" />
|
||||
|
||||
@@ -51,7 +51,7 @@ export type CommandDispatchResponse =
|
||||
| SkillCommandDispatchResponse
|
||||
| SendCommandDispatchResponse
|
||||
|
||||
export type SidebarNavId = 'new-session' | 'command-center' | 'settings' | 'skills' | 'artifacts'
|
||||
export type SidebarNavId = 'new-session' | 'command-center' | 'settings' | 'skills' | 'messaging' | 'artifacts'
|
||||
|
||||
export interface SidebarNavItem {
|
||||
id: SidebarNavId
|
||||
|
||||
@@ -137,7 +137,7 @@ export const Thread: FC<{
|
||||
>
|
||||
<ThreadScrollSync sessionKey={sessionKey} />
|
||||
<StickToBottom.Content
|
||||
className="mx-auto flex w-full max-w-3xl min-w-0 flex-col gap-3 px-4 pt-[calc(var(--vsq)*19)] sm:px-6 lg:px-8"
|
||||
className="pb-9 mx-auto flex w-full max-w-3xl min-w-0 flex-col gap-3 px-4 pt-[calc(var(--vsq)*19)] sm:px-6 lg:px-8"
|
||||
data-slot="aui_thread-content"
|
||||
scrollClassName="overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type StatusTone = 'good' | 'muted' | 'warn' | 'bad'
|
||||
|
||||
const TONE_BG: Record<StatusTone, string> = {
|
||||
good: 'bg-primary',
|
||||
muted: 'bg-muted-foreground/40',
|
||||
warn: 'bg-amber-500',
|
||||
bad: 'bg-destructive'
|
||||
}
|
||||
|
||||
interface StatusDotProps extends ComponentProps<'span'> {
|
||||
tone: StatusTone
|
||||
}
|
||||
|
||||
export function StatusDot({ className, tone, ...props }: StatusDotProps) {
|
||||
return (
|
||||
<span aria-hidden="true" className={cn('inline-block size-1.5 rounded-full', TONE_BG[tone], className)} {...props} />
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import type {
|
||||
HermesConfig,
|
||||
HermesConfigRecord,
|
||||
LogsResponse,
|
||||
MessagingPlatformsResponse,
|
||||
MessagingPlatformTestResponse,
|
||||
MessagingPlatformUpdate,
|
||||
ModelAssignmentRequest,
|
||||
ModelAssignmentResponse,
|
||||
ModelInfoResponse,
|
||||
@@ -43,6 +46,12 @@ export type {
|
||||
HermesConfig,
|
||||
HermesConfigRecord,
|
||||
LogsResponse,
|
||||
MessagingEnvVarInfo,
|
||||
MessagingHomeChannel,
|
||||
MessagingPlatformInfo,
|
||||
MessagingPlatformsResponse,
|
||||
MessagingPlatformTestResponse,
|
||||
MessagingPlatformUpdate,
|
||||
ModelAssignmentRequest,
|
||||
ModelAssignmentResponse,
|
||||
ModelInfoResponse,
|
||||
@@ -278,6 +287,30 @@ export function getToolsets(): Promise<ToolsetInfo[]> {
|
||||
})
|
||||
}
|
||||
|
||||
export function getMessagingPlatforms(): Promise<MessagingPlatformsResponse> {
|
||||
return window.hermesDesktop.api<MessagingPlatformsResponse>({
|
||||
path: '/api/messaging/platforms'
|
||||
})
|
||||
}
|
||||
|
||||
export function updateMessagingPlatform(
|
||||
platformId: string,
|
||||
body: MessagingPlatformUpdate
|
||||
): Promise<{ ok: boolean; platform: string }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; platform: string }>({
|
||||
path: `/api/messaging/platforms/${encodeURIComponent(platformId)}`,
|
||||
method: 'PUT',
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
export function testMessagingPlatform(platformId: string): Promise<MessagingPlatformTestResponse> {
|
||||
return window.hermesDesktop.api<MessagingPlatformTestResponse>({
|
||||
path: `/api/messaging/platforms/${encodeURIComponent(platformId)}/test`,
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function getGlobalModelOptions(): Promise<ModelOptionsResponse> {
|
||||
return window.hermesDesktop.api<ModelOptionsResponse>({
|
||||
path: '/api/model/options'
|
||||
|
||||
@@ -98,6 +98,57 @@ export interface EnvVarInfo {
|
||||
url: null | string
|
||||
}
|
||||
|
||||
export interface MessagingEnvVarInfo {
|
||||
advanced: boolean
|
||||
description: string
|
||||
is_password: boolean
|
||||
is_set: boolean
|
||||
key: string
|
||||
prompt: string
|
||||
redacted_value: null | string
|
||||
required: boolean
|
||||
url: null | string
|
||||
}
|
||||
|
||||
export interface MessagingHomeChannel {
|
||||
chat_id: string
|
||||
name: string
|
||||
platform: string
|
||||
thread_id?: string
|
||||
}
|
||||
|
||||
export interface MessagingPlatformInfo {
|
||||
configured: boolean
|
||||
description: string
|
||||
docs_url: string
|
||||
enabled: boolean
|
||||
env_vars: MessagingEnvVarInfo[]
|
||||
error_code?: null | string
|
||||
error_message?: null | string
|
||||
gateway_running: boolean
|
||||
home_channel?: MessagingHomeChannel | null
|
||||
id: string
|
||||
name: string
|
||||
state?: null | string
|
||||
updated_at?: null | string
|
||||
}
|
||||
|
||||
export interface MessagingPlatformsResponse {
|
||||
platforms: MessagingPlatformInfo[]
|
||||
}
|
||||
|
||||
export interface MessagingPlatformUpdate {
|
||||
clear_env?: string[]
|
||||
enabled?: boolean
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface MessagingPlatformTestResponse {
|
||||
message: string
|
||||
ok: boolean
|
||||
state?: null | string
|
||||
}
|
||||
|
||||
export interface GatewayReadyPayload {
|
||||
skin?: unknown
|
||||
}
|
||||
|
||||
+32
-28
@@ -727,7 +727,7 @@ def load_gateway_config() -> GatewayConfig:
|
||||
existing = {}
|
||||
# Deep-merge extra dicts so gateway.json defaults survive
|
||||
merged_extra = {**existing.get("extra", {}), **plat_block.get("extra", {})}
|
||||
if plat_name == Platform.SLACK.value and "enabled" in plat_block:
|
||||
if "enabled" in plat_block:
|
||||
merged_extra["_enabled_explicit"] = True
|
||||
merged = {**existing, **plat_block}
|
||||
if merged_extra:
|
||||
@@ -791,7 +791,7 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if not isinstance(extra, dict):
|
||||
extra = {}
|
||||
plat_data["extra"] = extra
|
||||
if plat == Platform.SLACK and enabled_was_explicit:
|
||||
if enabled_was_explicit:
|
||||
extra["_enabled_explicit"] = True
|
||||
extra.update(bridged)
|
||||
|
||||
@@ -1136,14 +1136,23 @@ def _validate_gateway_config(config: "GatewayConfig") -> None:
|
||||
|
||||
def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
"""Apply environment variable overrides to config."""
|
||||
|
||||
def _enable_from_env(platform: Platform) -> PlatformConfig:
|
||||
if platform not in config.platforms:
|
||||
config.platforms[platform] = PlatformConfig(enabled=True)
|
||||
return config.platforms[platform]
|
||||
|
||||
platform_config = config.platforms[platform]
|
||||
enabled_was_explicit = bool(platform_config.extra.pop("_enabled_explicit", False))
|
||||
if not platform_config.enabled and not enabled_was_explicit:
|
||||
platform_config.enabled = True
|
||||
return platform_config
|
||||
|
||||
# Telegram
|
||||
telegram_token = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
if telegram_token:
|
||||
if Platform.TELEGRAM not in config.platforms:
|
||||
config.platforms[Platform.TELEGRAM] = PlatformConfig()
|
||||
config.platforms[Platform.TELEGRAM].enabled = True
|
||||
config.platforms[Platform.TELEGRAM].token = telegram_token
|
||||
telegram_config = _enable_from_env(Platform.TELEGRAM)
|
||||
telegram_config.token = telegram_token
|
||||
|
||||
# Reply threading mode for Telegram (off/first/all)
|
||||
telegram_reply_mode = os.getenv("TELEGRAM_REPLY_TO_MODE", "").lower()
|
||||
@@ -1172,10 +1181,8 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
# Discord
|
||||
discord_token = os.getenv("DISCORD_BOT_TOKEN")
|
||||
if discord_token:
|
||||
if Platform.DISCORD not in config.platforms:
|
||||
config.platforms[Platform.DISCORD] = PlatformConfig()
|
||||
config.platforms[Platform.DISCORD].enabled = True
|
||||
config.platforms[Platform.DISCORD].token = discord_token
|
||||
discord_config = _enable_from_env(Platform.DISCORD)
|
||||
discord_config.token = discord_token
|
||||
|
||||
discord_home = os.getenv("DISCORD_HOME_CHANNEL")
|
||||
if discord_home and Platform.DISCORD in config.platforms:
|
||||
@@ -1247,10 +1254,8 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
signal_url = os.getenv("SIGNAL_HTTP_URL")
|
||||
signal_account = os.getenv("SIGNAL_ACCOUNT")
|
||||
if signal_url and signal_account:
|
||||
if Platform.SIGNAL not in config.platforms:
|
||||
config.platforms[Platform.SIGNAL] = PlatformConfig()
|
||||
config.platforms[Platform.SIGNAL].enabled = True
|
||||
config.platforms[Platform.SIGNAL].extra.update({
|
||||
signal_config = _enable_from_env(Platform.SIGNAL)
|
||||
signal_config.extra.update({
|
||||
"http_url": signal_url,
|
||||
"account": signal_account,
|
||||
"ignore_stories": os.getenv("SIGNAL_IGNORE_STORIES", "true").lower() in ("true", "1", "yes"),
|
||||
@@ -1270,11 +1275,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
mattermost_url = os.getenv("MATTERMOST_URL", "")
|
||||
if not mattermost_url:
|
||||
logger.warning("MATTERMOST_TOKEN set but MATTERMOST_URL is missing")
|
||||
if Platform.MATTERMOST not in config.platforms:
|
||||
config.platforms[Platform.MATTERMOST] = PlatformConfig()
|
||||
config.platforms[Platform.MATTERMOST].enabled = True
|
||||
config.platforms[Platform.MATTERMOST].token = mattermost_token
|
||||
config.platforms[Platform.MATTERMOST].extra["url"] = mattermost_url
|
||||
mattermost_config = _enable_from_env(Platform.MATTERMOST)
|
||||
mattermost_config.token = mattermost_token
|
||||
mattermost_config.extra["url"] = mattermost_url
|
||||
mattermost_home = os.getenv("MATTERMOST_HOME_CHANNEL")
|
||||
if mattermost_home and Platform.MATTERMOST in config.platforms:
|
||||
config.platforms[Platform.MATTERMOST].home_channel = HomeChannel(
|
||||
@@ -1290,23 +1293,21 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
if matrix_token or os.getenv("MATRIX_PASSWORD"):
|
||||
if not matrix_homeserver:
|
||||
logger.warning("MATRIX_ACCESS_TOKEN/MATRIX_PASSWORD set but MATRIX_HOMESERVER is missing")
|
||||
if Platform.MATRIX not in config.platforms:
|
||||
config.platforms[Platform.MATRIX] = PlatformConfig()
|
||||
config.platforms[Platform.MATRIX].enabled = True
|
||||
matrix_config = _enable_from_env(Platform.MATRIX)
|
||||
if matrix_token:
|
||||
config.platforms[Platform.MATRIX].token = matrix_token
|
||||
config.platforms[Platform.MATRIX].extra["homeserver"] = matrix_homeserver
|
||||
matrix_config.token = matrix_token
|
||||
matrix_config.extra["homeserver"] = matrix_homeserver
|
||||
matrix_user = os.getenv("MATRIX_USER_ID", "")
|
||||
if matrix_user:
|
||||
config.platforms[Platform.MATRIX].extra["user_id"] = matrix_user
|
||||
matrix_config.extra["user_id"] = matrix_user
|
||||
matrix_password = os.getenv("MATRIX_PASSWORD", "")
|
||||
if matrix_password:
|
||||
config.platforms[Platform.MATRIX].extra["password"] = matrix_password
|
||||
matrix_config.extra["password"] = matrix_password
|
||||
matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes")
|
||||
config.platforms[Platform.MATRIX].extra["encryption"] = matrix_e2ee
|
||||
matrix_config.extra["encryption"] = matrix_e2ee
|
||||
matrix_device_id = os.getenv("MATRIX_DEVICE_ID", "")
|
||||
if matrix_device_id:
|
||||
config.platforms[Platform.MATRIX].extra["device_id"] = matrix_device_id
|
||||
matrix_config.extra["device_id"] = matrix_device_id
|
||||
matrix_home = os.getenv("MATRIX_HOME_ROOM")
|
||||
if matrix_home and Platform.MATRIX in config.platforms:
|
||||
config.platforms[Platform.MATRIX].home_channel = HomeChannel(
|
||||
@@ -1769,3 +1770,6 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Plugin platform enable pass failed: %s", e)
|
||||
|
||||
for platform_config in config.platforms.values():
|
||||
platform_config.extra.pop("_enabled_explicit", None)
|
||||
|
||||
@@ -458,6 +458,12 @@ class EnvVarReveal(BaseModel):
|
||||
key: str
|
||||
|
||||
|
||||
class MessagingPlatformUpdate(BaseModel):
|
||||
enabled: Optional[bool] = None
|
||||
env: Dict[str, str] = {}
|
||||
clear_env: List[str] = []
|
||||
|
||||
|
||||
class AudioTranscriptionRequest(BaseModel):
|
||||
data_url: str
|
||||
mime_type: Optional[str] = None
|
||||
@@ -1518,6 +1524,523 @@ async def reveal_env_var(body: EnvVarReveal, request: Request):
|
||||
return {"key": body.key, "value": value}
|
||||
|
||||
|
||||
# Curated UI metadata for messaging platforms. Keyed by the gateway's
|
||||
# platform id (Platform.value for built-ins, PlatformEntry.name for plugins).
|
||||
# Entries omit fields they don't need to override; the catalog builder fills
|
||||
# in env_vars from OPTIONAL_ENV_VARS via prefix matching when not specified,
|
||||
# and pulls required_env from a plugin's PlatformEntry when available.
|
||||
_PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = {
|
||||
"telegram": {
|
||||
"name": "Telegram",
|
||||
"description": "Run Hermes from Telegram DMs, groups, and topics.",
|
||||
"docs_url": "https://core.telegram.org/bots/features#botfather",
|
||||
"env_vars": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_USERS", "TELEGRAM_PROXY"),
|
||||
"required_env": ("TELEGRAM_BOT_TOKEN",),
|
||||
},
|
||||
"discord": {
|
||||
"name": "Discord",
|
||||
"description": "Connect Hermes to Discord DMs, channels, and threads.",
|
||||
"docs_url": "https://discord.com/developers/applications",
|
||||
"env_vars": ("DISCORD_BOT_TOKEN", "DISCORD_ALLOWED_USERS", "DISCORD_REPLY_TO_MODE"),
|
||||
"required_env": ("DISCORD_BOT_TOKEN",),
|
||||
},
|
||||
"slack": {
|
||||
"name": "Slack",
|
||||
"description": "Use Hermes from Slack via Socket Mode.",
|
||||
"docs_url": "https://api.slack.com/apps",
|
||||
"env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"),
|
||||
"required_env": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"),
|
||||
},
|
||||
"mattermost": {
|
||||
"name": "Mattermost",
|
||||
"description": "Connect Hermes to Mattermost channels and direct messages.",
|
||||
"docs_url": "https://mattermost.com/deploy/",
|
||||
"env_vars": ("MATTERMOST_URL", "MATTERMOST_TOKEN", "MATTERMOST_ALLOWED_USERS"),
|
||||
"required_env": ("MATTERMOST_URL", "MATTERMOST_TOKEN"),
|
||||
},
|
||||
"matrix": {
|
||||
"name": "Matrix",
|
||||
"description": "Use Hermes in Matrix rooms and direct messages.",
|
||||
"docs_url": "https://matrix.org/ecosystem/servers/",
|
||||
"env_vars": ("MATRIX_HOMESERVER", "MATRIX_ACCESS_TOKEN", "MATRIX_USER_ID", "MATRIX_ALLOWED_USERS"),
|
||||
"required_env": ("MATRIX_HOMESERVER", "MATRIX_ACCESS_TOKEN", "MATRIX_USER_ID"),
|
||||
},
|
||||
"signal": {
|
||||
"name": "Signal",
|
||||
"description": "Connect through a signal-cli REST bridge.",
|
||||
"docs_url": "https://github.com/bbernhard/signal-cli-rest-api",
|
||||
"env_vars": ("SIGNAL_HTTP_URL", "SIGNAL_ACCOUNT", "SIGNAL_ALLOWED_USERS"),
|
||||
"required_env": ("SIGNAL_HTTP_URL", "SIGNAL_ACCOUNT"),
|
||||
},
|
||||
"whatsapp": {
|
||||
"name": "WhatsApp",
|
||||
"description": "Use Hermes through the bundled WhatsApp bridge with QR-based auth.",
|
||||
"docs_url": "https://github.com/tulir/whatsmeow",
|
||||
"env_vars": ("WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS"),
|
||||
"required_env": (),
|
||||
},
|
||||
"homeassistant": {
|
||||
"name": "Home Assistant",
|
||||
"description": "Control your smart home from Hermes via Home Assistant.",
|
||||
"docs_url": "https://www.home-assistant.io/docs/authentication/",
|
||||
"env_vars": ("HASS_URL", "HASS_TOKEN"),
|
||||
"required_env": ("HASS_URL", "HASS_TOKEN"),
|
||||
},
|
||||
"email": {
|
||||
"name": "Email",
|
||||
"description": "Talk to Hermes through an IMAP/SMTP mailbox.",
|
||||
"docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/",
|
||||
"env_vars": ("EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_IMAP_HOST", "EMAIL_SMTP_HOST"),
|
||||
"required_env": ("EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_IMAP_HOST", "EMAIL_SMTP_HOST"),
|
||||
},
|
||||
"sms": {
|
||||
"name": "SMS (Twilio)",
|
||||
"description": "Send and receive text messages via Twilio.",
|
||||
"docs_url": "https://www.twilio.com/console",
|
||||
"env_vars": ("TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"),
|
||||
"required_env": ("TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"),
|
||||
},
|
||||
"dingtalk": {
|
||||
"name": "DingTalk",
|
||||
"description": "Connect Hermes to DingTalk groups (钉钉).",
|
||||
"docs_url": "https://open.dingtalk.com/document/orgapp/the-robot-development-process",
|
||||
"env_vars": ("DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"),
|
||||
"required_env": ("DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"),
|
||||
},
|
||||
"feishu": {
|
||||
"name": "Feishu / Lark",
|
||||
"description": "Use Hermes inside Feishu / Lark.",
|
||||
"docs_url": "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/intro",
|
||||
"env_vars": ("FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN"),
|
||||
"required_env": ("FEISHU_APP_ID", "FEISHU_APP_SECRET"),
|
||||
},
|
||||
"wecom": {
|
||||
"name": "WeCom (group bot)",
|
||||
"description": "Send-only WeCom group bot via webhook.",
|
||||
"docs_url": "https://developer.work.weixin.qq.com/document/path/91770",
|
||||
"env_vars": ("WECOM_BOT_ID", "WECOM_SECRET"),
|
||||
"required_env": ("WECOM_BOT_ID",),
|
||||
},
|
||||
"wecom_callback": {
|
||||
"name": "WeCom (app)",
|
||||
"description": "Two-way WeCom integration via callback app.",
|
||||
"docs_url": "https://developer.work.weixin.qq.com/document/path/90930",
|
||||
"env_vars": (
|
||||
"WECOM_CALLBACK_CORP_ID",
|
||||
"WECOM_CALLBACK_CORP_SECRET",
|
||||
"WECOM_CALLBACK_AGENT_ID",
|
||||
"WECOM_CALLBACK_TOKEN",
|
||||
"WECOM_CALLBACK_ENCODING_AES_KEY",
|
||||
),
|
||||
"required_env": ("WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET", "WECOM_CALLBACK_AGENT_ID"),
|
||||
},
|
||||
"weixin": {
|
||||
"name": "WeChat (Official Account)",
|
||||
"description": "Connect a WeChat Official Account.",
|
||||
"docs_url": "https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Overview.html",
|
||||
"env_vars": ("WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL"),
|
||||
"required_env": ("WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN"),
|
||||
},
|
||||
"bluebubbles": {
|
||||
"name": "BlueBubbles (iMessage)",
|
||||
"description": "Use Hermes through iMessage via a BlueBubbles server.",
|
||||
"docs_url": "https://bluebubbles.app/",
|
||||
"env_vars": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD", "BLUEBUBBLES_ALLOWED_USERS"),
|
||||
"required_env": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD"),
|
||||
},
|
||||
"qqbot": {
|
||||
"name": "QQ Bot",
|
||||
"description": "Connect Hermes to a QQ Bot from the QQ Open Platform.",
|
||||
"docs_url": "https://q.qq.com",
|
||||
"env_vars": ("QQ_APP_ID", "QQ_CLIENT_SECRET", "QQ_ALLOWED_USERS"),
|
||||
"required_env": ("QQ_APP_ID", "QQ_CLIENT_SECRET"),
|
||||
},
|
||||
"yuanbao": {
|
||||
"name": "Yuanbao (元宝)",
|
||||
"description": "Connect Hermes to Tencent Yuanbao.",
|
||||
"docs_url": "",
|
||||
"required_env": (),
|
||||
},
|
||||
"api_server": {
|
||||
"name": "API server",
|
||||
"description": "Expose Hermes as an OpenAI-compatible HTTP API for tools like Open WebUI.",
|
||||
"docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/",
|
||||
"env_vars": (
|
||||
"API_SERVER_ENABLED",
|
||||
"API_SERVER_KEY",
|
||||
"API_SERVER_PORT",
|
||||
"API_SERVER_HOST",
|
||||
"API_SERVER_MODEL_NAME",
|
||||
),
|
||||
"required_env": (),
|
||||
},
|
||||
"webhook": {
|
||||
"name": "Webhooks",
|
||||
"description": "Receive events from GitHub, GitLab, and other webhook sources.",
|
||||
"docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/",
|
||||
"env_vars": ("WEBHOOK_ENABLED", "WEBHOOK_PORT", "WEBHOOK_SECRET"),
|
||||
"required_env": (),
|
||||
},
|
||||
}
|
||||
|
||||
# Display order: well-known platforms surface first; unknown plugins fall to
|
||||
# the end alphabetically.
|
||||
_PLATFORM_ORDER: tuple[str, ...] = (
|
||||
"telegram",
|
||||
"discord",
|
||||
"slack",
|
||||
"mattermost",
|
||||
"matrix",
|
||||
"whatsapp",
|
||||
"signal",
|
||||
"bluebubbles",
|
||||
"homeassistant",
|
||||
"email",
|
||||
"sms",
|
||||
"dingtalk",
|
||||
"feishu",
|
||||
"wecom",
|
||||
"wecom_callback",
|
||||
"weixin",
|
||||
"qqbot",
|
||||
"yuanbao",
|
||||
"api_server",
|
||||
"webhook",
|
||||
)
|
||||
|
||||
# Display labels for env vars not in OPTIONAL_ENV_VARS (HOME_CHANNEL_*, bridge
|
||||
# toggles, Twilio, HASS, Email, etc.). Anything missing from OPTIONAL_ENV_VARS
|
||||
# falls back here so the UI can still render a friendly label.
|
||||
_MESSAGING_ENV_FALLBACKS: dict[str, dict[str, Any]] = {
|
||||
"SIGNAL_HTTP_URL": {
|
||||
"description": "signal-cli REST API base URL, e.g. http://127.0.0.1:8080",
|
||||
"prompt": "Signal bridge URL",
|
||||
"url": "https://github.com/bbernhard/signal-cli-rest-api",
|
||||
},
|
||||
"SIGNAL_ACCOUNT": {
|
||||
"description": "Signal account phone number registered with the bridge",
|
||||
"prompt": "Signal account",
|
||||
},
|
||||
"SIGNAL_ALLOWED_USERS": {
|
||||
"description": "Comma-separated Signal users allowed to use the bot",
|
||||
"prompt": "Allowed Signal users",
|
||||
},
|
||||
"WHATSAPP_ENABLED": {
|
||||
"description": "Enable the WhatsApp gateway adapter",
|
||||
"prompt": "Enable WhatsApp",
|
||||
"advanced": True,
|
||||
},
|
||||
"WHATSAPP_MODE": {"description": "WhatsApp bridge mode", "prompt": "WhatsApp mode", "advanced": True},
|
||||
"WHATSAPP_ALLOWED_USERS": {
|
||||
"description": "Comma-separated WhatsApp users allowed to use the bot",
|
||||
"prompt": "Allowed WhatsApp users",
|
||||
},
|
||||
"HASS_URL": {
|
||||
"description": "Home Assistant base URL, e.g. https://homeassistant.local:8123",
|
||||
"prompt": "Home Assistant URL",
|
||||
},
|
||||
"HASS_TOKEN": {
|
||||
"description": "Long-lived access token from Home Assistant (Profile → Security)",
|
||||
"prompt": "Home Assistant access token",
|
||||
"password": True,
|
||||
},
|
||||
"EMAIL_ADDRESS": {"description": "Email address to send and receive from", "prompt": "Email address"},
|
||||
"EMAIL_PASSWORD": {
|
||||
"description": "Email account password or app password",
|
||||
"prompt": "Email password",
|
||||
"password": True,
|
||||
},
|
||||
"EMAIL_IMAP_HOST": {"description": "IMAP server host (e.g. imap.gmail.com)", "prompt": "IMAP host"},
|
||||
"EMAIL_SMTP_HOST": {"description": "SMTP server host (e.g. smtp.gmail.com)", "prompt": "SMTP host"},
|
||||
"TWILIO_ACCOUNT_SID": {
|
||||
"description": "Twilio Account SID",
|
||||
"prompt": "Twilio Account SID",
|
||||
"url": "https://www.twilio.com/console",
|
||||
},
|
||||
"TWILIO_AUTH_TOKEN": {"description": "Twilio Auth Token", "prompt": "Twilio Auth Token", "password": True},
|
||||
"WECOM_BOT_ID": {"description": "WeCom group bot ID", "prompt": "WeCom Bot ID"},
|
||||
"WECOM_SECRET": {"description": "WeCom group bot secret", "prompt": "WeCom Secret", "password": True},
|
||||
"WECOM_CALLBACK_CORP_ID": {"description": "WeCom corp ID", "prompt": "WeCom Corp ID"},
|
||||
"WECOM_CALLBACK_CORP_SECRET": {
|
||||
"description": "WeCom app corp secret",
|
||||
"prompt": "WeCom Corp Secret",
|
||||
"password": True,
|
||||
},
|
||||
"WECOM_CALLBACK_AGENT_ID": {"description": "WeCom app agent ID", "prompt": "WeCom Agent ID"},
|
||||
"WECOM_CALLBACK_TOKEN": {"description": "WeCom callback verification token", "prompt": "WeCom Token"},
|
||||
"WECOM_CALLBACK_ENCODING_AES_KEY": {
|
||||
"description": "WeCom callback AES encoding key",
|
||||
"prompt": "WeCom AES Key",
|
||||
"password": True,
|
||||
},
|
||||
"WEIXIN_ACCOUNT_ID": {"description": "WeChat Official Account ID", "prompt": "Account ID"},
|
||||
"WEIXIN_TOKEN": {"description": "WeChat callback token", "prompt": "Token", "password": True},
|
||||
"WEIXIN_BASE_URL": {"description": "WeChat platform base URL", "prompt": "Base URL"},
|
||||
"FEISHU_APP_ID": {"description": "Feishu / Lark app ID", "prompt": "App ID"},
|
||||
"FEISHU_APP_SECRET": {"description": "Feishu / Lark app secret", "prompt": "App secret", "password": True},
|
||||
"FEISHU_ENCRYPT_KEY": {"description": "Feishu / Lark encrypt key", "prompt": "Encrypt key", "password": True},
|
||||
"FEISHU_VERIFICATION_TOKEN": {
|
||||
"description": "Feishu / Lark verification token",
|
||||
"prompt": "Verification token",
|
||||
"password": True,
|
||||
},
|
||||
"DINGTALK_CLIENT_ID": {"description": "DingTalk client ID (App key)", "prompt": "Client ID"},
|
||||
"DINGTALK_CLIENT_SECRET": {
|
||||
"description": "DingTalk client secret (App secret)",
|
||||
"prompt": "Client secret",
|
||||
"password": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _messaging_platform_catalog() -> tuple[dict[str, Any], ...]:
|
||||
"""Build the messaging catalog from the gateway's Platform enum + plugin registry.
|
||||
|
||||
Built-in platforms come from ``gateway.config.Platform`` (LOCAL is excluded).
|
||||
Plugin platforms come from ``gateway.platform_registry.plugin_entries()``,
|
||||
which lets newly installed adapters (e.g. IRC) appear without a code change
|
||||
here. Per-platform UI metadata (description, docs URL, env-var picks) lives
|
||||
in :data:`_PLATFORM_OVERRIDES`; anything not overridden gets reasonable
|
||||
defaults derived from the platform id and required_env.
|
||||
"""
|
||||
from gateway.config import Platform
|
||||
|
||||
seen: set[str] = set()
|
||||
entries: list[dict[str, Any]] = []
|
||||
|
||||
for member in Platform.__members__.values():
|
||||
if member.value == "local":
|
||||
continue
|
||||
if member.value in seen:
|
||||
continue
|
||||
seen.add(member.value)
|
||||
entries.append(_build_catalog_entry(member.value))
|
||||
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
|
||||
for plugin_entry in platform_registry.plugin_entries():
|
||||
if plugin_entry.name in seen:
|
||||
continue
|
||||
seen.add(plugin_entry.name)
|
||||
entries.append(_build_catalog_entry(plugin_entry.name, plugin_entry))
|
||||
except Exception:
|
||||
_log.debug("plugin platform registry unavailable", exc_info=True)
|
||||
|
||||
order = {pid: idx for idx, pid in enumerate(_PLATFORM_ORDER)}
|
||||
entries.sort(key=lambda e: (order.get(e["id"], len(_PLATFORM_ORDER)), e["name"].lower()))
|
||||
return tuple(entries)
|
||||
|
||||
|
||||
def _build_catalog_entry(platform_id: str, plugin_entry: Any | None = None) -> dict[str, Any]:
|
||||
override = _PLATFORM_OVERRIDES.get(platform_id, {})
|
||||
|
||||
if "env_vars" in override:
|
||||
env_vars: tuple[str, ...] = tuple(override["env_vars"])
|
||||
elif plugin_entry is not None and plugin_entry.required_env:
|
||||
env_vars = tuple(plugin_entry.required_env)
|
||||
else:
|
||||
prefix = platform_id.upper() + "_"
|
||||
env_vars = tuple(k for k in OPTIONAL_ENV_VARS if k.startswith(prefix))
|
||||
|
||||
if "required_env" in override:
|
||||
required_env = tuple(override["required_env"])
|
||||
elif plugin_entry is not None:
|
||||
required_env = tuple(plugin_entry.required_env or ())
|
||||
else:
|
||||
required_env = ()
|
||||
|
||||
if override.get("name"):
|
||||
name = override["name"]
|
||||
elif plugin_entry is not None and plugin_entry.label:
|
||||
name = plugin_entry.label
|
||||
else:
|
||||
name = platform_id.replace("_", " ").title()
|
||||
|
||||
description = override.get("description")
|
||||
if not description and plugin_entry is not None:
|
||||
description = plugin_entry.install_hint or ""
|
||||
|
||||
return {
|
||||
"id": platform_id,
|
||||
"name": name,
|
||||
"description": description or "",
|
||||
"docs_url": override.get("docs_url", ""),
|
||||
"env_vars": env_vars,
|
||||
"required_env": required_env,
|
||||
}
|
||||
|
||||
|
||||
def _catalog_lookup(platform_id: str) -> dict[str, Any] | None:
|
||||
for entry in _messaging_platform_catalog():
|
||||
if entry["id"] == platform_id:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def _messaging_env_info(key: str) -> dict[str, Any]:
|
||||
info = OPTIONAL_ENV_VARS.get(key) or _MESSAGING_ENV_FALLBACKS.get(key) or {}
|
||||
return {
|
||||
"description": info.get("description", ""),
|
||||
"prompt": info.get("prompt", key),
|
||||
"url": info.get("url"),
|
||||
"is_password": info.get("password", False),
|
||||
"advanced": info.get("advanced", False),
|
||||
}
|
||||
|
||||
|
||||
def _gateway_platform_config(platform_id: str):
|
||||
from gateway.config import Platform, load_gateway_config
|
||||
|
||||
config = load_gateway_config()
|
||||
platform = Platform(platform_id)
|
||||
platform_config = config.platforms.get(platform)
|
||||
return config, platform, platform_config
|
||||
|
||||
|
||||
def _messaging_platform_payload(entry: dict[str, Any], env_on_disk: dict[str, str], runtime: dict | None) -> dict[str, Any]:
|
||||
platform_id = entry["id"]
|
||||
gateway_running = get_running_pid() is not None
|
||||
runtime_platforms = runtime.get("platforms") if runtime else {}
|
||||
runtime_platform = runtime_platforms.get(platform_id, {}) if isinstance(runtime_platforms, dict) else {}
|
||||
env_vars = []
|
||||
|
||||
for key in entry["env_vars"]:
|
||||
value = env_on_disk.get(key) or os.getenv(key, "")
|
||||
env_vars.append({
|
||||
"key": key,
|
||||
"required": key in entry["required_env"],
|
||||
"is_set": bool(value),
|
||||
"redacted_value": redact_key(value) if value else None,
|
||||
**_messaging_env_info(key),
|
||||
})
|
||||
|
||||
try:
|
||||
gateway_config, platform, platform_config = _gateway_platform_config(platform_id)
|
||||
enabled = bool(platform_config and platform_config.enabled)
|
||||
configured = bool(platform_config and gateway_config._is_platform_connected(platform, platform_config))
|
||||
home_channel = platform_config.home_channel.to_dict() if platform_config and platform_config.home_channel else None
|
||||
except Exception:
|
||||
enabled = False
|
||||
configured = all(env_on_disk.get(key) or os.getenv(key, "") for key in entry["required_env"])
|
||||
home_channel = None
|
||||
|
||||
state = runtime_platform.get("state") if isinstance(runtime_platform, dict) else None
|
||||
if not enabled:
|
||||
state = "disabled"
|
||||
elif not configured:
|
||||
state = "not_configured"
|
||||
elif gateway_running and not state:
|
||||
state = "pending_restart"
|
||||
elif not gateway_running and not state:
|
||||
state = "gateway_stopped"
|
||||
|
||||
return {
|
||||
"id": platform_id,
|
||||
"name": entry["name"],
|
||||
"description": entry["description"],
|
||||
"docs_url": entry["docs_url"],
|
||||
"enabled": enabled,
|
||||
"configured": configured,
|
||||
"gateway_running": gateway_running,
|
||||
"state": state,
|
||||
"error_code": runtime_platform.get("error_code") if isinstance(runtime_platform, dict) else None,
|
||||
"error_message": runtime_platform.get("error_message") if isinstance(runtime_platform, dict) else None,
|
||||
"updated_at": runtime_platform.get("updated_at") if isinstance(runtime_platform, dict) else None,
|
||||
"home_channel": home_channel,
|
||||
"env_vars": env_vars,
|
||||
}
|
||||
|
||||
|
||||
def _write_platform_enabled(platform_id: str, enabled: bool) -> None:
|
||||
config = load_config()
|
||||
platforms = config.setdefault("platforms", {})
|
||||
if not isinstance(platforms, dict):
|
||||
platforms = {}
|
||||
config["platforms"] = platforms
|
||||
platform_config = platforms.setdefault(platform_id, {})
|
||||
if not isinstance(platform_config, dict):
|
||||
platform_config = {}
|
||||
platforms[platform_id] = platform_config
|
||||
platform_config["enabled"] = enabled
|
||||
save_config(config)
|
||||
|
||||
|
||||
@app.get("/api/messaging/platforms")
|
||||
async def get_messaging_platforms():
|
||||
env_on_disk = load_env()
|
||||
runtime = read_runtime_status()
|
||||
return {
|
||||
"platforms": [
|
||||
_messaging_platform_payload(entry, env_on_disk, runtime)
|
||||
for entry in _messaging_platform_catalog()
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.put("/api/messaging/platforms/{platform_id}")
|
||||
async def update_messaging_platform(platform_id: str, body: MessagingPlatformUpdate):
|
||||
entry = _catalog_lookup(platform_id)
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown messaging platform: {platform_id}")
|
||||
|
||||
allowed_env = set(entry["env_vars"])
|
||||
try:
|
||||
for key in body.clear_env:
|
||||
if key not in allowed_env:
|
||||
raise HTTPException(status_code=400, detail=f"{key} is not configurable for {entry['name']}")
|
||||
remove_env_value(key)
|
||||
|
||||
for key, value in body.env.items():
|
||||
if key not in allowed_env:
|
||||
raise HTTPException(status_code=400, detail=f"{key} is not configurable for {entry['name']}")
|
||||
trimmed = value.strip()
|
||||
if trimmed:
|
||||
save_env_value(key, trimmed)
|
||||
|
||||
if body.enabled is not None:
|
||||
_write_platform_enabled(platform_id, body.enabled)
|
||||
|
||||
return {"ok": True, "platform": platform_id}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
_log.exception("PUT /api/messaging/platforms/%s failed", platform_id)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@app.post("/api/messaging/platforms/{platform_id}/test")
|
||||
async def test_messaging_platform(platform_id: str):
|
||||
entry = _catalog_lookup(platform_id)
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown messaging platform: {platform_id}")
|
||||
|
||||
env_on_disk = load_env()
|
||||
payload = _messaging_platform_payload(entry, env_on_disk, read_runtime_status())
|
||||
if not payload["enabled"]:
|
||||
message = f"{entry['name']} is disabled. Enable it, then restart the gateway."
|
||||
return {"ok": False, "state": payload["state"], "message": message}
|
||||
if not payload["configured"]:
|
||||
missing = [field["key"] for field in payload["env_vars"] if field["required"] and not field["is_set"]]
|
||||
message = f"Missing required setup: {', '.join(missing)}" if missing else "Platform setup is incomplete."
|
||||
return {"ok": False, "state": payload["state"], "message": message}
|
||||
if not payload["gateway_running"]:
|
||||
return {
|
||||
"ok": False,
|
||||
"state": payload["state"],
|
||||
"message": "Gateway is not running. Restart the gateway to connect this platform.",
|
||||
}
|
||||
if payload["state"] == "connected":
|
||||
return {"ok": True, "state": payload["state"], "message": f"{entry['name']} is connected."}
|
||||
if payload.get("error_message"):
|
||||
return {"ok": False, "state": payload["state"], "message": payload["error_message"]}
|
||||
return {
|
||||
"ok": False,
|
||||
"state": payload["state"],
|
||||
"message": "Setup looks complete, but the gateway has not reported a connection yet. Restart the gateway.",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OAuth provider endpoints — status + disconnect (Phase 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -345,6 +345,82 @@ class TestWebServerEndpoints:
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_get_messaging_platforms(self):
|
||||
resp = self.client.get("/api/messaging/platforms")
|
||||
|
||||
assert resp.status_code == 200
|
||||
platforms = resp.json()["platforms"]
|
||||
telegram = next(platform for platform in platforms if platform["id"] == "telegram")
|
||||
assert telegram["name"] == "Telegram"
|
||||
assert telegram["enabled"] is False
|
||||
assert any(field["key"] == "TELEGRAM_BOT_TOKEN" and field["required"] for field in telegram["env_vars"])
|
||||
|
||||
def test_messaging_catalog_covers_gateway_platforms(self):
|
||||
"""Catalog is derived from the Platform enum, so every built-in shows up."""
|
||||
from gateway.config import Platform
|
||||
|
||||
resp = self.client.get("/api/messaging/platforms")
|
||||
platforms = {entry["id"] for entry in resp.json()["platforms"]}
|
||||
|
||||
for member in Platform.__members__.values():
|
||||
if member.value == "local":
|
||||
continue
|
||||
assert member.value in platforms, f"Missing gateway platform {member.value} from /api/messaging/platforms"
|
||||
|
||||
def test_messaging_catalog_includes_plugin_platforms(self, monkeypatch):
|
||||
"""Plugin-registered adapters appear in the catalog without per-platform code."""
|
||||
from gateway.platform_registry import PlatformEntry, platform_registry
|
||||
|
||||
entry = PlatformEntry(
|
||||
name="ircfake",
|
||||
label="IRC (test)",
|
||||
adapter_factory=lambda cfg: None,
|
||||
check_fn=lambda: True,
|
||||
required_env=["IRC_SERVER"],
|
||||
install_hint="Connect to IRC.",
|
||||
source="plugin",
|
||||
)
|
||||
platform_registry.register(entry)
|
||||
try:
|
||||
resp = self.client.get("/api/messaging/platforms")
|
||||
ids = {row["id"]: row for row in resp.json()["platforms"]}
|
||||
assert "ircfake" in ids
|
||||
assert ids["ircfake"]["name"] == "IRC (test)"
|
||||
assert any(field["key"] == "IRC_SERVER" and field["required"] for field in ids["ircfake"]["env_vars"])
|
||||
finally:
|
||||
platform_registry.unregister("ircfake")
|
||||
|
||||
def test_update_messaging_platform_saves_env_and_enablement(self):
|
||||
from hermes_cli.config import load_config, load_env
|
||||
|
||||
resp = self.client.put(
|
||||
"/api/messaging/platforms/telegram",
|
||||
json={
|
||||
"enabled": False,
|
||||
"env": {"TELEGRAM_BOT_TOKEN": "1234567890abcdef"},
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert load_env()["TELEGRAM_BOT_TOKEN"] == "1234567890abcdef"
|
||||
assert load_config()["platforms"]["telegram"]["enabled"] is False
|
||||
|
||||
status = self.client.get("/api/messaging/platforms").json()["platforms"]
|
||||
telegram = next(platform for platform in status if platform["id"] == "telegram")
|
||||
assert telegram["enabled"] is False
|
||||
|
||||
def test_messaging_platform_test_reports_missing_required_setup(self):
|
||||
resp = self.client.put("/api/messaging/platforms/discord", json={"enabled": True})
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = self.client.post("/api/messaging/platforms/discord/test")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is False
|
||||
assert data["state"] == "not_configured"
|
||||
assert "DISCORD_BOT_TOKEN" in data["message"]
|
||||
|
||||
def test_session_token_endpoint_removed(self):
|
||||
"""GET /api/auth/session-token should no longer exist (token injected via HTML)."""
|
||||
resp = self.client.get("/api/auth/session-token")
|
||||
|
||||
Reference in New Issue
Block a user