diff --git a/internal/adapters/telegram/handler.go b/internal/adapters/telegram/handler.go index 87d80303..edf75af1 100644 --- a/internal/adapters/telegram/handler.go +++ b/internal/adapters/telegram/handler.go @@ -21,6 +21,8 @@ import ( "github.com/qf-studio/pilot/internal/transcription" ) +const startupAPITimeout = 10 * time.Second + // MemberResolver resolves a Telegram user to a team member ID for RBAC (GH-634). // Decoupled from teams package to avoid import cycles. type MemberResolver interface { @@ -77,24 +79,24 @@ type Handler struct { store *memory.Store // Memory store for history/queue/budget (optional) cmdHandler *CommandHandler // Command handler for /commands plainTextMode bool // Use plain text instead of Markdown - botUsername string // Bot username for mention stripping (GH-2129) + botUsername string // Bot username for mention stripping (GH-2129) commsHandler *comms.Handler // Shared message handler (GH-2143) } // HandlerConfig holds configuration for the Telegram handler type HandlerConfig struct { BotToken string - ProjectPath string // Default/fallback project path - Projects comms.ProjectSource // Project source for multi-project support - AllowedIDs []int64 // User/chat IDs allowed to send tasks - Transcription *transcription.Config // Voice transcription config (optional) - Store *memory.Store // Memory store for history/queue/budget (optional) - PlainTextMode bool // Use plain text instead of Markdown (default: true) + ProjectPath string // Default/fallback project path + Projects comms.ProjectSource // Project source for multi-project support + AllowedIDs []int64 // User/chat IDs allowed to send tasks + Transcription *transcription.Config // Voice transcription config (optional) + Store *memory.Store // Memory store for history/queue/budget (optional) + PlainTextMode bool // Use plain text instead of Markdown (default: true) RateLimit *comms.RateLimitConfig // Rate limiting config (optional) - LLMClassifier *LLMClassifierConfig // LLM intent classification config (optional) - MemberResolver MemberResolver // Team member resolver for RBAC (optional, GH-634) - CommsHandler *comms.Handler // Shared message handler (optional, GH-2143) - Client *Client // Optional reuse of existing client + LLMClassifier *LLMClassifierConfig // LLM intent classification config (optional) + MemberResolver MemberResolver // Team member resolver for RBAC (optional, GH-634) + CommsHandler *comms.Handler // Shared message handler (optional, GH-2143) + Client *Client // Optional reuse of existing client } // NewHandler creates a new Telegram message handler @@ -119,15 +121,15 @@ func NewHandler(config *HandlerConfig, runner *executor.Runner) *Handler { } h := &Handler{ - client: client, - runner: runner, - projects: config.Projects, - projectPath: projectPath, - allowedIDs: allowedIDs, - stopCh: make(chan struct{}), - store: config.Store, + client: client, + runner: runner, + projects: config.Projects, + projectPath: projectPath, + allowedIDs: allowedIDs, + stopCh: make(chan struct{}), + store: config.Store, plainTextMode: config.PlainTextMode, - commsHandler: config.CommsHandler, + commsHandler: config.CommsHandler, } // Initialize command handler @@ -198,13 +200,17 @@ func (h *Handler) getParseMode() string { // CheckSingleton verifies no other bot instance is already running. // Returns ErrConflict if another instance is detected. func (h *Handler) CheckSingleton(ctx context.Context) error { - return h.client.CheckSingleton(ctx) + startupCtx, cancel := context.WithTimeout(ctx, startupAPITimeout) + defer cancel() + return h.client.CheckSingleton(startupCtx) } // StartPolling starts polling for updates in a goroutine func (h *Handler) StartPolling(ctx context.Context) { // Fetch bot username for mention stripping (GH-2129) - if me, err := h.client.GetMe(ctx); err != nil { + startupCtx, cancel := context.WithTimeout(ctx, startupAPITimeout) + defer cancel() + if me, err := h.client.GetMe(startupCtx); err != nil { logging.WithComponent("telegram").Warn("Failed to fetch bot username via getMe", slog.String("error", err.Error())) } else if me != nil { h.botUsername = me.Username diff --git a/internal/adapters/telegram/handler_test.go b/internal/adapters/telegram/handler_test.go index f3d2ed8f..b565c6d2 100644 --- a/internal/adapters/telegram/handler_test.go +++ b/internal/adapters/telegram/handler_test.go @@ -34,7 +34,7 @@ func (n *noopMessenger) SendChunked(context.Context, string, string, string, str return nil } func (n *noopMessenger) AcknowledgeCallback(context.Context, string) error { return nil } -func (n *noopMessenger) MaxMessageLength() int { return 4096 } +func (n *noopMessenger) MaxMessageLength() int { return 4096 } // newTestCommsHandler creates a comms.Handler with a no-op messenger for tests. func newTestCommsHandler() *comms.Handler { @@ -795,6 +795,43 @@ func TestHandlerCheckSingleton(t *testing.T) { _ = h.CheckSingleton(ctx) } +func TestHandlerStartPollingUsesStartupTimeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "getMe") { + time.Sleep(startupAPITimeout + time.Second) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "result": map[string]any{"id": 1, "username": "pilot_bot", "first_name": "Pilot"}, + }) + })) + defer server.Close() + + h := &Handler{ + client: NewClientWithBaseURL(testutil.FakeTelegramBotToken, server.URL), + stopCh: make(chan struct{}), + commsHandler: newTestCommsHandler(), + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + start := time.Now() + h.StartPolling(ctx) + defer h.Stop() + + if elapsed := time.Since(start); elapsed > startupAPITimeout+2*time.Second { + t.Fatalf("StartPolling blocked too long: %v", elapsed) + } + if h.botUsername != "" { + t.Fatalf("botUsername = %q, want empty on startup timeout", h.botUsername) + } + if elapsed := time.Since(start); elapsed < startupAPITimeout { + t.Fatalf("StartPolling returned before startup timeout elapsed: %v", elapsed) + } +} + // TestFastListTasksEmpty tests fast list when no tasks directory func TestFastListTasksEmpty(t *testing.T) { tmpDir := t.TempDir()