diff --git a/config/config.example.json b/config/config.example.json index c214f26fa..2a91e42ed 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -97,7 +97,15 @@ "app_id": "YOUR_QQ_APP_ID", "app_secret": "YOUR_QQ_APP_SECRET", "allow_from": [], - "reasoning_channel_id": "" + "reasoning_channel_id": "", + "_comment_stability": "Stability parameters (all optional with sensible defaults)", + "reconnect_initial_ms": 5000, + "reconnect_max_ms": 300000, + "max_retries": 3, + "retry_initial_delay_ms": 500, + "retry_max_delay_ms": 10000, + "rate_limit_group_ms": 500, + "rate_limit_direct_ms": 200 }, "maixcam": { "enabled": false, diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index a47128a63..9c0274c8f 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -42,15 +42,19 @@ const ( typingSeconds = 10 bytesPerMiB = 1024 * 1024 - // Reconnection constants - reconnectInitial = 5 * time.Second - reconnectMax = 5 * time.Minute - reconnectMultiplier = 2.0 + // Reconnection constants (used as defaults when config values are 0) + reconnectInitialDefault = 5 * time.Second + reconnectMaxDefault = 5 * time.Minute + reconnectMultiplier = 2.0 - // Retry constants - maxRetries = 3 - retryInitialDelay = 500 * time.Millisecond - retryMaxDelay = 10 * time.Second + // Retry constants (used as defaults when config values are 0) + maxRetriesDefault = 3 + retryInitialDelayDefault = 500 * time.Millisecond + retryMaxDelayDefault = 10 * time.Second + + // Rate limit defaults + rateLimitGroupDefault = 500 * time.Millisecond + rateLimitDirectDefault = 200 * time.Millisecond ) type qqAPI interface { @@ -108,13 +112,23 @@ func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) + // Use config values with defaults for rate limiters + groupRateMs := cfg.RateLimitGroupMs + if groupRateMs <= 0 { + groupRateMs = int(rateLimitGroupDefault / time.Millisecond) + } + directRateMs := cfg.RateLimitDirectMs + if directRateMs <= 0 { + directRateMs = int(rateLimitDirectDefault / time.Millisecond) + } + return &QQChannel{ - BaseChannel: base, - config: cfg, - dedup: make(map[string]time.Time), - done: make(chan struct{}), - groupRateLimiter: newRateLimiter(500 * time.Millisecond), // 20 msg/min with headroom - directRateLimiter: newRateLimiter(200 * time.Millisecond), // 5 msg/sec with headroom + BaseChannel: base, + config: cfg, + dedup: make(map[string]time.Time), + done: make(chan struct{}), + groupRateLimiter: newRateLimiter(time.Duration(groupRateMs) * time.Millisecond), + directRateLimiter: newRateLimiter(time.Duration(directRateMs) * time.Millisecond), }, nil } @@ -250,10 +264,11 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { } // Route to group or C2C with retry. + maxRetries := c.maxRetries() var lastErr error for attempt := 0; attempt < maxRetries; attempt++ { if attempt > 0 { - backoff := calculateBackoff(attempt - 1) + backoff := c.calculateBackoff(attempt - 1) logger.InfoCF("qq", "Retrying send", map[string]any{ "attempt": attempt, "chat_id": msg.ChatID, @@ -1030,7 +1045,7 @@ func (c *QQChannel) reconnect() { c.reconnectMu.Unlock() }() - backoff := reconnectInitial + backoff := c.reconnectInitial() for { select { case <-c.ctx.Done(): @@ -1064,6 +1079,7 @@ func (c *QQChannel) reconnect() { case <-c.stopReconnect: return case <-time.After(backoff): + reconnectMax := c.reconnectMax() if backoff < reconnectMax { backoff = time.Duration(float64(backoff) * reconnectMultiplier) if backoff > reconnectMax { @@ -1123,10 +1139,52 @@ func isRetryableError(err error) bool { } // calculateBackoff returns the next backoff duration with exponential increase. -func calculateBackoff(attempt int) time.Duration { - backoff := retryInitialDelay * time.Duration(1< retryMaxDelay { - backoff = retryMaxDelay +func (c *QQChannel) calculateBackoff(attempt int) time.Duration { + initialDelay := c.retryInitialDelay() + maxDelay := c.retryMaxDelay() + backoff := initialDelay * time.Duration(1< maxDelay { + backoff = maxDelay } return backoff } + +// reconnectInitial returns the configured or default initial reconnect interval. +func (c *QQChannel) reconnectInitial() time.Duration { + if c.config.ReconnectInitialMs > 0 { + return time.Duration(c.config.ReconnectInitialMs) * time.Millisecond + } + return reconnectInitialDefault +} + +// reconnectMax returns the configured or default max reconnect interval. +func (c *QQChannel) reconnectMax() time.Duration { + if c.config.ReconnectMaxMs > 0 { + return time.Duration(c.config.ReconnectMaxMs) * time.Millisecond + } + return reconnectMaxDefault +} + +// maxRetries returns the configured or default max retry count. +func (c *QQChannel) maxRetries() int { + if c.config.MaxRetries > 0 { + return c.config.MaxRetries + } + return maxRetriesDefault +} + +// retryInitialDelay returns the configured or default initial retry delay. +func (c *QQChannel) retryInitialDelay() time.Duration { + if c.config.RetryInitialDelayMs > 0 { + return time.Duration(c.config.RetryInitialDelayMs) * time.Millisecond + } + return retryInitialDelayDefault +} + +// retryMaxDelay returns the configured or default max retry delay. +func (c *QQChannel) retryMaxDelay() time.Duration { + if c.config.RetryMaxDelayMs > 0 { + return time.Duration(c.config.RetryMaxDelayMs) * time.Millisecond + } + return retryMaxDelayDefault +} diff --git a/pkg/channels/qq/qq_stability_test.go b/pkg/channels/qq/qq_stability_test.go index 512de3a19..10b01640d 100644 --- a/pkg/channels/qq/qq_stability_test.go +++ b/pkg/channels/qq/qq_stability_test.go @@ -374,7 +374,8 @@ func TestCalculateBackoff(t *testing.T) { for _, tt := range tests { t.Run("", func(t *testing.T) { - result := calculateBackoff(tt.attempt) + ch := newTestQQChannel(t, &mockQQAPI{}) + result := ch.calculateBackoff(tt.attempt) if result != tt.expected { t.Errorf("calculateBackoff(%d) = %v, want %v", tt.attempt, result, tt.expected) } @@ -427,6 +428,105 @@ func TestQQChannel_ResetSessionState_ReasoningChannel(t *testing.T) { } } +// TestQQChannel_ConfigDrivenParams tests that config values override defaults. +func TestQQChannel_ConfigDrivenParams(t *testing.T) { + tests := []struct { + name string + setup func(*config.QQConfig) + check func(*testing.T, *QQChannel) + }{ + { + name: "reconnect initial uses config when set", + setup: func(cfg *config.QQConfig) { cfg.ReconnectInitialMs = 10000 }, + check: func(t *testing.T, ch *QQChannel) { + if got := ch.reconnectInitial(); got != 10*time.Second { + t.Errorf("reconnectInitial() = %v, want 10s", got) + } + }, + }, + { + name: "reconnect initial falls back to default when zero", + setup: func(cfg *config.QQConfig) { cfg.ReconnectInitialMs = 0 }, + check: func(t *testing.T, ch *QQChannel) { + if got := ch.reconnectInitial(); got != 5*time.Second { + t.Errorf("reconnectInitial() = %v, want 5s", got) + } + }, + }, + { + name: "reconnect max uses config when set", + setup: func(cfg *config.QQConfig) { cfg.ReconnectMaxMs = 60000 }, + check: func(t *testing.T, ch *QQChannel) { + if got := ch.reconnectMax(); got != 60*time.Second { + t.Errorf("reconnectMax() = %v, want 60s", got) + } + }, + }, + { + name: "max retries uses config when set", + setup: func(cfg *config.QQConfig) { cfg.MaxRetries = 5 }, + check: func(t *testing.T, ch *QQChannel) { + if got := ch.maxRetries(); got != 5 { + t.Errorf("maxRetries() = %v, want 5", got) + } + }, + }, + { + name: "max retries falls back to default when zero", + setup: func(cfg *config.QQConfig) { cfg.MaxRetries = 0 }, + check: func(t *testing.T, ch *QQChannel) { + if got := ch.maxRetries(); got != 3 { + t.Errorf("maxRetries() = %v, want 3", got) + } + }, + }, + { + name: "retry max delay uses config when set", + setup: func(cfg *config.QQConfig) { cfg.RetryMaxDelayMs = 30000 }, + check: func(t *testing.T, ch *QQChannel) { + if got := ch.retryMaxDelay(); got != 30*time.Second { + t.Errorf("retryMaxDelay() = %v, want 30s", got) + } + }, + }, + { + name: "rate limiters use config values", + setup: func(cfg *config.QQConfig) { + cfg.RateLimitGroupMs = 1000 + cfg.RateLimitDirectMs = 500 + }, + check: func(t *testing.T, ch *QQChannel) { + // Verify rate limiters are created with config values + start := time.Now() + ch.groupRateLimiter.wait("test-chat") + ch.groupRateLimiter.wait("test-chat") + elapsed := time.Since(start) + if elapsed < 900*time.Millisecond { + t.Errorf("group rate limiter did not delay ~1s, got %v", elapsed) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := config.QQConfig{AppID: "test", AppSecret: "test"} + tt.setup(&cfg) + // Rate limiter test needs NewQQChannel to initialize rate limiters + if tt.name == "rate limiters use config values" { + ch, err := NewQQChannel(cfg, nil) + if err != nil { + t.Fatalf("NewQQChannel failed: %v", err) + } + tt.check(t, ch) + return + } + ch := &QQChannel{config: cfg} + tt.check(t, ch) + }) + } +} + // Verify mockQQAPI implements qqAPI interface at compile time var _ qqAPI = (*mockQQAPI)(nil) diff --git a/pkg/config/config.go b/pkg/config/config.go index 4e380d431..ec8c289b0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -358,6 +358,15 @@ type QQConfig struct { MaxBase64FileSizeMiB int64 `json:"max_base64_file_size_mib" env:"PICOCLAW_CHANNELS_QQ_MAX_BASE64_FILE_SIZE_MIB"` SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"` + + // Stability config (all optional with sensible defaults) + ReconnectInitialMs int `json:"reconnect_initial_ms,omitempty" env:"PICOCLAW_CHANNELS_QQ_RECONNECT_INITIAL_MS"` + ReconnectMaxMs int `json:"reconnect_max_ms,omitempty" env:"PICOCLAW_CHANNELS_QQ_RECONNECT_MAX_MS"` + MaxRetries int `json:"max_retries,omitempty" env:"PICOCLAW_CHANNELS_QQ_MAX_RETRIES"` + RetryInitialDelayMs int `json:"retry_initial_delay_ms,omitempty" env:"PICOCLAW_CHANNELS_QQ_RETRY_INITIAL_DELAY_MS"` + RetryMaxDelayMs int `json:"retry_max_delay_ms,omitempty" env:"PICOCLAW_CHANNELS_QQ_RETRY_MAX_DELAY_MS"` + RateLimitGroupMs int `json:"rate_limit_group_ms,omitempty" env:"PICOCLAW_CHANNELS_QQ_RATE_LIMIT_GROUP_MS"` + RateLimitDirectMs int `json:"rate_limit_direct_ms,omitempty" env:"PICOCLAW_CHANNELS_QQ_RATE_LIMIT_DIRECT_MS"` } type DingTalkConfig struct {