Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
yilsem committed 2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 files changed
+3442369

No files matched your search

@@ -0,0 +1,234 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MessagingPlatformInfo } from '@/types/hermes'
const getMessagingPlatforms = vi.fn()
const updateMessagingPlatform = vi.fn()
const getPairing = vi.fn()
const approvePairing = vi.fn()
const revokePairing = vi.fn()
const openExternalLink = vi.fn()
vi.mock('@/hermes', () => ({
approvePairing: (platformId: string, requestId: string, profile?: null | string) =>
approvePairing(platformId, requestId, profile),
getMessagingPlatforms: (profile?: null | string) => getMessagingPlatforms(profile),
getPairing: (profile?: null | string) => getPairing(profile),
getProfiles: vi.fn(async () => ({ profiles: [] })),
revokePairing: (platformId: string, userId: string, profile?: null | string) =>
revokePairing(platformId, userId, profile),
setApiRequestProfile: vi.fn(),
updateMessagingPlatform: (id: string, body: unknown, profile?: null | string) =>
updateMessagingPlatform(id, body, profile)
}))
// Keep store/profile's side-effecting imports inert (pulled in via the shared
// settings scope store) — same seam as store/profile.test.ts.
vi.mock('@/store/gateway', () => ({
$gateway: { get: () => null, subscribe: () => () => {} },
ensureGatewayForAgent: vi.fn(async () => undefined),
ensureGatewayForProfile: vi.fn(async () => undefined),
openGatewayForProfile: vi.fn(async () => undefined)
}))
vi.mock('@/lib/query-client', () => ({ invalidateProfileScopedQueries: vi.fn() }))
vi.mock('@/store/starmap', () => ({ resetStarmapGraph: vi.fn() }))
vi.mock('@/lib/external-link', () => ({
openExternalLink: (href: string) => openExternalLink(href)
}))
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()
}))
vi.mock('@/store/system-actions', () => ({
runGatewayRestart: vi.fn()
}))
function platform(patch: Partial<MessagingPlatformInfo> = {}): MessagingPlatformInfo {
return {
configured: false,
description: 'A platform.',
docs_url: '',
enabled: false,
env_vars: [],
gateway_running: true,
id: 'teams',
name: 'Microsoft Teams',
state: 'disabled',
...patch
}
}
beforeEach(() => {
updateMessagingPlatform.mockResolvedValue({ ok: true, platform: 'teams' })
getPairing.mockResolvedValue({ approved: [], pending: [] })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
async function renderMessaging() {
const { MessagingView } = await import('./index')
let result: ReturnType<typeof render>
await act(async () => {
result = render(
<MemoryRouter>
<MessagingView />
</MemoryRouter>
)
})
return result!
}
describe('MessagingView profile scope', () => {
it('follows the active profile instead of targeting primary when there is no override', async () => {
const { $settingsScopeOverride } = await import('@/store/settings-scope')
$settingsScopeOverride.set(null)
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
await renderMessaging()
await waitFor(() => expect(getMessagingPlatforms).toHaveBeenCalledWith(undefined))
expect(getPairing).toHaveBeenCalledWith(undefined)
})
})
describe('MessagingView setup-guide link', () => {
it('hides the setup-guide button for a plugin platform with no docs URL', async () => {
// Teams (and other plugin platforms) ship an empty docs_url. Rendering an
// anchor with href="" let Electron resolve it to the app's own packaged
// index.html and fail with an OS "file not found" dialog. The button must
// simply not appear when there is no guide to open.
getMessagingPlatforms.mockResolvedValue({ platforms: [platform({ docs_url: '' })] })
await renderMessaging()
expect((await screen.findAllByText('Microsoft Teams')).length).toBeGreaterThan(0)
expect(screen.queryByText('Open setup guide')).toBeNull()
})
it('opens a real docs URL through the validated external opener', async () => {
const docsUrl = 'https://hermes-agent.nousresearch.com/docs/user-guide/messaging/teams'
getMessagingPlatforms.mockResolvedValue({ platforms: [platform({ docs_url: docsUrl })] })
await renderMessaging()
const link = await screen.findByText('Open setup guide')
await act(async () => {
fireEvent.click(link)
})
await waitFor(() => expect(openExternalLink).toHaveBeenCalledWith(docsUrl))
})
})
describe('MessagingView pairing', () => {
const pendingUser = {
age_minutes: 3,
platform: 'teams',
request_id: 'a1b2c3d4e5f60718',
user_id: '7712345',
user_name: 'Bee'
}
it('approves the listed request by its request id, never by a code', async () => {
// The whole point of the request-id grant path: the UI can only ever send
// the server-side row id, because the one-time code is never returned by
// the API. Posting anything derived from the code could not be approved.
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
getPairing.mockResolvedValue({ approved: [], pending: [pendingUser] })
approvePairing.mockResolvedValue({ ok: true, user: { user_id: '7712345', user_name: 'Bee' } })
await renderMessaging()
const approve = await screen.findByRole('button', { name: 'Approve' })
await act(async () => {
fireEvent.click(approve)
})
await waitFor(() => expect(approvePairing).toHaveBeenCalledWith('teams', 'a1b2c3d4e5f60718', undefined))
})
it('restores the pending row when approval fails', async () => {
// Optimistic removal must not silently swallow the request: a failed
// approve has to leave the operator something to retry.
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
getPairing.mockResolvedValue({ approved: [], pending: [pendingUser] })
approvePairing.mockRejectedValue(new Error('500 boom'))
await renderMessaging()
await act(async () => {
fireEvent.click(await screen.findByRole('button', { name: 'Approve' }))
})
expect(await screen.findByRole('button', { name: 'Approve' })).toBeTruthy()
expect(screen.getByText('Bee')).toBeTruthy()
})
it('shows no pairing affordance when nobody is waiting', async () => {
// Approvals are rare; an always-present empty state would be permanent
// chrome on a page that is otherwise about credentials.
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
getPairing.mockResolvedValue({ approved: [], pending: [] })
await renderMessaging()
expect((await screen.findAllByText('Microsoft Teams')).length).toBeGreaterThan(0)
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
expect(screen.queryByText(/Pending requests/)).toBeNull()
})
it('still renders platforms when the pairing endpoint fails', async () => {
// An older backend without the endpoint must not blank the page.
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
getPairing.mockRejectedValue(new Error('404 not found'))
await renderMessaging()
expect((await screen.findAllByText('Microsoft Teams')).length).toBeGreaterThan(0)
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
})
it('refetches pending rows on pairing.changed, not on platforms.changed', async () => {
// The two signals are not interchangeable: platforms.changed tracks
// connect/disconnect health via gateway_state.json, which a new pairing
// request never moves. Riding it would leave someone invisible in the
// pending list until an unrelated reconnect happened to fire.
const { $changeEventsAvailable, $pairingChangeTick, $platformsChangeTick } = await import('@/store/live-sync')
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
getPairing.mockResolvedValue({ approved: [], pending: [] })
await renderMessaging()
await act(async () => {
$changeEventsAvailable.set(true)
})
getPairing.mockClear()
// Someone DMs the bot: the store moves, the watcher ticks pairing.changed.
getPairing.mockResolvedValue({ approved: [], pending: [pendingUser] })
await act(async () => {
$pairingChangeTick.set($pairingChangeTick.get() + 1)
})
await waitFor(() => expect(getPairing).toHaveBeenCalled())
expect(await screen.findByRole('button', { name: 'Approve' })).toBeTruthy()
// A platform health tick alone must not be what fetches pairing.
getPairing.mockClear()
await act(async () => {
$platformsChangeTick.set($platformsChangeTick.get() + 1)
})
expect(getPairing).not.toHaveBeenCalled()
})
})
+962
View File
@@ -0,0 +1,962 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { StatusDot, type StatusTone } from '@/components/status-dot'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { ErrorBanner } from '@/components/ui/error-state'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Tip } from '@/components/ui/tooltip'
import {
approvePairing,
getMessagingPlatforms,
getPairing,
type MessagingEnvVarInfo,
type MessagingPlatformInfo,
type PairingUser,
revokePairing,
updateMessagingPlatform
} from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { openExternalLink } from '@/lib/external-link'
import { ExternalLink, Save, Trash2 } from '@/lib/icons'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { $changeEventsAvailable, $pairingChangeTick, $platformsChangeTick } from '@/store/live-sync'
import { notify, notifyError } from '@/store/notifications'
import { $settingsRequestProfile } from '@/store/settings-scope'
import { runGatewayRestart } from '@/store/system-actions'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { DetailColumn, ListColumn, MasterDetail } from '../master-detail'
import { PageSearchShell } from '../page-search-shell'
import { CREDENTIAL_CONTROL_CLASS } from '../settings/credential-key-ui'
import { ListRow } from '../settings/primitives'
import { SettingsProfileScope } from '../settings/profile-scope'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { PlatformAvatar } from './platform-icon'
interface MessagingViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
}
type EditMap = Record<string, Record<string, string>>
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 stateLabel = (state: null | string | undefined, m: Translations['messaging']) =>
state ? m.states[state] || state.replace(/_/g, ' ') : m.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)
)
/** Stable row identity: a user id is only unique within its platform. */
const pairingKey = (user: PairingUser) => `${user.platform}:${user.user_id}`
const pairingLabel = (user: PairingUser) => user.user_name || user.user_id
/** Group pairing rows by platform id so a detail pane can slice its own. */
function byPlatform(rows: PairingUser[]): Record<string, PairingUser[]> {
const grouped: Record<string, PairingUser[]> = {}
for (const row of rows) {
;(grouped[row.platform] ||= []).push(row)
}
return grouped
}
const FIELD_COPY: Record<string, { advanced?: boolean }> = {
TELEGRAM_PROXY: { advanced: true },
DISCORD_REPLY_TO_MODE: { advanced: true },
DISCORD_ALLOW_ALL_USERS: { advanced: true },
DISCORD_HOME_CHANNEL: { advanced: true },
DISCORD_HOME_CHANNEL_NAME: { advanced: true },
BLUEBUBBLES_ALLOW_ALL_USERS: { advanced: true },
MATTERMOST_ALLOW_ALL_USERS: { advanced: true },
MATTERMOST_HOME_CHANNEL: { advanced: true },
QQ_ALLOW_ALL_USERS: { advanced: true },
QQBOT_HOME_CHANNEL: { advanced: true },
QQBOT_HOME_CHANNEL_NAME: { advanced: true },
WHATSAPP_ENABLED: { advanced: true },
WHATSAPP_MODE: { advanced: true }
}
function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) {
const copy = FIELD_COPY[field.key] || {}
const localized = m.fieldCopy[field.key] || {}
return {
label: localized.label || field.prompt || field.key,
help: localized.help || field.description,
placeholder: localized.placeholder || field.prompt,
advanced: Boolean(copy.advanced || field.advanced)
}
}
export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: MessagingViewProps) {
const { t } = useI18n()
const m = t.messaging
// Shared settings "Applies to" scope, request-shaped (undefined → follow
// the active profile; the API helpers treat null as "target primary").
const scopeProfile = useStore($settingsRequestProfile)
// Both save/toggle toasts offer the same one-click restart.
const restartGatewayAction = { label: t.commandCenter.restartGateway, onClick: () => void runGatewayRestart() }
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
const [pairing, setPairing] = useState<{ approved: PairingUser[]; pending: PairingUser[] }>({
approved: [],
pending: []
})
const [approving, setApproving] = useState<null | string>(null)
const [pendingRevoke, setPendingRevoke] = useState<null | PairingUser>(null)
const [edits, setEdits] = useState<EditMap>({})
const [query, setQuery] = useState('')
const [refreshing, setRefreshing] = useState(false)
const [saving, setSaving] = useState<string | null>(null)
const platformIds = useMemo(() => platforms?.map(p => p.id) ?? [], [platforms])
const [selectedId, setSelectedId] = useRouteEnumParam('platform', platformIds, platformIds[0] ?? '')
const refreshPlatforms = useCallback(
async (silent = false) => {
if (!silent) {
setRefreshing(true)
}
try {
const result = await getMessagingPlatforms(scopeProfile)
setPlatforms(result.platforms)
} catch (err) {
if (!silent) {
notifyError(err, m.loadFailed)
}
} finally {
if (!silent) {
setRefreshing(false)
}
}
},
[m, scopeProfile]
)
// Pairing has its own signal. platforms.changed tracks connect/disconnect
// health via gateway_state.json, which a new pairing request never moves —
// riding it would leave a pending row invisible until something unrelated
// reconnected. Failures stay silent: an older backend without the endpoint
// should show no rows, not an error banner over a working page.
const refreshPairing = useCallback(async () => {
try {
const result = await getPairing(scopeProfile)
setPairing({ approved: result.approved ?? [], pending: result.pending ?? [] })
} catch {
// Leave the last known rows in place rather than blanking them.
}
}, [scopeProfile])
const refreshAll = useCallback(
async (silent = false) => {
await Promise.all([refreshPlatforms(silent), refreshPairing()])
},
[refreshPairing, refreshPlatforms]
)
useRefreshHotkey(() => void refreshAll())
useEffect(() => {
void refreshAll()
}, [refreshAll])
// Scope switch: the mounted list still shows the PREVIOUS profile's
// platforms/pairing while the new fetch is in flight — blank it so stale
// rows can't be toggled against the wrong backend.
const scopeSeenRef = useRef(scopeProfile)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (scope-change guard)
useEffect(() => {
if (scopeSeenRef.current === scopeProfile) {
return
}
scopeSeenRef.current = scopeProfile
setPlatforms(null)
setPairing({ approved: [], pending: [] })
setEdits({})
}, [scopeProfile])
const changeEventsAvailable = useStore($changeEventsAvailable)
const platformsChangeTick = useStore($platformsChangeTick)
const pairingChangeTick = useStore($pairingChangeTick)
// A new pending request (or a grant from another surface) moves the pairing
// store on disk; the change watcher turns that into pairing.changed.
useEffect(() => {
if (!changeEventsAvailable || pairingChangeTick === 0 || document.hidden) {
return
}
void refreshPairing()
}, [changeEventsAvailable, pairingChangeTick, refreshPairing])
// Connection status updates without a manual "check" click. platforms.changed
// (the gateway persisting connect/disconnect/health to gateway_state.json)
// drives the refresh on event-capable backends — no timer; older backends
// keep the legacy visible-tab poll.
useEffect(() => {
if (!changeEventsAvailable || platformsChangeTick === 0 || document.hidden) {
return
}
void refreshPlatforms(true)
}, [changeEventsAvailable, platformsChangeTick, refreshPlatforms])
useEffect(() => {
if (changeEventsAvailable) {
return
}
let cancelled = false
function tick() {
if (cancelled || document.hidden) {
return
}
void refreshAll(true)
}
const id = window.setInterval(tick, 6000)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [changeEventsAvailable, refreshAll])
const selected = useMemo(() => {
if (!platforms) {
return null
}
return platforms.find(platform => platform.id === selectedId) || platforms[0] || null
}, [platforms, selectedId])
const pendingByPlatform = useMemo(() => byPlatform(pairing.pending), [pairing.pending])
const approvedByPlatform = useMemo(() => byPlatform(pairing.approved), [pairing.approved])
const visiblePlatforms = useMemo(() => {
if (!platforms) {
return []
}
const q = normalize(query)
if (!q) {
return platforms
}
return platforms.filter(platform =>
[platform.id, platform.name, platform.description, platform.state]
.filter(Boolean)
.some(value => String(value).toLowerCase().includes(q))
)
}, [platforms, query])
async function handleToggle(platform: MessagingPlatformInfo, enabled: boolean) {
setSaving(`enabled:${platform.id}`)
try {
await updateMessagingPlatform(platform.id, { enabled }, scopeProfile)
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 ? m.platformEnabled(platform.name) : m.platformDisabled(platform.name),
message: m.restartToApply,
action: restartGatewayAction
})
} catch (err) {
notifyError(err, m.failedUpdate(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 }, scopeProfile)
setEdits(current => ({ ...current, [platform.id]: {} }))
await refreshPlatforms()
notify({
kind: 'success',
title: m.setupSaved(platform.name),
message: m.restartToReconnect,
action: restartGatewayAction
})
} catch (err) {
notifyError(err, m.failedSave(platform.name))
} finally {
setSaving(null)
}
}
async function handleClear(platform: MessagingPlatformInfo, key: string) {
setSaving(`clear:${key}`)
try {
await updateMessagingPlatform(platform.id, { clear_env: [key] }, scopeProfile)
setEdits(current => ({
...current,
[platform.id]: {
...(current[platform.id] || {}),
[key]: ''
}
}))
await refreshPlatforms()
notify({ kind: 'success', title: m.keyCleared(key), message: m.setupUpdated(platform.name) })
} catch (err) {
notifyError(err, m.failedClear(key))
} finally {
setSaving(null)
}
}
// Approve/revoke paint from a snapshot immediately, then let the
// authoritative refresh have the last word. A failed write restores the
// snapshot so the row never silently disappears on an error.
async function handleApprove(user: PairingUser) {
if (!user.request_id) {
return
}
const key = pairingKey(user)
const snapshot = pairing
setApproving(key)
setPairing(current => ({
approved: current.approved,
pending: current.pending.filter(row => pairingKey(row) !== key)
}))
try {
await approvePairing(user.platform, user.request_id, scopeProfile)
notify({ kind: 'success', title: m.approvedUser(pairingLabel(user)), message: m.approvedHint })
await refreshPairing()
} catch (err) {
setPairing(snapshot)
// 429 is the code path's brute-force lockout — a distinct condition the
// operator can only wait out, so it gets its own message.
const lockedOut = err instanceof Error && err.message.includes('429')
notifyError(err, lockedOut ? m.pairingLockedOut : m.failedApprove(pairingLabel(user)))
} finally {
setApproving(null)
}
}
// ConfirmDialog owns the pending → done → close beat and shows an inline
// error when onConfirm throws, so this rethrows instead of swallowing.
async function handleRevoke(user: PairingUser) {
const key = pairingKey(user)
const snapshot = pairing
setPairing(current => ({
approved: current.approved.filter(row => pairingKey(row) !== key),
pending: current.pending
}))
try {
await revokePairing(user.platform, user.user_id, scopeProfile)
notify({ kind: 'success', title: m.revokedUser(pairingLabel(user)), message: user.platform })
await refreshPairing()
} catch (err) {
setPairing(snapshot)
throw err
}
}
return (
<PageSearchShell
{...props}
onSearchChange={setQuery}
searchHidden={(platforms?.length ?? 0) === 0}
searchHints={platforms?.slice(0, 5).map(platform => t.common.tryHint(platform.name.toLowerCase()))}
searchPlaceholder={m.search}
searchValue={query}
>
{!platforms ? (
<PageLoader label={m.loading} />
) : (
<div className="flex h-full min-h-0 flex-col">
{/* Which profile's gateway this page configures (hidden for
single-profile users). */}
<SettingsProfileScope className="border-b border-(--ui-stroke-secondary) px-3 py-2" />
<div className="min-h-0 flex-1">
<MasterDetail>
<ListColumn>
<ul className="space-y-1">
{visiblePlatforms.map(platform => (
<li key={platform.id}>
<PlatformRow
active={selected?.id === platform.id}
onSelect={() => setSelectedId(platform.id)}
pendingCount={pendingByPlatform[platform.id]?.length ?? 0}
platform={platform}
/>
</li>
))}
</ul>
</ListColumn>
<DetailColumn
actionBar={
selected && (
<PlatformActionBar
hasEdits={Object.keys(trimEdits(edits[selected.id] || {})).length > 0}
onSave={() => void handleSave(selected)}
onToggle={enabled => void handleToggle(selected, enabled)}
platform={selected}
saving={saving}
/>
)
}
>
{selected && (
<PlatformDetail
approved={approvedByPlatform[selected.id] ?? []}
approving={approving}
edits={edits[selected.id] || {}}
onApprove={user => void handleApprove(user)}
onClear={key => void handleClear(selected, key)}
onEdit={(key, value) =>
setEdits(current => ({
...current,
[selected.id]: {
...(current[selected.id] || {}),
[key]: value
}
}))
}
onRevoke={setPendingRevoke}
pending={pendingByPlatform[selected.id] ?? []}
platform={selected}
saving={saving}
/>
)}
</DetailColumn>
</MasterDetail>
</div>
</div>
)}
<ConfirmDialog
busyLabel={m.revoking}
cancelLabel={t.common.cancel}
confirmLabel={m.revoke}
description={pendingRevoke ? m.revokeDesc(pairingLabel(pendingRevoke)) : null}
destructive
onClose={() => setPendingRevoke(null)}
onConfirm={() => (pendingRevoke ? handleRevoke(pendingRevoke) : undefined)}
open={Boolean(pendingRevoke)}
title={m.revokeTitle}
/>
</PageSearchShell>
)
}
function PlatformRow({
active,
onSelect,
pendingCount,
platform
}: {
active: boolean
onSelect: () => void
pendingCount: number
platform: MessagingPlatformInfo
}) {
const { t } = useI18n()
return (
<button
className={cn(
'row-hover flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:text-foreground',
active ? 'bg-(--ui-row-active-background) text-foreground' : 'text-(--ui-text-secondary)'
)}
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-[length:var(--conversation-text-font-size)] font-normal">{platform.name}</span>
<span className="flex shrink-0 items-center gap-1.5">
{/* Someone is waiting to be let in — the only way this page tells
you so before you open the platform. */}
{pendingCount > 0 && (
<span
aria-label={t.messaging.pendingAria(pendingCount)}
className={cn(
'inline-flex min-w-4 items-center justify-center rounded-full px-1 text-[0.66rem] font-medium tabular-nums',
PILL_TONE.warn
)}
>
{pendingCount}
</span>
)}
<StatusDot tone={stateTone(platform)} />
</span>
</span>
</button>
)
}
function PlatformDetail({
approved,
approving,
edits,
onApprove,
onClear,
onEdit,
onRevoke,
pending,
platform,
saving
}: {
approved: PairingUser[]
approving: null | string
edits: Record<string, string>
onApprove: (user: PairingUser) => void
onClear: (key: string) => void
onEdit: (key: string, value: string) => void
onRevoke: (user: PairingUser) => void
pending: PairingUser[]
platform: MessagingPlatformInfo
saving: string | null
}) {
const { t } = useI18n()
const m = t.messaging
const [showAdvanced, setShowAdvanced] = useState(false)
const requiredFields = platform.env_vars.filter(field => field.required)
const optionalFields = platform.env_vars.filter(field => !field.required && !fieldCopy(field, m).advanced)
const advancedFields = platform.env_vars.filter(field => !field.required && fieldCopy(field, m).advanced)
const hiddenCount = advancedFields.length
return (
<>
<header className="flex items-start gap-3">
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="min-w-0 truncate text-[0.9375rem] font-semibold tracking-tight">{platform.name}</h3>
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state, m)}</StatePill>
{/* Resting states earn no pill — only actionable ones. */}
{!platform.configured && <SetupPill active={false}>{m.needsSetup}</SetupPill>}
{!platform.gateway_running && <SetupPill active={false}>{m.gatewayStopped}</SetupPill>}
</div>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{platform.description}
</p>
<PlatformHint platform={platform} />
</div>
</header>
{platform.error_message && <ErrorBanner>{platform.error_message}</ErrorBanner>}
{/* Pending pairing requests. Rendered only when someone is actually
waiting — an empty-state card here would be permanent chrome on a
page that is usually about credentials, not approvals. */}
{pending.length > 0 && (
<section>
<SectionTitle>{m.pendingRequests(pending.length)}</SectionTitle>
<div className="mt-1 grid gap-1">
{pending.map(user => {
const busy = approving === pairingKey(user)
const waited = typeof user.age_minutes === 'number' ? m.waitingSince(user.age_minutes) : null
return (
<ListRow
action={
<Button
disabled={busy || !user.request_id}
onClick={() => onApprove(user)}
size="sm"
variant="secondary"
>
{busy ? m.approving : m.approve}
</Button>
}
// An unnamed requester is only a user id — showing it as
// both title and description just repeats itself.
description={[user.user_name ? user.user_id : null, waited].filter(Boolean).join(' · ')}
key={pairingKey(user)}
title={pairingLabel(user)}
/>
)
})}
</div>
</section>
)}
{approved.length > 0 && (
<section>
<SectionTitle>{m.approvedUsers(approved.length)}</SectionTitle>
<div className="mt-1 grid gap-1">
{approved.map(user => (
<ListRow
action={
<Button
aria-label={m.revokeAria(pairingLabel(user))}
onClick={() => onRevoke(user)}
size="sm"
variant="ghost"
>
{m.revoke}
</Button>
}
description={user.user_name ? user.user_id : undefined}
key={pairingKey(user)}
title={pairingLabel(user)}
/>
))}
</div>
</section>
)}
<section>
<SectionTitle>{m.getCredentials}</SectionTitle>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{introCopy(platform, m)}
</p>
{platform.docs_url && (
<div className="mt-3">
<Button asChild size="sm" variant="textStrong">
<a
href={platform.docs_url}
onClick={event => {
// Route through the validated external opener instead of
// letting Electron resolve the anchor. A packaged build's
// empty/relative href resolves to the app's own
// index.html file path, which shell.openPath then fails to
// open ("file not found"). Plugin platforms (Teams, etc.)
// ship no docs_url, so this guard + handler keeps the
// button from ever pointing at a local bundle path.
event.preventDefault()
openExternalLink(platform.docs_url)
}}
rel="noreferrer"
target="_blank"
>
{m.openSetupGuide}
<ExternalLink className="size-3.5" />
</a>
</Button>
</div>
)}
</section>
<section>
<SectionTitle>{m.required}</SectionTitle>
<div className="mt-3 grid gap-1">
{requiredFields.length > 0 ? (
requiredFields.map(field => (
<MessagingField
edits={edits}
field={field}
key={field.key}
onClear={onClear}
onEdit={onEdit}
saving={saving}
/>
))
) : (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{m.noTokenNeeded}
</p>
)}
</div>
</section>
{optionalFields.length > 0 && (
<section>
<SectionTitle>{m.recommended}</SectionTitle>
<div className="mt-3 grid gap-1">
{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 py-0.5 text-left text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setShowAdvanced(value => !value)}
type="button"
>
<span>{m.advanced(hiddenCount)}</span>
<DisclosureCaret open={showAdvanced} size="0.875rem" />
</button>
{showAdvanced && (
<div className="mt-3 grid gap-1">
{advancedFields.map(field => (
<MessagingField
edits={edits}
field={field}
key={field.key}
onClear={onClear}
onEdit={onEdit}
saving={saving}
/>
))}
</div>
)}
</section>
)}
</>
)
}
function PlatformActionBar({
hasEdits,
onSave,
onToggle,
platform,
saving
}: {
hasEdits: boolean
onSave: () => void
onToggle: (enabled: boolean) => void
platform: MessagingPlatformInfo
saving: string | null
}) {
const { t } = useI18n()
const m = t.messaging
const isSavingEnv = saving === `env:${platform.id}`
return (
<>
<Switch
aria-label={platform.enabled ? m.disableAria(platform.name) : m.enableAria(platform.name)}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
size="xs"
/>
<div className="ml-auto flex items-center gap-2">
{hasEdits && <span className="text-xs text-muted-foreground">{m.unsavedChanges}</span>}
<Button disabled={!hasEdits || isSavingEnv} onClick={onSave} size="sm">
<Save />
{isSavingEnv ? m.saving : m.saveChanges}
</Button>
</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 and app-level token.',
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:
"Run `hermes gateway setup`, select Weixin, then scan and confirm the QR code with a personal WeChat account. Hermes connects through Tencent's iLink Bot API and saves the credentials.",
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, m: Translations['messaging']) =>
m.platformIntro[platform.id] || 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 { t } = useI18n()
const m = t.messaging
const copy = fieldCopy(field, m)
const fieldId = `messaging-field-${field.key}`
return (
<ListRow
action={
<div className="flex items-center gap-2">
<Input
className={CREDENTIAL_CONTROL_CLASS}
id={fieldId}
onChange={event => onEdit(field.key, event.target.value)}
placeholder={field.is_set ? field.redacted_value || m.replaceValue : copy.placeholder}
type={field.is_password ? 'password' : 'text'}
value={edits[field.key] || ''}
/>
{field.url && (
<Tip label={m.openDocs}>
<Button asChild className="size-8 shrink-0" variant="ghost">
<a href={field.url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3.5" />
</a>
</Button>
</Tip>
)}
{field.is_set && (
<Tip label={m.clearField(field.key)}>
<Button
className="size-8 shrink-0"
disabled={saving === `clear:${field.key}`}
onClick={() => onClear(field.key)}
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
</Tip>
)}
</div>
}
description={copy.help}
title={
<span className="flex flex-wrap items-center gap-2">
<label htmlFor={fieldId}>{copy.label}</label>
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">{m.saved}</span>}
</span>
}
/>
)
}
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 }) {
const { t } = useI18n()
if (!platform.enabled || platform.state === 'connected') {
return null
}
const hint =
platform.state === 'pending_restart'
? t.messaging.hintPendingRestart
: platform.gateway_running
? null
: t.messaging.hintGatewayStopped
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>
)
}
@@ -0,0 +1,101 @@
import {
SiApple,
SiBilibili,
SiDiscord,
SiGmail,
SiHomeassistant,
SiMatrix,
SiMattermost,
SiQq,
SiSignal,
SiTelegram,
SiWechat,
SiWhatsapp
} from '@icons-pack/react-simple-icons'
import type { ComponentPropsWithoutRef, ComponentType, SVGProps } from 'react'
import { forwardRef, memo } from 'react'
import { AvatarChip } from '@/components/ui/avatar-chip'
import { Globe, Link as LinkIcon, MessageSquareText } from '@/lib/icons'
// ---------------------------------------------------------------------------
// Photon brand icon — three diagonal rounded bars (the Photon logo mark).
// Rendered at ~14 px inside the PlatformAvatar so the bars are kept thick
// enough to stay legible. At small sizes the bars blend into a distinctive
// silhouette; the wide triangular spacing preserves the logo's identity.
// ---------------------------------------------------------------------------
function PhotonIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
<rect height="10" rx="1.25" transform="rotate(15 14 7.5)" width="2.5" x="12.75" y="2.5" />
<rect height="10" rx="1.25" transform="rotate(15 8 13)" width="2.5" x="6.75" y="8" />
<rect height="10" rx="1.25" transform="rotate(15 16 18)" width="2.5" x="14.75" y="13" />
</svg>
)
}
// We render simpleicons.org brand glyphs for platforms whose owners publish a
// usable mark (telegram, discord, matrix, ...). A few brands — Slack, Dingtalk,
// Feishu, WeCom — have been removed from Simple Icons at the brand owner's
// request, so we fall back to a colored letter monogram for those.
//
// `iconColor` is the brand's hex from simpleicons.org so we can paint each
// glyph in its native color on top of a soft tint. The fallback monogram uses
// the same hex to keep visual consistency.
type IconKind = 'brand' | 'generic'
interface PlatformIconSpec {
Icon?: ComponentType<SVGProps<SVGSVGElement>>
color: string
kind: IconKind
monogram?: string
}
const PLATFORM_ICONS: Record<string, PlatformIconSpec> = {
telegram: { Icon: SiTelegram, color: '#26A5E4', kind: 'brand' },
discord: { Icon: SiDiscord, color: '#5865F2', kind: 'brand' },
// Slack removed from Simple Icons by Salesforce request — letter monogram.
slack: { color: '#4A154B', kind: 'brand', monogram: 'S' },
mattermost: { Icon: SiMattermost, color: '#0058CC', kind: 'brand' },
matrix: { Icon: SiMatrix, color: '#000000', kind: 'brand' },
signal: { Icon: SiSignal, color: '#3A76F0', kind: 'brand' },
whatsapp: { Icon: SiWhatsapp, color: '#25D366', kind: 'brand' },
bluebubbles: { Icon: SiApple, color: '#0BD318', kind: 'brand' },
photon: { Icon: PhotonIcon, color: '#6366F1', kind: 'brand' },
homeassistant: { Icon: SiHomeassistant, color: '#18BCF2', kind: 'brand' },
email: { Icon: SiGmail, color: '#EA4335', kind: 'brand' },
sms: { Icon: MessageSquareText, color: '#F43F5E', kind: 'generic' },
webhook: { Icon: LinkIcon, color: '#71717A', kind: 'generic' },
api_server: { Icon: Globe, color: '#64748B', kind: 'generic' },
weixin: { Icon: SiWechat, color: '#07C160', kind: 'brand' },
qqbot: { Icon: SiQq, color: '#EB1923', kind: 'brand' },
yuanbao: { Icon: SiBilibili, color: '#FB7299', kind: 'brand' }
}
interface PlatformAvatarProps extends Omit<ComponentPropsWithoutRef<'span'>, 'children'> {
platformId: string
platformName: string
}
// forwardRef + spreading ...rest is required so a wrapping <Tip> (Radix
// Tooltip's `asChild`) can actually attach its trigger: asChild clones this
// component and injects a ref plus pointer/focus/aria handlers onto it. A
// plain function component with no ref/rest forwarding drops all of that
// silently — the tooltip renders but never opens (#67500).
export const PlatformAvatar = memo(
forwardRef<HTMLSpanElement, PlatformAvatarProps>(function PlatformAvatar(
{ className, platformId, platformName, ...rest },
ref
) {
return (
<AvatarChip
aria-hidden="true"
brand={PLATFORM_ICONS[platformId]}
className={className}
name={platformName}
ref={ref}
{...rest}
/>
)
})
)