feat(qq): make stability parameters configurable

Adds optional stability configuration fields to QQConfig:
- reconnect_initial_ms / reconnect_max_ms: WebSocket reconnection intervals
- max_retries / retry_initial_delay_ms / retry_max_delay_ms: API retry parameters
- rate_limit_group_ms / rate_limit_direct_ms: Per-chat rate limit intervals

All values are optional and fall back to sensible hardcoded defaults.
Adds 7 new unit tests covering config-driven parameter methods.
This commit is contained in:
kent.liu 2026-03-19 17:59:05 +08:00
parent 0195ccfd38
commit d25abb59a4
4 changed files with 197 additions and 22 deletions

View file

@ -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,

View file

@ -42,15 +42,19 @@ const (
typingSeconds = 10
bytesPerMiB = 1024 * 1024
// Reconnection constants
reconnectInitial = 5 * time.Second
reconnectMax = 5 * time.Minute
// 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
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<<uint(attempt))
if backoff > retryMaxDelay {
backoff = retryMaxDelay
func (c *QQChannel) calculateBackoff(attempt int) time.Duration {
initialDelay := c.retryInitialDelay()
maxDelay := c.retryMaxDelay()
backoff := initialDelay * time.Duration(1<<uint(attempt))
if backoff > 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
}

View file

@ -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)

View file

@ -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 {