diff --git a/.github/pr-assets/634/after-auth-detail.png b/.github/pr-assets/634/after-auth-detail.png
new file mode 100644
index 00000000..06d65687
Binary files /dev/null and b/.github/pr-assets/634/after-auth-detail.png differ
diff --git a/.github/pr-assets/634/after-channels-list.png b/.github/pr-assets/634/after-channels-list.png
new file mode 100644
index 00000000..d1129eba
Binary files /dev/null and b/.github/pr-assets/634/after-channels-list.png differ
diff --git a/.github/pr-assets/634/after-missing-detail.png b/.github/pr-assets/634/after-missing-detail.png
new file mode 100644
index 00000000..bf44cc63
Binary files /dev/null and b/.github/pr-assets/634/after-missing-detail.png differ
diff --git a/.github/pr-assets/634/after-overview.png b/.github/pr-assets/634/after-overview.png
new file mode 100644
index 00000000..f62afdf2
Binary files /dev/null and b/.github/pr-assets/634/after-overview.png differ
diff --git a/.github/pr-assets/634/before-auth-detail.png b/.github/pr-assets/634/before-auth-detail.png
new file mode 100644
index 00000000..3c653e16
Binary files /dev/null and b/.github/pr-assets/634/before-auth-detail.png differ
diff --git a/.github/pr-assets/634/before-channels-list.png b/.github/pr-assets/634/before-channels-list.png
new file mode 100644
index 00000000..d28f8526
Binary files /dev/null and b/.github/pr-assets/634/before-channels-list.png differ
diff --git a/.github/pr-assets/634/before-missing-detail.png b/.github/pr-assets/634/before-missing-detail.png
new file mode 100644
index 00000000..33ec47da
Binary files /dev/null and b/.github/pr-assets/634/before-missing-detail.png differ
diff --git a/.github/pr-assets/634/before-overview.png b/.github/pr-assets/634/before-overview.png
new file mode 100644
index 00000000..d5c832fe
Binary files /dev/null and b/.github/pr-assets/634/before-overview.png differ
diff --git a/.github/pr-assets/634/index.html b/.github/pr-assets/634/index.html
new file mode 100644
index 00000000..fd943f3b
--- /dev/null
+++ b/.github/pr-assets/634/index.html
@@ -0,0 +1,668 @@
+
+
+
+
+
+ PR #634 — Channel Health Diagnostics UI Before / After
+
+
+
+
+
+
+
Draft PR #634 • Visual Review
+
Channel Health Diagnostics UI Before / After
+
+ This evidence pack documents the user-facing impact of the channel-health diagnostics
+ work for issue #628 and the follow-up UI hardening that landed during review.
+ The screenshots show the same seeded scenarios before the PR and after the current draft
+ head, so the deltas are attributable to this branch rather than a different test setup.
+
+
+
+
Issue Status
+
Partially solved. The product now explains the failure path, but bad credentials still need operator action.
Shared diagnostics + remediation, not a Telegram-only patch.
+
+
+
Review Hardening
+
Added neutral checking state, safer tab fallback, and no partial-data overcounting.
+
+
+
+
+
+
What This PR Proves
+
+ The screenshots validate the visible remediation experience. The code changes go a bit
+ further than the screenshots alone, so the report separates visual proof from the
+ additional hardening that prevents misleading UI states.
+
+
+
+ Overview no longer hides the problem
+
+
Before: dashboard-level attention existed, but the missing-credentials path was easy to miss.
+
After: the overview clearly communicates that channel attention exists and aligns with the richer downstream diagnostics.
+
+
+
+ Channels list now points to action
+
+
Before: rows could degrade into vague summaries or omit the useful next step.
+
After: the row copy names the failure and exposes the remediation action more directly.
+
+
+
+ Auth failures are diagnosable
+
+
Before: the detail page lacked enough structure for a user to understand what failed.
+
After: the detail page shows what happened, recommended action, credentials entry point, and technical detail.
+
+
+
+ Config failures are diagnosable
+
+
Before: missing credentials did not surface a complete remediation path.
+
After: the detail page explains the config problem and exposes the credentials action clearly.
+
+
+
+
+
+
+
Overview
+
+ The dashboard summary is the first place a user looks. It now leads toward the richer diagnostics
+ instead of leaving the channel state effectively opaque.
+
+
+
+
+
Overview — Before
+
Before
+
+
+
+ Before: the overview conveyed that attention existed, but it did not make the missing-credentials
+ path nearly as actionable as the PR now does downstream.
+
+
+
+
+
Overview — After
+
After
+
+
+
+ After: the overview aligns with the richer diagnostics flow and no longer relies on the old opaque
+ failure presentation.
+
+
+
+
+
+
+
Channels List
+
+ This is where the PR most clearly replaces vague channel status with named failures and next-step copy.
+
+
+
+
+
Channels List — Before
+
Before
+
+
+
+ Before: summaries and remediation copy were incomplete. The list did not consistently tell the user
+ what failed or which action to take next.
+
+
+
+
+
Channels List — After
+
After
+
+
+
+ After: the row naming and next-step prompts are explicit. The UI now makes the channel-health state
+ actionable rather than just visible.
+
+
+
+
+
+
+
Auth Failure Detail
+
+ This captures the original class of incident from #628: a channel is enabled but blocked by
+ auth/runtime reality. The page now explains that state instead of flattening it.
+
+
+
+
+
Auth Detail — Before
+
Before
+
+
+
+ Before: the user could land on the channel detail view without enough structure to understand
+ what happened, what to try next, or where the credentials/debug entry point was.
+
+
+
+
+
Auth Detail — After
+
After
+
+
+
+ After: the page names the auth failure, provides recommended action, exposes the credentials entry
+ point, and shows technical detail useful for debugging.
+
+
+
+
+
+
+
Missing Credentials Detail
+
+ Missing or incomplete channel configuration now gets the same structured treatment as auth failures.
+
+
+
+
+
Missing Credentials — Before
+
Before
+
+
+
+ Before: the config failure path was visually thin and did not fully guide the user back to the
+ required credentials work.
+
+
+
+
+
Missing Credentials — After
+
After
+
+
+
+ After: the UI names the config problem, explains the credentials requirement, and surfaces the
+ action path directly on the page.
+
+
+
+
+
+
+
Additional Hardening Beyond The Screenshots
+
+ Review surfaced a few cases where the visible diagnostics could still be misleading. Those are fixed in
+ code even when they do not have a dedicated screenshot in this pack.
+
+
+
+ No more false “Stopped” state for unknown-but-enabled channels
+
+
Enabled channels without backend status now stay neutral as Checking.
+
This prevents the UI from manufacturing attention where the runtime has not yet reported anything.
+
+
+
+ No more partial-data overcounting in overview
+
+
Overview attention synthesis only runs when the loaded instance slice is complete.
+
This avoids claiming more failing channels than the current dataset can justify.
+
+
+
+ Fallback remediation is channel-specific
+
+
zalo_personal now points to re-auth instead of generic credentials repair.
+
Generic missing-credentials fallback stays on the credentials path with localized copy.
+
+
+
+ Invalid channel detail tabs fail safely
+
+
Unsupported ?tab= values now fall back to general.
+
Non-Telegram channels can no longer land on the Telegram-only groups tab.
+
+
+
+
+
+
+
Assertion Snapshot
+
+ These structured checks back up the screenshots by recording the UI markers that were present before and after.
+
+ Visual verdict: this PR fixes GoClaw’s product-side diagnosability gap for the seeded auth and config
+ channel failures. It does not auto-repair invalid credentials, so the incident class from #628
+ is better described as diagnosable and actionable, not automatically healed.
+
+
+
+
+
+
+
diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go
index c60b34b0..eb2a47af 100644
--- a/cmd/gateway_channels_setup.go
+++ b/cmd/gateway_channels_setup.go
@@ -28,9 +28,26 @@ import (
// registerConfigChannels registers config-based channels as fallback when no DB instances are loaded.
func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, msgBus *bus.MessageBus, pgStores *store.Stores, instanceLoader *channels.InstanceLoader) {
- if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token != "" && instanceLoader == nil {
- tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pgStores.Pairing)
- if err != nil {
+ if instanceLoader != nil {
+ return
+ }
+
+ recordMissingConfig := func(name, detail string) {
+ channelMgr.RecordHealth(name, channels.NewChannelHealthForType(
+ name,
+ channels.ChannelHealthStateFailed,
+ "Missing credentials",
+ detail,
+ channels.ChannelFailureKindConfig,
+ false,
+ ))
+ }
+
+ if cfg.Channels.Telegram.Enabled {
+ if cfg.Channels.Telegram.Token == "" {
+ recordMissingConfig(channels.TypeTelegram, "Set channels.telegram.token in config.")
+ } else if tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pgStores.Pairing); err != nil {
+ channelMgr.RecordFailure(channels.TypeTelegram, "", err)
slog.Error("failed to initialize telegram channel", "error", err)
} else {
channelMgr.RegisterChannel(channels.TypeTelegram, tg)
@@ -38,9 +55,11 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms
}
}
- if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token != "" && instanceLoader == nil {
- dc, err := discord.New(cfg.Channels.Discord, msgBus, nil, nil, nil, nil)
- if err != nil {
+ if cfg.Channels.Discord.Enabled {
+ if cfg.Channels.Discord.Token == "" {
+ recordMissingConfig(channels.TypeDiscord, "Set channels.discord.token in config.")
+ } else if dc, err := discord.New(cfg.Channels.Discord, msgBus, nil, nil, nil, nil); err != nil {
+ channelMgr.RecordFailure(channels.TypeDiscord, "", err)
slog.Error("failed to initialize discord channel", "error", err)
} else {
channelMgr.RegisterChannel(channels.TypeDiscord, dc)
@@ -48,9 +67,11 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms
}
}
- if cfg.Channels.WhatsApp.Enabled && cfg.Channels.WhatsApp.BridgeURL != "" && instanceLoader == nil {
- wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, nil)
- if err != nil {
+ if cfg.Channels.WhatsApp.Enabled {
+ if cfg.Channels.WhatsApp.BridgeURL == "" {
+ recordMissingConfig(channels.TypeWhatsApp, "Set channels.whatsapp.bridge_url in config.")
+ } else if wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, nil); err != nil {
+ channelMgr.RecordFailure(channels.TypeWhatsApp, "", err)
slog.Error("failed to initialize whatsapp channel", "error", err)
} else {
channelMgr.RegisterChannel(channels.TypeWhatsApp, wa)
@@ -58,9 +79,11 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms
}
}
- if cfg.Channels.Zalo.Enabled && cfg.Channels.Zalo.Token != "" && instanceLoader == nil {
- z, err := zalo.New(cfg.Channels.Zalo, msgBus, pgStores.Pairing)
- if err != nil {
+ if cfg.Channels.Zalo.Enabled {
+ if cfg.Channels.Zalo.Token == "" {
+ recordMissingConfig(channels.TypeZaloOA, "Set channels.zalo.token in config.")
+ } else if z, err := zalo.New(cfg.Channels.Zalo, msgBus, pgStores.Pairing); err != nil {
+ channelMgr.RecordFailure(channels.TypeZaloOA, "", err)
slog.Error("failed to initialize zalo channel", "error", err)
} else {
channelMgr.RegisterChannel(channels.TypeZaloOA, z)
@@ -68,9 +91,10 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms
}
}
- if cfg.Channels.ZaloPersonal.Enabled && instanceLoader == nil {
+ if cfg.Channels.ZaloPersonal.Enabled {
zp, err := zalopersonal.New(cfg.Channels.ZaloPersonal, msgBus, pgStores.Pairing, nil)
if err != nil {
+ channelMgr.RecordFailure(channels.TypeZaloPersonal, "", err)
slog.Error("failed to initialize zca channel", "error", err)
} else {
channelMgr.RegisterChannel(channels.TypeZaloPersonal, zp)
@@ -78,19 +102,29 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms
}
}
- if cfg.Channels.Slack.Enabled && cfg.Channels.Slack.BotToken != "" && cfg.Channels.Slack.AppToken != "" && instanceLoader == nil {
- sl, err := slackchannel.New(cfg.Channels.Slack, msgBus, nil, nil)
- if err != nil {
- slog.Error("failed to initialize slack channel", "error", err)
- } else {
- channelMgr.RegisterChannel(channels.TypeSlack, sl)
- slog.Info("slack channel enabled (config)")
+ if cfg.Channels.Slack.Enabled {
+ switch {
+ case cfg.Channels.Slack.BotToken == "":
+ recordMissingConfig(channels.TypeSlack, "Set channels.slack.bot_token in config.")
+ case cfg.Channels.Slack.AppToken == "":
+ recordMissingConfig(channels.TypeSlack, "Set channels.slack.app_token in config.")
+ case true:
+ sl, err := slackchannel.New(cfg.Channels.Slack, msgBus, nil, nil)
+ if err != nil {
+ channelMgr.RecordFailure(channels.TypeSlack, "", err)
+ slog.Error("failed to initialize slack channel", "error", err)
+ } else {
+ channelMgr.RegisterChannel(channels.TypeSlack, sl)
+ slog.Info("slack channel enabled (config)")
+ }
}
}
- if cfg.Channels.Feishu.Enabled && cfg.Channels.Feishu.AppID != "" && instanceLoader == nil {
- f, err := feishu.New(cfg.Channels.Feishu, msgBus, pgStores.Pairing, nil)
- if err != nil {
+ if cfg.Channels.Feishu.Enabled {
+ if cfg.Channels.Feishu.AppID == "" {
+ recordMissingConfig(channels.TypeFeishu, "Set channels.feishu.app_id in config.")
+ } else if f, err := feishu.New(cfg.Channels.Feishu, msgBus, pgStores.Pairing, nil); err != nil {
+ channelMgr.RecordFailure(channels.TypeFeishu, "", err)
slog.Error("failed to initialize feishu channel", "error", err)
} else {
channelMgr.RegisterChannel(channels.TypeFeishu, f)
diff --git a/internal/channels/channel.go b/internal/channels/channel.go
index c4bf4570..b7eec16e 100644
--- a/internal/channels/channel.go
+++ b/internal/channels/channel.go
@@ -13,6 +13,8 @@ import (
"log/slog"
"net/http"
"strings"
+ "sync"
+ "time"
"github.com/google/uuid"
@@ -40,9 +42,9 @@ type DMPolicy string
const (
DMPolicyPairing DMPolicy = "pairing" // Require pairing code
- DMPolicyAllowlist DMPolicy = "allowlist" // Only whitelisted senders
- DMPolicyOpen DMPolicy = "open" // Accept all
- DMPolicyDisabled DMPolicy = "disabled" // Reject all DMs
+ DMPolicyAllowlist DMPolicy = "allowlist" // Only whitelisted senders
+ DMPolicyOpen DMPolicy = "open" // Accept all
+ DMPolicyDisabled DMPolicy = "disabled" // Reject all DMs
)
// GroupPolicy controls how group messages are handled.
@@ -50,8 +52,8 @@ type GroupPolicy string
const (
GroupPolicyOpen GroupPolicy = "open" // Accept all groups
- GroupPolicyAllowlist GroupPolicy = "allowlist" // Only whitelisted groups
- GroupPolicyDisabled GroupPolicy = "disabled" // No group messages
+ GroupPolicyAllowlist GroupPolicy = "allowlist" // Only whitelisted groups
+ GroupPolicyDisabled GroupPolicy = "disabled" // No group messages
)
// Channel type constants used across channel packages and gateway wiring.
@@ -65,6 +67,345 @@ const (
TypeZaloPersonal = "zalo_personal"
)
+// ChannelHealthState captures the current runtime state of a channel instance.
+type ChannelHealthState string
+
+const (
+ ChannelHealthStateRegistered ChannelHealthState = "registered"
+ ChannelHealthStateStarting ChannelHealthState = "starting"
+ ChannelHealthStateHealthy ChannelHealthState = "healthy"
+ ChannelHealthStateDegraded ChannelHealthState = "degraded"
+ ChannelHealthStateFailed ChannelHealthState = "failed"
+ ChannelHealthStateStopped ChannelHealthState = "stopped"
+)
+
+// ChannelFailureKind classifies the dominant cause of the current failure state.
+type ChannelFailureKind string
+
+const (
+ ChannelFailureKindAuth ChannelFailureKind = "auth"
+ ChannelFailureKindConfig ChannelFailureKind = "config"
+ ChannelFailureKindNetwork ChannelFailureKind = "network"
+ ChannelFailureKindUnknown ChannelFailureKind = "unknown"
+)
+
+// ChannelRemediationCode identifies the next operator step suggested for a channel incident.
+type ChannelRemediationCode string
+
+const (
+ ChannelRemediationCodeReauth ChannelRemediationCode = "reauth"
+ ChannelRemediationCodeOpenCredentials ChannelRemediationCode = "open_credentials"
+ ChannelRemediationCodeOpenAdvanced ChannelRemediationCode = "open_advanced"
+ ChannelRemediationCodeCheckNetwork ChannelRemediationCode = "check_network"
+)
+
+// ChannelRemediationTarget tells the UI which existing surface can help resolve the issue.
+type ChannelRemediationTarget string
+
+const (
+ ChannelRemediationTargetCredentials ChannelRemediationTarget = "credentials"
+ ChannelRemediationTargetAdvanced ChannelRemediationTarget = "advanced"
+ ChannelRemediationTargetReauth ChannelRemediationTarget = "reauth"
+ ChannelRemediationTargetDetails ChannelRemediationTarget = "details"
+)
+
+// ChannelRemediation contains a coarse, additive operator hint for the current incident.
+type ChannelRemediation struct {
+ Code ChannelRemediationCode `json:"code"`
+ Headline string `json:"headline"`
+ Hint string `json:"hint,omitempty"`
+ Target ChannelRemediationTarget `json:"target,omitempty"`
+}
+
+// ChannelHealth is the shared runtime health snapshot exposed via channels.status.
+type ChannelHealth struct {
+ ChannelType string `json:"-"`
+ Enabled bool `json:"enabled"`
+ Running bool `json:"running"`
+ State ChannelHealthState `json:"state"`
+ Summary string `json:"summary,omitempty"`
+ Detail string `json:"detail,omitempty"`
+ FailureKind ChannelFailureKind `json:"failure_kind,omitempty"`
+ Retryable bool `json:"retryable"`
+ CheckedAt time.Time `json:"checked_at,omitempty"`
+ FailureCount int `json:"failure_count,omitempty"`
+ ConsecutiveFailures int `json:"consecutive_failures,omitempty"`
+ FirstFailedAt time.Time `json:"first_failed_at,omitempty"`
+ LastFailedAt time.Time `json:"last_failed_at,omitempty"`
+ LastHealthyAt time.Time `json:"last_healthy_at,omitempty"`
+ Remediation *ChannelRemediation `json:"remediation,omitempty"`
+}
+
+// ChannelErrorInfo contains shared error classification output for operators.
+type ChannelErrorInfo struct {
+ Summary string
+ Detail string
+ Kind ChannelFailureKind
+ Retryable bool
+}
+
+// ClassifyChannelError maps common channel startup/runtime failures into operator-facing buckets.
+func ClassifyChannelError(err error) ChannelErrorInfo {
+ if err == nil {
+ return ChannelErrorInfo{
+ Summary: "Channel failed",
+ Detail: "GoClaw could not determine the latest channel error.",
+ Kind: ChannelFailureKindUnknown,
+ Retryable: true,
+ }
+ }
+
+ detail := err.Error()
+ msg := strings.ToLower(detail)
+
+ switch {
+ case strings.Contains(msg, "401") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "forbidden"):
+ return ChannelErrorInfo{
+ Summary: "Authentication failed",
+ Detail: "The upstream service rejected the configured credentials or session.",
+ Kind: ChannelFailureKindAuth,
+ Retryable: false,
+ }
+ case strings.Contains(msg, "invalid proxy"):
+ return ChannelErrorInfo{
+ Summary: "Configuration is invalid",
+ Detail: "Configured proxy URL is invalid.",
+ Kind: ChannelFailureKindConfig,
+ Retryable: false,
+ }
+ case strings.Contains(msg, "agent ") && strings.Contains(msg, " not found for channel"):
+ return ChannelErrorInfo{
+ Summary: "Configuration is invalid",
+ Detail: "The linked agent for this channel could not be found.",
+ Kind: ChannelFailureKindConfig,
+ Retryable: false,
+ }
+ case strings.Contains(msg, "token is required"),
+ strings.Contains(msg, "missing credentials"),
+ strings.Contains(msg, "decode "),
+ strings.Contains(msg, "not found for channel"),
+ strings.Contains(msg, "required"):
+ safeDetail := "A required channel setting is missing or invalid."
+ switch {
+ case strings.Contains(msg, "token is required"), strings.Contains(msg, "missing credentials"):
+ safeDetail = "Required channel credentials are missing or incomplete."
+ case strings.Contains(msg, "decode "):
+ safeDetail = "Saved channel configuration could not be parsed."
+ }
+ return ChannelErrorInfo{
+ Summary: "Configuration is invalid",
+ Detail: safeDetail,
+ Kind: ChannelFailureKindConfig,
+ Retryable: false,
+ }
+ case strings.Contains(msg, "timeout"),
+ strings.Contains(msg, "i/o timeout"),
+ strings.Contains(msg, "deadline exceeded"),
+ strings.Contains(msg, "context deadline exceeded"):
+ return ChannelErrorInfo{
+ Summary: "Network error",
+ Detail: "Timed out while reaching the upstream service.",
+ Kind: ChannelFailureKindNetwork,
+ Retryable: true,
+ }
+ case strings.Contains(msg, "connection refused"):
+ return ChannelErrorInfo{
+ Summary: "Network error",
+ Detail: "The upstream service refused the connection attempt.",
+ Kind: ChannelFailureKindNetwork,
+ Retryable: true,
+ }
+ case strings.Contains(msg, "no such host"):
+ return ChannelErrorInfo{
+ Summary: "Network error",
+ Detail: "GoClaw could not resolve the upstream host.",
+ Kind: ChannelFailureKindNetwork,
+ Retryable: true,
+ }
+ case strings.Contains(msg, "connection reset"),
+ strings.Contains(msg, "eof"):
+ return ChannelErrorInfo{
+ Summary: "Network error",
+ Detail: "The upstream service closed the connection unexpectedly.",
+ Kind: ChannelFailureKindNetwork,
+ Retryable: true,
+ }
+ case strings.Contains(msg, "dial tcp"),
+ strings.Contains(msg, "connection refused"),
+ strings.Contains(msg, "tcp "):
+ return ChannelErrorInfo{
+ Summary: "Network error",
+ Detail: "GoClaw could not open a network connection to the upstream service.",
+ Kind: ChannelFailureKindNetwork,
+ Retryable: true,
+ }
+ default:
+ return ChannelErrorInfo{
+ Summary: "Channel failed",
+ Detail: "An unexpected channel error occurred. Review server logs for the full error.",
+ Kind: ChannelFailureKindUnknown,
+ Retryable: true,
+ }
+ }
+}
+
+// NewChannelHealth builds a shared runtime snapshot with a current timestamp.
+func NewChannelHealth(state ChannelHealthState, summary, detail string, kind ChannelFailureKind, retryable bool) ChannelHealth {
+ return NewChannelHealthForType("", state, summary, detail, kind, retryable)
+}
+
+// NewChannelHealthForType builds a shared runtime snapshot for a specific channel type.
+func NewChannelHealthForType(channelType string, state ChannelHealthState, summary, detail string, kind ChannelFailureKind, retryable bool) ChannelHealth {
+ return ChannelHealth{
+ ChannelType: channelType,
+ Enabled: true,
+ Running: state == ChannelHealthStateHealthy || state == ChannelHealthStateDegraded,
+ State: state,
+ Summary: summary,
+ Detail: detail,
+ FailureKind: kind,
+ Retryable: retryable,
+ CheckedAt: time.Now().UTC(),
+ }
+}
+
+// NewFailedChannelHealth builds a failed snapshot from a classified error.
+func NewFailedChannelHealth(summary string, err error) ChannelHealth {
+ return NewFailedChannelHealthForType("", summary, err)
+}
+
+// NewFailedChannelHealthForType builds a failed snapshot from a classified error for one channel type.
+func NewFailedChannelHealthForType(channelType, summary string, err error) ChannelHealth {
+ info := ClassifyChannelError(err)
+ if summary == "" {
+ summary = info.Summary
+ }
+ return NewChannelHealthForType(channelType, ChannelHealthStateFailed, summary, info.Detail, info.Kind, info.Retryable)
+}
+
+func isFailureState(state ChannelHealthState) bool {
+ return state == ChannelHealthStateFailed || state == ChannelHealthStateDegraded
+}
+
+func mergeChannelHealth(previous, snapshot ChannelHealth) ChannelHealth {
+ if snapshot.CheckedAt.IsZero() {
+ snapshot.CheckedAt = time.Now().UTC()
+ }
+ if snapshot.Enabled == false {
+ snapshot.Enabled = true
+ }
+ if snapshot.ChannelType == "" {
+ snapshot.ChannelType = previous.ChannelType
+ }
+
+ if isFailureState(snapshot.State) {
+ if snapshot.FailureCount == 0 {
+ snapshot.FailureCount = previous.FailureCount + 1
+ }
+ if snapshot.ConsecutiveFailures == 0 {
+ snapshot.ConsecutiveFailures = previous.ConsecutiveFailures + 1
+ }
+ if snapshot.FirstFailedAt.IsZero() {
+ if previous.FirstFailedAt.IsZero() || !isFailureState(previous.State) {
+ snapshot.FirstFailedAt = snapshot.CheckedAt
+ } else {
+ snapshot.FirstFailedAt = previous.FirstFailedAt
+ }
+ }
+ if snapshot.LastFailedAt.IsZero() {
+ snapshot.LastFailedAt = snapshot.CheckedAt
+ }
+ if snapshot.LastHealthyAt.IsZero() {
+ snapshot.LastHealthyAt = previous.LastHealthyAt
+ }
+ } else {
+ if snapshot.FailureCount == 0 {
+ snapshot.FailureCount = previous.FailureCount
+ }
+ snapshot.ConsecutiveFailures = 0
+ snapshot.FirstFailedAt = time.Time{}
+ if snapshot.LastFailedAt.IsZero() {
+ snapshot.LastFailedAt = previous.LastFailedAt
+ }
+ if snapshot.State == ChannelHealthStateHealthy {
+ snapshot.LastHealthyAt = snapshot.CheckedAt
+ } else if snapshot.LastHealthyAt.IsZero() {
+ snapshot.LastHealthyAt = previous.LastHealthyAt
+ }
+ }
+
+ snapshot.Remediation = buildChannelRemediation(snapshot)
+ return snapshot
+}
+
+func buildChannelRemediation(snapshot ChannelHealth) *ChannelRemediation {
+ if !isFailureState(snapshot.State) {
+ return nil
+ }
+
+ text := strings.ToLower(snapshot.Summary + " " + snapshot.Detail)
+
+ switch snapshot.FailureKind {
+ case ChannelFailureKindAuth:
+ if snapshot.ChannelType == TypeZaloPersonal {
+ return &ChannelRemediation{
+ Code: ChannelRemediationCodeReauth,
+ Headline: "Reconnect the channel session",
+ Hint: "Open the sign-in flow again to restore the current session.",
+ Target: ChannelRemediationTargetReauth,
+ }
+ }
+ return &ChannelRemediation{
+ Code: ChannelRemediationCodeOpenCredentials,
+ Headline: "Review channel credentials",
+ Hint: "Open credentials and confirm the current token or secret is still valid.",
+ Target: ChannelRemediationTargetCredentials,
+ }
+ case ChannelFailureKindConfig:
+ if strings.Contains(text, "credential") ||
+ strings.Contains(text, "token") ||
+ strings.Contains(text, "secret") ||
+ strings.Contains(text, "app_id") ||
+ strings.Contains(text, "app id") ||
+ strings.Contains(text, "required") {
+ return &ChannelRemediation{
+ Code: ChannelRemediationCodeOpenCredentials,
+ Headline: "Complete required credentials",
+ Hint: "Open credentials and fill the missing or invalid values for this channel.",
+ Target: ChannelRemediationTargetCredentials,
+ }
+ }
+ return &ChannelRemediation{
+ Code: ChannelRemediationCodeOpenAdvanced,
+ Headline: "Review channel settings",
+ Hint: "Open advanced settings and correct the invalid channel configuration.",
+ Target: ChannelRemediationTargetAdvanced,
+ }
+ case ChannelFailureKindNetwork:
+ return &ChannelRemediation{
+ Code: ChannelRemediationCodeCheckNetwork,
+ Headline: "Check upstream reachability",
+ Hint: "Verify the upstream service is reachable from GoClaw, then inspect proxy or API server settings if you use them.",
+ Target: ChannelRemediationTargetDetails,
+ }
+ default:
+ if snapshot.Retryable {
+ return &ChannelRemediation{
+ Code: ChannelRemediationCodeCheckNetwork,
+ Headline: "Inspect the latest failure",
+ Hint: "Open the channel details and review the latest runtime error before retrying.",
+ Target: ChannelRemediationTargetDetails,
+ }
+ }
+ return &ChannelRemediation{
+ Code: ChannelRemediationCodeOpenAdvanced,
+ Headline: "Review channel settings",
+ Hint: "Open channel settings and inspect the latest error detail.",
+ Target: ChannelRemediationTargetAdvanced,
+ }
+ }
+}
+
// Channel defines the interface that all channel implementations must satisfy.
type Channel interface {
// Name returns the channel instance name (e.g., "telegram", "discord", "slack").
@@ -156,9 +497,11 @@ type BaseChannel struct {
channelType string // platform type; defaults to name if unset
bus *bus.MessageBus
running bool
+ stateMu sync.RWMutex
+ health ChannelHealth
allowList []string
- agentID string // for DB instances: routes to specific agent (empty = use resolveAgentRoute)
- tenantID uuid.UUID // for DB instances: tenant scope (zero = master tenant fallback)
+ agentID string // for DB instances: routes to specific agent (empty = use resolveAgentRoute)
+ tenantID uuid.UUID // for DB instances: tenant scope (zero = master tenant fallback)
contactCollector *store.ContactCollector // optional: auto-collect contacts from channel messages
}
@@ -167,6 +510,7 @@ func NewBaseChannel(name string, msgBus *bus.MessageBus, allowList []string) *Ba
return &BaseChannel{
name: name,
bus: msgBus,
+ health: NewChannelHealthForType(name, ChannelHealthStateRegistered, "Configured", "", ChannelFailureKindUnknown, false),
allowList: allowList,
}
}
@@ -207,10 +551,125 @@ func (c *BaseChannel) SetContactCollector(cc *store.ContactCollector) { c.contac
func (c *BaseChannel) ContactCollector() *store.ContactCollector { return c.contactCollector }
// IsRunning returns whether the channel is running.
-func (c *BaseChannel) IsRunning() bool { return c.running }
+func (c *BaseChannel) IsRunning() bool {
+ c.stateMu.RLock()
+ defer c.stateMu.RUnlock()
+ return c.running
+}
// SetRunning updates the running state.
-func (c *BaseChannel) SetRunning(running bool) { c.running = running }
+func (c *BaseChannel) SetRunning(running bool) {
+ c.stateMu.Lock()
+ defer c.stateMu.Unlock()
+
+ next := c.health
+ next.ChannelType = c.Type()
+ next.Running = running
+ switch {
+ case running && (next.State == "" ||
+ next.State == ChannelHealthStateRegistered ||
+ next.State == ChannelHealthStateStarting ||
+ next.State == ChannelHealthStateStopped):
+ next.State = ChannelHealthStateHealthy
+ if next.Summary == "" ||
+ next.Summary == "Configured" ||
+ next.Summary == "Starting" ||
+ next.Summary == "Stopped" {
+ next.Summary = "Connected"
+ }
+ next.Detail = ""
+ next.FailureKind = ChannelFailureKindUnknown
+ next.Retryable = false
+ next.CheckedAt = time.Now().UTC()
+ case !running && next.State == ChannelHealthStateHealthy:
+ next.State = ChannelHealthStateStopped
+ next.Summary = "Stopped"
+ next.Detail = ""
+ next.FailureKind = ChannelFailureKindUnknown
+ next.Retryable = false
+ next.CheckedAt = time.Now().UTC()
+ default:
+ c.running = running
+ c.health.Running = running
+ return
+ }
+
+ next = mergeChannelHealth(c.health, next)
+ next.Running = running
+ c.running = running
+ c.health = next
+}
+
+// HealthSnapshot returns the current runtime health snapshot for the channel.
+func (c *BaseChannel) HealthSnapshot() ChannelHealth {
+ c.stateMu.RLock()
+ defer c.stateMu.RUnlock()
+
+ snapshot := c.health
+ snapshot.ChannelType = c.Type()
+ snapshot.Enabled = true
+ snapshot.Running = c.running
+ return snapshot
+}
+
+// MarkRegistered records that the channel was configured and registered successfully.
+func (c *BaseChannel) MarkRegistered(summary string) {
+ if summary == "" {
+ summary = "Configured"
+ }
+ c.setHealth(NewChannelHealth(ChannelHealthStateRegistered, summary, "", ChannelFailureKindUnknown, false), false)
+}
+
+// MarkStarting records that the channel is in startup validation / connection setup.
+func (c *BaseChannel) MarkStarting(summary string) {
+ if summary == "" {
+ summary = "Starting"
+ }
+ c.setHealth(NewChannelHealth(ChannelHealthStateStarting, summary, "", ChannelFailureKindUnknown, true), false)
+}
+
+// MarkHealthy records a healthy connected state.
+func (c *BaseChannel) MarkHealthy(summary string) {
+ if summary == "" {
+ summary = "Connected"
+ }
+ c.setHealth(NewChannelHealth(ChannelHealthStateHealthy, summary, "", ChannelFailureKindUnknown, false), false)
+}
+
+// MarkDegraded records a non-fatal warning state.
+func (c *BaseChannel) MarkDegraded(summary, detail string, kind ChannelFailureKind, retryable bool) {
+ if summary == "" {
+ summary = "Running with warnings"
+ }
+ c.setHealth(NewChannelHealth(ChannelHealthStateDegraded, summary, detail, kind, retryable), true)
+}
+
+// MarkFailed records a startup or runtime failure.
+func (c *BaseChannel) MarkFailed(summary, detail string, kind ChannelFailureKind, retryable bool) {
+ if summary == "" {
+ summary = "Channel failed"
+ }
+ c.setHealth(NewChannelHealth(ChannelHealthStateFailed, summary, detail, kind, retryable), true)
+}
+
+// MarkStopped records a cleanly stopped state.
+func (c *BaseChannel) MarkStopped(summary string) {
+ if summary == "" {
+ summary = "Stopped"
+ }
+ c.setHealth(NewChannelHealth(ChannelHealthStateStopped, summary, "", ChannelFailureKindUnknown, false), false)
+}
+
+func (c *BaseChannel) setHealth(snapshot ChannelHealth, _ bool) {
+ c.stateMu.Lock()
+ defer c.stateMu.Unlock()
+
+ snapshot.ChannelType = c.Type()
+ snapshot = mergeChannelHealth(c.health, snapshot)
+ snapshot.Running = snapshot.State == ChannelHealthStateHealthy || snapshot.State == ChannelHealthStateDegraded
+ c.running = snapshot.Running
+ c.health = snapshot
+}
// Bus returns the message bus reference.
func (c *BaseChannel) Bus() *bus.MessageBus { return c.bus }
diff --git a/internal/channels/health_test.go b/internal/channels/health_test.go
new file mode 100644
index 00000000..185ff261
--- /dev/null
+++ b/internal/channels/health_test.go
@@ -0,0 +1,536 @@
+package channels
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+type fakeHealthChannel struct {
+ *BaseChannel
+ startErr error
+ selfMarkFailure bool
+}
+
+func newFakeHealthChannel(name string) *fakeHealthChannel {
+ return &fakeHealthChannel{
+ BaseChannel: NewBaseChannel(name, bus.New(), nil),
+ }
+}
+
+func (c *fakeHealthChannel) Start(context.Context) error {
+ if c.startErr != nil {
+ if c.selfMarkFailure {
+ info := ClassifyChannelError(c.startErr)
+ c.MarkFailed(info.Summary, info.Detail, info.Kind, info.Retryable)
+ }
+ return c.startErr
+ }
+ c.SetRunning(true)
+ return nil
+}
+
+func (c *fakeHealthChannel) Stop(context.Context) error {
+ c.SetRunning(false)
+ return nil
+}
+
+func (c *fakeHealthChannel) Send(context.Context, bus.OutboundMessage) error { return nil }
+
+type loaderHealthChannel struct {
+ base *BaseChannel
+ startErr error
+ selfMarkFailure bool
+}
+
+func newLoaderHealthChannel(name, channelType string) *loaderHealthChannel {
+ base := NewBaseChannel(name, bus.New(), nil)
+ base.SetType(channelType)
+ return &loaderHealthChannel{base: base}
+}
+
+func (c *loaderHealthChannel) Name() string { return c.base.Name() }
+func (c *loaderHealthChannel) Type() string { return c.base.Type() }
+func (c *loaderHealthChannel) IsRunning() bool { return c.base.IsRunning() }
+func (c *loaderHealthChannel) IsAllowed(senderID string) bool { return c.base.IsAllowed(senderID) }
+func (c *loaderHealthChannel) Send(context.Context, bus.OutboundMessage) error { return nil }
+func (c *loaderHealthChannel) Stop(context.Context) error {
+ c.base.MarkStopped("Stopped")
+ return nil
+}
+func (c *loaderHealthChannel) Start(context.Context) error {
+ if c.startErr != nil {
+ if c.selfMarkFailure {
+ info := ClassifyChannelError(c.startErr)
+ c.base.MarkFailed(info.Summary, info.Detail, info.Kind, info.Retryable)
+ }
+ return c.startErr
+ }
+ c.base.MarkHealthy("Connected")
+ return nil
+}
+func (c *loaderHealthChannel) SetType(channelType string) { c.base.SetType(channelType) }
+func (c *loaderHealthChannel) HealthSnapshot() ChannelHealth {
+ return c.base.HealthSnapshot()
+}
+func (c *loaderHealthChannel) MarkFailed(summary, detail string, kind ChannelFailureKind, retryable bool) {
+ c.base.MarkFailed(summary, detail, kind, retryable)
+}
+
+func TestManagerGetStatusIncludesPreRegistrationFailures(t *testing.T) {
+ mgr := NewManager(bus.New())
+
+ mgr.RecordFailureForType("telegram-main", TypeTelegram, "", errors.New(`telego: getMe: api: 401 "Unauthorized"`))
+
+ raw, ok := mgr.GetStatus()["telegram-main"]
+ if !ok {
+ t.Fatal("expected failed instance in status map")
+ }
+ status, ok := raw.(ChannelHealth)
+ if !ok {
+ t.Fatalf("expected ChannelHealth entry, got %T", raw)
+ }
+ if status.State != ChannelHealthStateFailed {
+ t.Fatalf("expected failed state, got %q", status.State)
+ }
+ if status.FailureKind != ChannelFailureKindAuth {
+ t.Fatalf("expected auth failure kind, got %q", status.FailureKind)
+ }
+ if status.Remediation == nil {
+ t.Fatal("expected remediation metadata")
+ }
+ if status.Remediation.Code != ChannelRemediationCodeOpenCredentials {
+ t.Fatalf("expected open_credentials remediation, got %q", status.Remediation.Code)
+ }
+ if status.ConsecutiveFailures != 1 {
+ t.Fatalf("expected consecutive failures to be 1, got %d", status.ConsecutiveFailures)
+ }
+}
+
+func TestClassifyChannelErrorSanitizesSensitiveDetails(t *testing.T) {
+ info := ClassifyChannelError(errors.New(`telego: getMe: api: 401 "Unauthorized" token=123456:ABC host=api.telegram.org`))
+ if info.Detail == "" {
+ t.Fatal("expected sanitized detail")
+ }
+ for _, secret := range []string{"123456:ABC", "api.telegram.org", "Unauthorized"} {
+ if contains := strings.Contains(info.Detail, secret); contains {
+ t.Fatalf("expected detail to omit %q, got %q", secret, info.Detail)
+ }
+ }
+}
+
+func TestManagerStartAllPromotesHealthyChannels(t *testing.T) {
+ mgr := NewManager(bus.New())
+ channel := newFakeHealthChannel("telegram-main")
+ mgr.RegisterChannel("telegram-main", channel)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ if err := mgr.StartAll(ctx); err != nil {
+ t.Fatalf("StartAll returned error: %v", err)
+ }
+
+ raw := mgr.GetStatus()["telegram-main"]
+ status, ok := raw.(ChannelHealth)
+ if !ok {
+ t.Fatalf("expected ChannelHealth entry, got %T", raw)
+ }
+ if !status.Running {
+ t.Fatal("expected running=true")
+ }
+ if status.State != ChannelHealthStateHealthy {
+ t.Fatalf("expected healthy state, got %q", status.State)
+ }
+ if status.LastHealthyAt.IsZero() {
+ t.Fatal("expected last healthy timestamp to be set")
+ }
+ if status.Remediation != nil {
+ t.Fatalf("expected no remediation for healthy channel, got %+v", status.Remediation)
+ }
+}
+
+func TestManagerStartAllCapturesStartupFailures(t *testing.T) {
+ mgr := NewManager(bus.New())
+ channel := newFakeHealthChannel("telegram-main")
+ channel.startErr = errors.New(`telego: getUpdates: api: 401 "Unauthorized"`)
+ mgr.RegisterChannel("telegram-main", channel)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ if err := mgr.StartAll(ctx); err != nil {
+ t.Fatalf("StartAll returned error: %v", err)
+ }
+
+ raw := mgr.GetStatus()["telegram-main"]
+ status, ok := raw.(ChannelHealth)
+ if !ok {
+ t.Fatalf("expected ChannelHealth entry, got %T", raw)
+ }
+ if status.State != ChannelHealthStateFailed {
+ t.Fatalf("expected failed state, got %q", status.State)
+ }
+ if status.FailureKind != ChannelFailureKindAuth {
+ t.Fatalf("expected auth failure kind, got %q", status.FailureKind)
+ }
+ if status.FailureCount < 1 {
+ t.Fatalf("expected failure count to increment, got %d", status.FailureCount)
+ }
+ if status.Remediation == nil {
+ t.Fatal("expected remediation metadata")
+ }
+ if status.ConsecutiveFailures != 1 {
+ t.Fatalf("expected consecutive failures to be 1, got %d", status.ConsecutiveFailures)
+ }
+}
+
+func TestManagerStartAllDoesNotDoubleCountSelfReportedStartupFailure(t *testing.T) {
+ mgr := NewManager(bus.New())
+ channel := newFakeHealthChannel("telegram-main")
+ channel.startErr = errors.New(`telego: getUpdates: api: 401 "Unauthorized"`)
+ channel.selfMarkFailure = true
+ mgr.RegisterChannel("telegram-main", channel)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ if err := mgr.StartAll(ctx); err != nil {
+ t.Fatalf("StartAll returned error: %v", err)
+ }
+
+ status := mgr.GetStatus()["telegram-main"].(ChannelHealth)
+ if status.FailureCount != 1 {
+ t.Fatalf("expected one startup failure to be recorded, got %d", status.FailureCount)
+ }
+ if status.ConsecutiveFailures != 1 {
+ t.Fatalf("expected one consecutive failure, got %d", status.ConsecutiveFailures)
+ }
+}
+
+func TestInstanceLoaderAutoStartPreservesRegisteredFailureState(t *testing.T) {
+ msgBus := bus.New()
+ mgr := NewManager(msgBus)
+ loader := NewInstanceLoader(nil, nil, mgr, msgBus, nil)
+ startErr := errors.New(`telego: getUpdates: api: 401 "Unauthorized"`)
+
+ loader.RegisterFactory(TypeTelegram, func(name string, _, _ json.RawMessage, _ *bus.MessageBus, _ store.PairingStore) (Channel, error) {
+ ch := newLoaderHealthChannel(name, TypeTelegram)
+ ch.startErr = startErr
+ return ch, nil
+ })
+
+ loader.mu.Lock()
+ err := loader.loadInstance(context.Background(), store.ChannelInstanceData{
+ Name: "telegram-auto",
+ ChannelType: TypeTelegram,
+ }, true)
+ loader.mu.Unlock()
+ if err != nil {
+ t.Fatalf("loadInstance returned error: %v", err)
+ }
+
+ status := mgr.GetStatus()["telegram-auto"].(ChannelHealth)
+ if status.State != ChannelHealthStateFailed {
+ t.Fatalf("expected failed state, got %q", status.State)
+ }
+ if status.FailureKind != ChannelFailureKindAuth {
+ t.Fatalf("expected auth failure kind, got %q", status.FailureKind)
+ }
+ if status.Remediation == nil {
+ t.Fatal("expected remediation metadata")
+ }
+}
+
+func TestInstanceLoaderAutoStartDoesNotDoubleCountSelfReportedFailure(t *testing.T) {
+ msgBus := bus.New()
+ mgr := NewManager(msgBus)
+ loader := NewInstanceLoader(nil, nil, mgr, msgBus, nil)
+
+ loader.RegisterFactory(TypeTelegram, func(name string, _, _ json.RawMessage, _ *bus.MessageBus, _ store.PairingStore) (Channel, error) {
+ ch := newLoaderHealthChannel(name, TypeTelegram)
+ ch.startErr = errors.New(`telego: getUpdates: api: 401 "Unauthorized"`)
+ ch.selfMarkFailure = true
+ return ch, nil
+ })
+
+ loader.mu.Lock()
+ err := loader.loadInstance(context.Background(), store.ChannelInstanceData{
+ Name: "telegram-auto-self",
+ ChannelType: TypeTelegram,
+ }, true)
+ loader.mu.Unlock()
+ if err != nil {
+ t.Fatalf("loadInstance returned error: %v", err)
+ }
+
+ status := mgr.GetStatus()["telegram-auto-self"].(ChannelHealth)
+ if status.FailureCount != 1 {
+ t.Fatalf("expected one startup failure to be recorded, got %d", status.FailureCount)
+ }
+ if status.ConsecutiveFailures != 1 {
+ t.Fatalf("expected one consecutive failure, got %d", status.ConsecutiveFailures)
+ }
+}
+
+func TestInstanceLoaderTracksPreRegistrationFailuresForCleanup(t *testing.T) {
+ msgBus := bus.New()
+ mgr := NewManager(msgBus)
+ loader := NewInstanceLoader(nil, nil, mgr, msgBus, nil)
+
+ loader.RegisterFactory(TypeTelegram, func(string, json.RawMessage, json.RawMessage, *bus.MessageBus, store.PairingStore) (Channel, error) {
+ return nil, errors.New("token is required: 123456:ABC")
+ })
+
+ loader.mu.Lock()
+ err := loader.loadInstance(context.Background(), store.ChannelInstanceData{
+ Name: "telegram-failed",
+ ChannelType: TypeTelegram,
+ }, false)
+ loader.mu.Unlock()
+ if err == nil {
+ t.Fatal("expected factory failure")
+ }
+
+ if _, ok := loader.LoadedNames()["telegram-failed"]; !ok {
+ t.Fatal("expected failed instance to remain tracked for cleanup")
+ }
+
+ status := mgr.GetStatus()["telegram-failed"].(ChannelHealth)
+ if status.State != ChannelHealthStateFailed {
+ t.Fatalf("expected failed state, got %q", status.State)
+ }
+ if status.Detail == "" || status.Detail == "token is required: 123456:ABC" {
+ t.Fatalf("expected sanitized detail, got %q", status.Detail)
+ }
+
+ loader.Stop(context.Background())
+
+ if _, ok := loader.LoadedNames()["telegram-failed"]; ok {
+ t.Fatal("expected cleanup to clear loader tracking")
+ }
+ if _, ok := mgr.GetStatus()["telegram-failed"]; ok {
+ t.Fatal("expected cleanup to remove failed status entry")
+ }
+}
+
+func TestInstanceLoaderCleansEarlyExitFailuresOnStop(t *testing.T) {
+ tests := []struct {
+ name string
+ channelType string
+ configure func(*InstanceLoader)
+ wantSummary string
+ wantKind ChannelFailureKind
+ }{
+ {
+ name: "unsupported-channel",
+ channelType: "unsupported",
+ wantSummary: "Unsupported channel type",
+ wantKind: ChannelFailureKindConfig,
+ },
+ {
+ name: "telegram-missing-creds",
+ channelType: TypeTelegram,
+ configure: func(loader *InstanceLoader) {
+ loader.RegisterFactory(TypeTelegram, func(string, json.RawMessage, json.RawMessage, *bus.MessageBus, store.PairingStore) (Channel, error) {
+ return nil, nil
+ })
+ },
+ wantSummary: "Missing credentials",
+ wantKind: ChannelFailureKindConfig,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ msgBus := bus.New()
+ mgr := NewManager(msgBus)
+ loader := NewInstanceLoader(nil, nil, mgr, msgBus, nil)
+ if tc.configure != nil {
+ tc.configure(loader)
+ }
+
+ loader.mu.Lock()
+ err := loader.loadInstance(context.Background(), store.ChannelInstanceData{
+ Name: tc.name,
+ ChannelType: tc.channelType,
+ }, false)
+ loader.mu.Unlock()
+ if err != nil {
+ t.Fatalf("loadInstance returned error: %v", err)
+ }
+
+ if _, ok := loader.LoadedNames()[tc.name]; !ok {
+ t.Fatal("expected failed instance to remain tracked for cleanup")
+ }
+
+ status := mgr.GetStatus()[tc.name].(ChannelHealth)
+ if status.State != ChannelHealthStateFailed {
+ t.Fatalf("expected failed state, got %q", status.State)
+ }
+ if status.Summary != tc.wantSummary {
+ t.Fatalf("expected summary %q, got %q", tc.wantSummary, status.Summary)
+ }
+ if status.FailureKind != tc.wantKind {
+ t.Fatalf("expected failure kind %q, got %q", tc.wantKind, status.FailureKind)
+ }
+
+ loader.Stop(context.Background())
+
+ if _, ok := loader.LoadedNames()[tc.name]; ok {
+ t.Fatal("expected cleanup to clear loader tracking")
+ }
+ if _, ok := mgr.GetStatus()[tc.name]; ok {
+ t.Fatal("expected cleanup to remove failed status entry")
+ }
+ })
+ }
+}
+
+func TestBuildChannelRemediationUsesRealOperatorTargets(t *testing.T) {
+ t.Helper()
+
+ cases := []struct {
+ name string
+ snapshot ChannelHealth
+ wantCode ChannelRemediationCode
+ wantTarget ChannelRemediationTarget
+ }{
+ {
+ name: "telegram auth opens credentials",
+ snapshot: NewFailedChannelHealthForType(
+ TypeTelegram,
+ "",
+ errors.New(`telego: getMe: api: 401 "Unauthorized"`),
+ ),
+ wantCode: ChannelRemediationCodeOpenCredentials,
+ wantTarget: ChannelRemediationTargetCredentials,
+ },
+ {
+ name: "zalo personal auth reauthenticates",
+ snapshot: NewFailedChannelHealthForType(
+ TypeZaloPersonal,
+ "",
+ errors.New(`session expired: unauthorized`),
+ ),
+ wantCode: ChannelRemediationCodeReauth,
+ wantTarget: ChannelRemediationTargetReauth,
+ },
+ {
+ name: "missing credentials opens credentials",
+ snapshot: NewChannelHealthForType(
+ TypeSlack,
+ ChannelHealthStateFailed,
+ "Missing credentials",
+ "Set channels.slack.bot_token in config.",
+ ChannelFailureKindConfig,
+ false,
+ ),
+ wantCode: ChannelRemediationCodeOpenCredentials,
+ wantTarget: ChannelRemediationTargetCredentials,
+ },
+ {
+ name: "invalid proxy opens advanced",
+ snapshot: NewChannelHealthForType(
+ TypeTelegram,
+ ChannelHealthStateFailed,
+ "Configuration is invalid",
+ "invalid proxy URL",
+ ChannelFailureKindConfig,
+ false,
+ ),
+ wantCode: ChannelRemediationCodeOpenAdvanced,
+ wantTarget: ChannelRemediationTargetAdvanced,
+ },
+ {
+ name: "network timeout checks network",
+ snapshot: NewFailedChannelHealthForType(
+ TypeTelegram,
+ "",
+ errors.New(`dial tcp 127.0.0.1:443: i/o timeout`),
+ ),
+ wantCode: ChannelRemediationCodeCheckNetwork,
+ wantTarget: ChannelRemediationTargetDetails,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ tc.snapshot = mergeChannelHealth(ChannelHealth{}, tc.snapshot)
+ if tc.snapshot.Remediation == nil {
+ t.Fatal("expected remediation")
+ }
+ if tc.snapshot.Remediation.Code != tc.wantCode {
+ t.Fatalf("expected remediation code %q, got %q", tc.wantCode, tc.snapshot.Remediation.Code)
+ }
+ if tc.snapshot.Remediation.Target != tc.wantTarget {
+ t.Fatalf("expected remediation target %q, got %q", tc.wantTarget, tc.snapshot.Remediation.Target)
+ }
+ })
+ }
+}
+
+func TestMergeChannelHealthTracksFailureTimelineAndRecovery(t *testing.T) {
+ firstFailedAt := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC)
+ secondFailedAt := firstFailedAt.Add(2 * time.Minute)
+ recoveredAt := secondFailedAt.Add(3 * time.Minute)
+ thirdFailedAt := recoveredAt.Add(4 * time.Minute)
+
+ first := NewFailedChannelHealthForType(TypeTelegram, "", errors.New(`telego: getUpdates: api: 401 "Unauthorized"`))
+ first.CheckedAt = firstFailedAt
+ first = mergeChannelHealth(ChannelHealth{}, first)
+
+ second := NewFailedChannelHealthForType(TypeTelegram, "", errors.New(`telego: getUpdates: api: 401 "Unauthorized"`))
+ second.CheckedAt = secondFailedAt
+ second = mergeChannelHealth(first, second)
+
+ if second.FailureCount != 2 {
+ t.Fatalf("expected cumulative failure count 2, got %d", second.FailureCount)
+ }
+ if second.ConsecutiveFailures != 2 {
+ t.Fatalf("expected consecutive failures 2, got %d", second.ConsecutiveFailures)
+ }
+ if !second.FirstFailedAt.Equal(firstFailedAt) {
+ t.Fatalf("expected first failed at %v, got %v", firstFailedAt, second.FirstFailedAt)
+ }
+ if !second.LastFailedAt.Equal(secondFailedAt) {
+ t.Fatalf("expected last failed at %v, got %v", secondFailedAt, second.LastFailedAt)
+ }
+
+ recovered := NewChannelHealthForType(TypeTelegram, ChannelHealthStateHealthy, "Connected", "", ChannelFailureKindUnknown, false)
+ recovered.CheckedAt = recoveredAt
+ recovered = mergeChannelHealth(second, recovered)
+
+ if recovered.ConsecutiveFailures != 0 {
+ t.Fatalf("expected consecutive failures reset, got %d", recovered.ConsecutiveFailures)
+ }
+ if recovered.Remediation != nil {
+ t.Fatalf("expected remediation to clear after recovery, got %+v", recovered.Remediation)
+ }
+ if !recovered.LastHealthyAt.Equal(recoveredAt) {
+ t.Fatalf("expected last healthy at %v, got %v", recoveredAt, recovered.LastHealthyAt)
+ }
+ if recovered.FailureCount != 2 {
+ t.Fatalf("expected cumulative failure count to be preserved, got %d", recovered.FailureCount)
+ }
+
+ third := NewFailedChannelHealthForType(TypeTelegram, "", errors.New(`telego: getUpdates: api: 401 "Unauthorized"`))
+ third.CheckedAt = thirdFailedAt
+ third = mergeChannelHealth(recovered, third)
+
+ if third.ConsecutiveFailures != 1 {
+ t.Fatalf("expected new failure streak to start at 1, got %d", third.ConsecutiveFailures)
+ }
+ if !third.FirstFailedAt.Equal(thirdFailedAt) {
+ t.Fatalf("expected first failed timestamp to reset to latest incident start, got %v", third.FirstFailedAt)
+ }
+ if third.FailureCount != 3 {
+ t.Fatalf("expected cumulative failure count 3, got %d", third.FailureCount)
+ }
+}
diff --git a/internal/channels/instance_loader.go b/internal/channels/instance_loader.go
index e45579a3..025d7c68 100644
--- a/internal/channels/instance_loader.go
+++ b/internal/channels/instance_loader.go
@@ -204,8 +204,18 @@ func (l *InstanceLoader) LoadedNames() map[string]struct{} {
// If autoStart is true, the channel is started immediately (used by Reload).
// If false, the caller is responsible for starting (used by LoadAll, where StartAll handles it).
func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelInstanceData, autoStart bool) error {
+ l.loaded[inst.Name] = struct{}{}
+
factory, ok := l.factories[inst.ChannelType]
if !ok {
+ l.manager.RecordHealth(inst.Name, NewChannelHealthForType(
+ inst.ChannelType,
+ ChannelHealthStateFailed,
+ "Unsupported channel type",
+ fmt.Sprintf("No channel factory is registered for %q", inst.ChannelType),
+ ChannelFailureKindConfig,
+ false,
+ ))
slog.Warn("no factory for channel type", "type", inst.ChannelType, "name", inst.Name)
return nil
}
@@ -216,9 +226,18 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns
ch, err := factory(inst.Name, inst.Credentials, cfg, l.msgBus, l.pairingSvc)
if err != nil {
+ l.manager.RecordFailureForType(inst.Name, inst.ChannelType, "", err)
return err
}
if ch == nil {
+ l.manager.RecordHealth(inst.Name, NewChannelHealthForType(
+ inst.ChannelType,
+ ChannelHealthStateFailed,
+ "Missing credentials",
+ "Channel instance is enabled but required credentials are incomplete.",
+ ChannelFailureKindConfig,
+ false,
+ ))
slog.Info("channel instance not ready (missing credentials)", "name", inst.Name, "type", inst.ChannelType)
return nil
}
@@ -231,6 +250,7 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns
var err error
ag, err = l.agentStore.GetByID(instCtx, inst.AgentID)
if err != nil {
+ l.manager.RecordFailureForType(inst.Name, inst.ChannelType, "", fmt.Errorf("agent %s not found for channel %s: %w", inst.AgentID, inst.Name, err))
return fmt.Errorf("agent %s not found for channel %s: %w", inst.AgentID, inst.Name, err)
}
base.SetAgentID(ag.AgentKey)
@@ -303,13 +323,15 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns
}
}
l.manager.RegisterChannel(inst.Name, ch)
- l.loaded[inst.Name] = struct{}{}
// Start the channel if requested (Reload path). LoadAll defers to StartAll.
if autoStart {
if err := ch.Start(ctx); err != nil {
+ l.manager.recordChannelStartFailure(inst.Name, ch, "", err)
slog.Error("channel instance start failed", "name", inst.Name, "error", err)
// Still registered — will show as not running.
+ } else {
+ l.manager.RecordHealth(inst.Name, snapshotChannelHealth(ch))
}
}
diff --git a/internal/channels/manager.go b/internal/channels/manager.go
index dfc47b91..1c4c4133 100644
--- a/internal/channels/manager.go
+++ b/internal/channels/manager.go
@@ -49,6 +49,7 @@ type RunContext struct {
// and routing outbound messages to the correct channel.
type Manager struct {
channels map[string]Channel
+ health map[string]ChannelHealth
bus *bus.MessageBus
runs sync.Map // runID string → *RunContext
dispatchTask *asyncTask
@@ -65,6 +66,7 @@ type asyncTask struct {
func NewManager(msgBus *bus.MessageBus) *Manager {
return &Manager{
channels: make(map[string]Channel),
+ health: make(map[string]ChannelHealth),
bus: msgBus,
}
}
@@ -90,9 +92,16 @@ func (m *Manager) StartAll(ctx context.Context) error {
for name, channel := range m.channels {
slog.Info("starting channel", "channel", name)
- if err := channel.Start(ctx); err != nil {
- slog.Error("failed to start channel", "channel", name, "error", err)
+ if hc, ok := channel.(interface{ MarkStarting(string) }); ok {
+ hc.MarkStarting("Starting")
}
+ m.syncChannelHealthLocked(name, channel)
+ if err := channel.Start(ctx); err != nil {
+ m.recordChannelStartFailureLocked(name, channel, "", err)
+ slog.Error("failed to start channel", "channel", name, "error", err)
+ continue
+ }
+ m.syncChannelHealthLocked(name, channel)
}
slog.Info("all channels started")
@@ -114,8 +123,14 @@ func (m *Manager) StopAll(ctx context.Context) error {
for name, channel := range m.channels {
slog.Info("stopping channel", "channel", name)
if err := channel.Stop(ctx); err != nil {
+ m.recordHealthLocked(name, NewFailedChannelHealth("Failed to stop channel", err))
slog.Error("error stopping channel", "channel", name, "error", err)
+ continue
}
+ if hc, ok := channel.(interface{ MarkStopped(string) }); ok {
+ hc.MarkStopped("Stopped")
+ }
+ m.syncChannelHealthLocked(name, channel)
}
slog.Info("all channels stopped")
@@ -135,12 +150,12 @@ func (m *Manager) GetStatus() map[string]any {
m.mu.RLock()
defer m.mu.RUnlock()
- status := make(map[string]any)
+ status := make(map[string]any, len(m.health)+len(m.channels))
+ for name, snapshot := range m.health {
+ status[name] = snapshot
+ }
for name, channel := range m.channels {
- status[name] = map[string]any{
- "enabled": true,
- "running": channel.IsRunning(),
- }
+ status[name] = snapshotChannelHealth(channel)
}
return status
}
@@ -168,6 +183,75 @@ func (m *Manager) RegisterChannel(name string, channel Channel) {
}
}
m.channels[name] = channel
+ if hc, ok := channel.(interface{ MarkRegistered(string) }); ok {
+ hc.MarkRegistered("Configured")
+ }
+ m.syncChannelHealthLocked(name, channel)
+}
+
+// RecordHealth stores runtime health for an instance, including failures before registration.
+func (m *Manager) RecordHealth(name string, snapshot ChannelHealth) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.recordHealthLocked(name, snapshot)
+}
+
+// RecordFailure stores a classified failure snapshot for an instance.
+func (m *Manager) RecordFailure(name, summary string, err error) {
+ m.RecordHealth(name, NewFailedChannelHealth(summary, err))
+}
+
+// RecordFailureForType stores a classified failure snapshot for an instance before registration exists.
+func (m *Manager) RecordFailureForType(name, channelType, summary string, err error) {
+ m.RecordHealth(name, NewFailedChannelHealthForType(channelType, summary, err))
+}
+
+func (m *Manager) recordChannelStartFailure(name string, channel Channel, summary string, err error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.recordChannelStartFailureLocked(name, channel, summary, err)
+}
+
+func (m *Manager) recordChannelStartFailureLocked(name string, channel Channel, summary string, err error) {
+ info := ClassifyChannelError(err)
+ if summary == "" {
+ summary = info.Summary
+ }
+
+ current := snapshotChannelHealth(channel)
+ if isFailureState(current.State) {
+ if current.ChannelType == "" {
+ current.ChannelType = channel.Type()
+ }
+ if current.Summary == "" {
+ current.Summary = summary
+ }
+ if current.Detail == "" {
+ current.Detail = info.Detail
+ }
+ if current.FailureKind == "" {
+ current.FailureKind = info.Kind
+ }
+ m.recordHealthLocked(name, current)
+ return
+ }
+
+ if hc, ok := channel.(interface {
+ MarkFailed(string, string, ChannelFailureKind, bool)
+ }); ok {
+ hc.MarkFailed(summary, info.Detail, info.Kind, info.Retryable)
+ m.syncChannelHealthLocked(name, channel)
+ return
+ }
+
+ m.recordHealthLocked(name, NewChannelHealthForType(
+ channel.Type(),
+ ChannelHealthStateFailed,
+ summary,
+ info.Detail,
+ info.Kind,
+ info.Retryable,
+ ))
}
// SetContactCollector sets the contact collector for all current and future channels.
@@ -229,4 +313,48 @@ func (m *Manager) UnregisterChannel(name string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.channels, name)
+ delete(m.health, name)
+}
+
+func (m *Manager) recordHealthLocked(name string, snapshot ChannelHealth) {
+ prev := m.health[name]
+ if snapshot.ChannelType == "" {
+ switch {
+ case prev.ChannelType != "":
+ snapshot.ChannelType = prev.ChannelType
+ case m.channels[name] != nil:
+ snapshot.ChannelType = m.channels[name].Type()
+ default:
+ snapshot.ChannelType = name
+ }
+ }
+ m.health[name] = mergeChannelHealth(prev, snapshot)
+}
+
+func (m *Manager) syncChannelHealthLocked(name string, channel Channel) {
+ m.recordHealthLocked(name, snapshotChannelHealth(channel))
+}
+
+func snapshotChannelHealth(channel Channel) ChannelHealth {
+ if reporter, ok := channel.(interface{ HealthSnapshot() ChannelHealth }); ok {
+ snapshot := reporter.HealthSnapshot()
+ snapshot.ChannelType = channel.Type()
+ snapshot.Enabled = true
+ snapshot.Running = channel.IsRunning()
+ return snapshot
+ }
+
+ state := ChannelHealthStateStopped
+ summary := "Stopped"
+ if channel.IsRunning() {
+ state = ChannelHealthStateHealthy
+ summary = "Connected"
+ }
+ return ChannelHealth{
+ ChannelType: channel.Type(),
+ Enabled: true,
+ Running: channel.IsRunning(),
+ State: state,
+ Summary: summary,
+ }
}
diff --git a/internal/channels/telegram/channel.go b/internal/channels/telegram/channel.go
index 0296535e..24cd98f9 100644
--- a/internal/channels/telegram/channel.go
+++ b/internal/channels/telegram/channel.go
@@ -23,32 +23,32 @@ import (
// Channel connects to Telegram via the Bot API using long polling.
type Channel struct {
*channels.BaseChannel
- bot *telego.Bot
- config config.TelegramConfig
- httpClient *http.Client
- transport *http.Transport
- ipv4Once sync.Once // guards enableIPv4Only to prevent data race
- pairingService store.PairingStore
- agentStore store.AgentStore // for agent key lookup (nil if not configured)
- configPermStore store.ConfigPermissionStore // for group file writer management (nil if not configured)
- teamStore store.TeamStore // for /tasks, /task_detail commands (nil if not configured)
- subagentTaskStore store.SubagentTaskStore // for /subagents, /subagent commands (nil if not configured)
- placeholders sync.Map // localKey string → messageID int
- stopThinking sync.Map // localKey string → *thinkingCancel
- typingCtrls sync.Map // localKey string → *typing.Controller
- reactions sync.Map // localKey string → *StatusReactionController
- pairingReplySent sync.Map // userID string → time.Time (debounce pairing replies)
- threadIDs sync.Map // localKey string → messageThreadID int (for forum topic routing)
- approvedGroups sync.Map // chatIDStr string → true (cached group pairing approval)
- groupHistory *channels.PendingHistory
- historyLimit int
- requireMention bool
- mentionMode string // "strict" (default) or "yield"
- pollCancel context.CancelFunc // cancels the long polling context
- pollDone chan struct{} // closed when polling goroutine exits
- handlerWg sync.WaitGroup // tracks in-flight handler goroutines for graceful shutdown
- handlerSem chan struct{} // bounded semaphore for concurrent handler goroutines
- pendingDraftID sync.Map // localKey string → int (draftID)
+ bot *telego.Bot
+ config config.TelegramConfig
+ httpClient *http.Client
+ transport *http.Transport
+ ipv4Once sync.Once // guards enableIPv4Only to prevent data race
+ pairingService store.PairingStore
+ agentStore store.AgentStore // for agent key lookup (nil if not configured)
+ configPermStore store.ConfigPermissionStore // for group file writer management (nil if not configured)
+ teamStore store.TeamStore // for /tasks, /task_detail commands (nil if not configured)
+ subagentTaskStore store.SubagentTaskStore // for /subagents, /subagent commands (nil if not configured)
+ placeholders sync.Map // localKey string → messageID int
+ stopThinking sync.Map // localKey string → *thinkingCancel
+ typingCtrls sync.Map // localKey string → *typing.Controller
+ reactions sync.Map // localKey string → *StatusReactionController
+ pairingReplySent sync.Map // userID string → time.Time (debounce pairing replies)
+ threadIDs sync.Map // localKey string → messageThreadID int (for forum topic routing)
+ approvedGroups sync.Map // chatIDStr string → true (cached group pairing approval)
+ groupHistory *channels.PendingHistory
+ historyLimit int
+ requireMention bool
+ mentionMode string // "strict" (default) or "yield"
+ pollCancel context.CancelFunc // cancels the long polling context
+ pollDone chan struct{} // closed when polling goroutine exits
+ handlerWg sync.WaitGroup // tracks in-flight handler goroutines for graceful shutdown
+ handlerSem chan struct{} // bounded semaphore for concurrent handler goroutines
+ pendingDraftID sync.Map // localKey string → int (draftID)
}
type thinkingCancel struct {
@@ -170,6 +170,18 @@ func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.Pai
// Start begins long polling for Telegram updates.
func (c *Channel) Start(ctx context.Context) error {
slog.Info("starting telegram bot (polling mode)")
+ c.MarkStarting("Validating Telegram bot")
+
+ probeCtx, probeCancel := context.WithTimeout(ctx, probeOverallTimeout)
+ me, err := c.bot.GetMe(probeCtx)
+ probeCancel()
+ if err != nil {
+ return fmt.Errorf("validate telegram bot: %w", err)
+ }
+ username := ""
+ if me != nil {
+ username = me.Username
+ }
// Create a cancellable context for the polling goroutine.
// Stop() cancels this context to cleanly shut down long polling.
@@ -192,18 +204,21 @@ func (c *Channel) Start(ctx context.Context) error {
}
c.SetRunning(true)
+ c.MarkHealthy(connectedSummary(username))
c.groupHistory.StartFlusher()
c.handlerSem = make(chan struct{}, 20) // limit concurrent message handlers
- slog.Info("telegram bot connected", "username", c.bot.Username())
+ slog.Info("telegram bot connected", "username", username)
// Register bot menu commands with retry.
go func() {
commands := DefaultMenuCommands()
syncCtx, cancel := context.WithTimeout(pollCtx, probeOverallTimeout)
defer cancel()
+ var lastErr error
for attempt := 1; attempt <= 3; attempt++ {
if err := c.SyncMenuCommands(syncCtx, commands); err != nil {
+ lastErr = err
slog.Warn("failed to sync telegram menu commands", "error", err, "attempt", attempt)
if attempt < 3 {
select {
@@ -217,6 +232,9 @@ func (c *Channel) Start(ctx context.Context) error {
return
}
}
+ if lastErr != nil {
+ slog.Warn("telegram menu commands remain unsynced", "error", lastErr)
+ }
}()
go func() {
@@ -227,6 +245,9 @@ func (c *Channel) Start(ctx context.Context) error {
return
case update, ok := <-updates:
if !ok {
+ if pollCtx.Err() == nil {
+ c.MarkFailed("Polling stopped unexpectedly", "Telegram updates channel closed unexpectedly.", channels.ChannelFailureKindNetwork, true)
+ }
slog.Info("telegram updates channel closed")
return
}
@@ -324,6 +345,7 @@ func (c *Channel) SetPendingHistoryTenantID(id uuid.UUID) { c.groupHistory.SetTe
func (c *Channel) Stop(_ context.Context) error {
slog.Info("stopping telegram bot")
c.SetRunning(false)
+ c.MarkStopped("Stopped")
c.groupHistory.StopFlusher()
if c.pollCancel != nil {
@@ -356,6 +378,13 @@ func (c *Channel) Stop(_ context.Context) error {
return nil
}
+func connectedSummary(username string) string {
+ if username == "" {
+ return "Connected"
+ }
+ return fmt.Sprintf("Connected as @%s", username)
+}
+
// applyIPv4Dialer forces a transport to use IPv4 only by overriding DialContext.
func applyIPv4Dialer(t *http.Transport) {
dialer := &net.Dialer{
diff --git a/ui/desktop/frontend/src/components/settings/channels/ChannelCard.tsx b/ui/desktop/frontend/src/components/settings/channels/ChannelCard.tsx
index d3b69cd4..8b6f8177 100644
--- a/ui/desktop/frontend/src/components/settings/channels/ChannelCard.tsx
+++ b/ui/desktop/frontend/src/components/settings/channels/ChannelCard.tsx
@@ -1,13 +1,16 @@
-import { useTranslation } from 'react-i18next'
-import { Switch } from '../../common/Switch'
-import type { ChannelInstanceData, ChannelStatus } from '../../../types/channel'
+import { useTranslation } from "react-i18next";
+import { Switch } from "../../common/Switch";
+import type {
+ ChannelInstanceData,
+ ChannelStatus,
+} from "../../../types/channel";
interface ChannelCardProps {
- instance: ChannelInstanceData
- status: ChannelStatus | null
- agentName: string
- onToggleEnabled: (enabled: boolean) => void
- onClick: () => void
+ instance: ChannelInstanceData;
+ status: ChannelStatus | null;
+ agentName: string;
+ onToggleEnabled: (enabled: boolean) => void;
+ onClick: () => void;
}
function TelegramIcon() {
@@ -15,7 +18,7 @@ function TelegramIcon() {
- )
+ );
}
function DiscordIcon() {
@@ -23,23 +26,55 @@ function DiscordIcon() {
- )
+ );
}
-export function ChannelCard({ instance, status, agentName, onToggleEnabled, onClick }: ChannelCardProps) {
- const { t } = useTranslation('channels')
- const isTelegram = instance.channel_type === 'telegram'
+export function ChannelCard({
+ instance,
+ status,
+ agentName,
+ onToggleEnabled,
+ onClick,
+}: ChannelCardProps) {
+ const { t } = useTranslation("channels");
+ const isTelegram = instance.channel_type === "telegram";
// Status dot color
- let dotColor = 'bg-gray-400'
- let statusText = t('status.disabled')
- if (instance.enabled && status?.running) {
- dotColor = 'bg-emerald-500'
- statusText = t('status.running')
- } else if (instance.enabled) {
- dotColor = 'bg-amber-500'
- statusText = t('status.stopped')
+ let dotColor = "bg-gray-400";
+ let statusText = t("status.disabled");
+ if (instance.enabled) {
+ switch (status?.state) {
+ case "healthy":
+ dotColor = "bg-emerald-500";
+ statusText = t("status.running");
+ break;
+ case "degraded":
+ dotColor = "bg-amber-500";
+ statusText = t("status.degraded", { defaultValue: "Degraded" });
+ break;
+ case "starting":
+ dotColor = "bg-sky-500";
+ statusText = t("status.starting", { defaultValue: "Starting" });
+ break;
+ case "registered":
+ dotColor = "bg-slate-400";
+ statusText = t("status.registered", { defaultValue: "Configured" });
+ break;
+ case "failed":
+ dotColor = "bg-red-500";
+ statusText = t("status.failed", { defaultValue: "Failed" });
+ break;
+ default:
+ dotColor = status?.running ? "bg-emerald-500" : "bg-amber-500";
+ statusText = status?.running
+ ? t("status.running")
+ : t("status.stopped");
+ }
}
+ const summaryHint =
+ instance.enabled && status?.summary && status.state !== "healthy"
+ ? status.summary
+ : null;
return (
{statusText}·
- {agentName}
+
+ {agentName}
+
+ {summaryHint && (
+