From af041b9acfeb464cc84a10ca85706cecd9613f8e Mon Sep 17 00:00:00 2001 From: HotPlexBot Date: Sun, 22 Mar 2026 14:31:07 +0800 Subject: [PATCH 1/2] fix(chatapps): handle Slack App Home not_enabled gracefully (Refs #337) When PublishViewContext returns "not_enabled" (App Home feature not enabled in Slack App config), log at INFO level and return nil instead of ERROR. Applies to both HandleHomeOpened and HandleHomeRefresh. fix(chatapps): downgrade AppendStream ERROR to WARN for recoverable stream state (Refs #336) message_not_in_streaming_state is a recoverable condition handled by the streaming writer's fallback mechanism. Downgrade from ERROR to WARN to avoid alarming operators while preserving diagnostics. fix(cmd): replace admin port check with HTTP health check in doctor (Refs #335) Replace netcat-based port availability check with actual HTTP request to /admin/v1/health. This properly detects whether the Admin API is responding, not just whether the port is open. Co-Authored-By: Claude Sonnet 4.6 --- chatapps/slack/apphome/handler.go | 31 +++++++++++++++++++++- chatapps/slack/messages.go | 14 +++++++++- cmd/hotplexd/cmd/doctor.go | 43 ++++++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/chatapps/slack/apphome/handler.go b/chatapps/slack/apphome/handler.go index f2101f01..1c58fbd5 100644 --- a/chatapps/slack/apphome/handler.go +++ b/chatapps/slack/apphome/handler.go @@ -95,6 +95,13 @@ func (h *Handler) HandleHomeOpened(ctx context.Context, event *HomeOpenedEvent) }, ) if err != nil { + // not_enabled means the App Home feature is not enabled in the Slack App config. + // This is a configuration issue, not a system failure — degrade gracefully. + if isAppHomeNotEnabled(err) { + h.logger.Info("App Home feature not enabled, skipping Home Tab update", + "user", event.User) + return nil + } h.logger.Error("Failed to publish Home Tab view", "user", event.User, "error", err) @@ -282,5 +289,27 @@ func (h *Handler) HandleHomeRefresh(ctx context.Context, userID string) error { View: *view, }, ) - return err + if err != nil { + // not_enabled means the App Home feature is not enabled in the Slack App config. + if isAppHomeNotEnabled(err) { + h.logger.Info("App Home feature not enabled, skipping Home Tab refresh", + "user", userID) + return nil + } + h.logger.Error("Failed to refresh Home Tab view", + "user", userID, + "error", err) + return fmt.Errorf("publish view: %w", err) + } + return nil +} + +// isAppHomeNotEnabled checks if the error indicates App Home is not enabled +// in the Slack App configuration. +func isAppHomeNotEnabled(err error) bool { + if err == nil { + return false + } + errStr := strings.ToLower(err.Error()) + return strings.Contains(errStr, "not_enabled") } diff --git a/chatapps/slack/messages.go b/chatapps/slack/messages.go index b09fa274..3e0fc2ce 100644 --- a/chatapps/slack/messages.go +++ b/chatapps/slack/messages.go @@ -476,7 +476,19 @@ func (a *Adapter) AppendStream(ctx context.Context, channelID, messageTS, conten _, _, err := a.client.AppendStreamContext(ctx, channelID, messageTS, options...) if err != nil { - a.Logger().Error("AppendStream failed", "channel_id", channelID, "message_ts", messageTS, "error", err) + // message_not_in_streaming_state is a recoverable condition handled by the + // streaming writer's fallback mechanism — log at WARN to avoid alarming operators. + if isStreamStateError(err) { + a.Logger().Warn("AppendStream failed (stream expired)", + "channel_id", channelID, + "message_ts", messageTS, + "error", err) + } else { + a.Logger().Error("AppendStream failed", + "channel_id", channelID, + "message_ts", messageTS, + "error", err) + } return fmt.Errorf("append stream: %w", err) } diff --git a/cmd/hotplexd/cmd/doctor.go b/cmd/hotplexd/cmd/doctor.go index 31ff4f78..2fe73d6a 100644 --- a/cmd/hotplexd/cmd/doctor.go +++ b/cmd/hotplexd/cmd/doctor.go @@ -3,6 +3,8 @@ package cmd import ( "context" "fmt" + "io" + "net/http" "os" "os/exec" "path/filepath" @@ -36,7 +38,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { {"Configuration Files", checkConfigFiles}, {"Environment Variables", checkEnvVars}, {"Port Availability (8080)", checkPortAvailable("8080")}, - {"Port Availability (9080)", checkPortAvailable("9080")}, + {"Admin API Health (9080)", checkAdminAPIHealth}, {"Database (SQLite)", checkDatabase}, } @@ -147,3 +149,42 @@ func checkDatabase() (bool, string) { return true, "database accessible" } + +// checkAdminAPIHealth verifies the Admin API server is responding. +// Unlike checkPortAvailable which only tests port reachability, this makes +// an actual HTTP request to confirm the Admin API is operational. +func checkAdminAPIHealth() (bool, string) { + adminPort := os.Getenv("HOTPLEX_ADMIN_PORT") + if adminPort == "" { + adminPort = "9080" + } + + url := "http://localhost:" + adminPort + "/admin/v1/health" + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false, "failed to create request" + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + // Provide actionable diagnostics based on error type + if strings.Contains(err.Error(), "connection refused") { + return false, "connection refused — is the daemon running? (hotplexd start)" + } + if strings.Contains(err.Error(), "no such host") || strings.Contains(err.Error(), "timeout") { + return false, "timeout — daemon may still be starting, try again shortly" + } + return false, "request failed: " + err.Error() + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 200)) + return false, fmt.Sprintf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + return true, "Admin API is healthy" +} From 6d6d6f496702b07b4dde7f4ced251cc9f828ec50 Mon Sep 17 00:00:00 2001 From: HotPlexBot Date: Sun, 22 Mar 2026 14:41:28 +0800 Subject: [PATCH 2/2] fix(cmd): handle resp.Body.Close error in doctor health check Use defer func() { _ = resp.Body.Close() }() to explicitly ignore the close error, satisfying the errcheck linter. Refs #335 Co-Authored-By: Claude Sonnet 4.6 --- cmd/hotplexd/cmd/doctor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/hotplexd/cmd/doctor.go b/cmd/hotplexd/cmd/doctor.go index 2fe73d6a..9ef6b49e 100644 --- a/cmd/hotplexd/cmd/doctor.go +++ b/cmd/hotplexd/cmd/doctor.go @@ -179,7 +179,7 @@ func checkAdminAPIHealth() (bool, string) { } return false, "request failed: " + err.Error() } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 200))