Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 27 additions & 21 deletions internal/adapters/telegram/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
39 changes: 38 additions & 1 deletion internal/adapters/telegram/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down