fix(desktop): broken "Open setup guide" button for plugin platforms

On the desktop Channels / Messaging page, the "Open setup guide" button was
rendered as a bare <a href={platform.docs_url} target="_blank"> with no guard.
Plugin-provided platforms (Microsoft Teams, Google Chat, Line, Raft, Yuanbao,
…) ship an empty docs_url, so the anchor's href was "".

In a packaged build, Electron resolves an empty href against the current
document — the app's own index.html inside the asar bundle — and
shell.openPath then fails with an OS "file not found" dialog. This is exactly
the Windows error reported for Messaging → Teams → Open guide.

Fix (3 changes):

1. fix(desktop) — Only render the "Open setup guide" button when docs_url is
   non-empty, and route clicks through openExternalLink so a relative/empty
   value can never be treated as a local bundle path. Fixes the whole class
   (every plugin platform), not just Teams.

2. fix(messaging) — Give the Teams platform plugin a real docs_url (Microsoft
   Teams setup guide) so its card shows a working button instead of nothing.

3. fix(messaging) — Give the Google Chat platform plugin a real docs_url
   (Google Chat setup guide) so its card shows a working button instead of
   nothing. Originally from #48940; folded in here because that PR's test
   was broken (it queried the HTTP endpoint, but google_chat is a dynamic
   enum member that only appears after the adapter module is imported).

Test plan:
- apps/desktop — new src/app/messaging/index.test.tsx: button is hidden when
  docs_url is empty; a real URL opens via the validated external opener (does
  not navigate).
- apps/desktop typecheck (tsc --noEmit) clean.
- backend — test_teams_messaging_metadata_links_setup_guide: the Teams catalog
  entry exposes the setup-guide docs_url.
- backend — test_google_chat_messaging_metadata_links_setup_guide: the Google
  Chat catalog entry exposes the setup-guide docs_url.

Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
Co-authored-by: p-andhika <andhika.prakasiwi@gmail.com>
This commit is contained in:
kshitijk4poor 2026-06-27 03:56:31 +05:30 committed by kshitij
parent 58919f68ab
commit 244a6f2ceb
4 changed files with 152 additions and 8 deletions

View file

@ -0,0 +1,89 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MessagingPlatformInfo } from '@/types/hermes'
const getMessagingPlatforms = vi.fn()
const updateMessagingPlatform = vi.fn()
const openExternalLink = vi.fn()
vi.mock('@/hermes', () => ({
getMessagingPlatforms: () => getMessagingPlatforms(),
updateMessagingPlatform: (id: string, body: unknown) => updateMessagingPlatform(id, body)
}))
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' })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
async function renderMessaging() {
const { MessagingView } = await import('./index')
return render(
<MemoryRouter>
<MessagingView />
</MemoryRouter>
)
}
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')
fireEvent.click(link)
await waitFor(() => expect(openExternalLink).toHaveBeenCalledWith(docsUrl))
})
})

View file

@ -14,6 +14,7 @@ import {
updateMessagingPlatform
} from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { openExternalLink } from '@/lib/external-link'
import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@ -404,14 +405,31 @@ function PlatformDetail({
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{introCopy(platform, m)}
</p>
<div className="mt-3">
<Button asChild size="sm" variant="textStrong">
<a href={platform.docs_url} rel="noreferrer" target="_blank">
{m.openSetupGuide}
<ExternalLink className="size-3.5" />
</a>
</Button>
</div>
{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>

View file

@ -4749,6 +4749,11 @@ _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = {
),
"required_env": ("FEISHU_APP_ID", "FEISHU_APP_SECRET"),
},
"google_chat": {
"name": "Google Chat",
"description": "Connect Hermes to Google Chat via Cloud Pub/Sub.",
"docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/google_chat",
},
"wecom": {
"name": "WeCom (group bot)",
"description": "Send-only WeCom group bot via webhook.",
@ -4798,6 +4803,12 @@ _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = {
"env_vars": ("QQ_APP_ID", "QQ_CLIENT_SECRET", "QQ_ALLOWED_USERS"),
"required_env": ("QQ_APP_ID", "QQ_CLIENT_SECRET"),
},
# Teams ships as a platform plugin, so its name/env vars come from the
# plugin registry. Only the docs link needs an override here so the
# Channels page can point at the Microsoft Teams setup guide.
"teams": {
"docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/teams",
},
"yuanbao": {
"name": "Yuanbao (元宝)",
"description": "Connect Hermes to Tencent Yuanbao.",
@ -4842,6 +4853,7 @@ _PLATFORM_ORDER: tuple[str, ...] = (
"sms",
"dingtalk",
"feishu",
"google_chat",
"wecom",
"wecom_callback",
"weixin",

View file

@ -1746,6 +1746,31 @@ class TestWebServerEndpoints:
assert "QR login" in fields[key]["description"]
assert "Official Account" not in fields[key]["description"]
def test_teams_messaging_metadata_links_setup_guide(self):
# Teams is a platform plugin, so the catalog entry is built from the
# plugin registry. The override must still supply a docs link so the
# Channels page renders a working "Open setup guide" button instead of
# an empty href (which resolves to the packaged app's own index.html).
from hermes_cli.web_server import _build_catalog_entry
teams = _build_catalog_entry("teams")
assert teams["docs_url"] == (
"https://hermes-agent.nousresearch.com/docs/user-guide/messaging/teams"
)
def test_google_chat_messaging_metadata_links_setup_guide(self):
# Google Chat is a platform plugin, so the catalog entry is built from
# the plugin registry. The override must supply a docs link so the
# Channels page renders a working "Open setup guide" button instead of
# an empty href (which resolves to the packaged app's own index.html).
from hermes_cli.web_server import _build_catalog_entry
google_chat = _build_catalog_entry("google_chat")
assert google_chat["name"] == "Google Chat"
assert google_chat["docs_url"] == (
"https://hermes-agent.nousresearch.com/docs/user-guide/messaging/google_chat"
)
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