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:
Brooklyn Nicholson
2026-05-08 15:59:43 -04:00
parent 9ec0f7cbff
commit f790c61207
20 changed files with 1717 additions and 72 deletions
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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}
+5 -7
View File
@@ -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}>
+755
View File
@@ -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>
)
}
+4 -2
View File
@@ -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}
+2 -2
View File
@@ -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" />
+1 -1
View File
@@ -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} />
)
}
+33
View File
@@ -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'
+51
View File
@@ -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
View File
@@ -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)
+523
View File
@@ -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)
# ---------------------------------------------------------------------------
+76
View File
@@ -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")