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:
parent
0195ccfd38
commit
d25abb59a4
4 changed files with 197 additions and 22 deletions
|
|
@ -97,7 +97,15 @@
|
||||||
"app_id": "YOUR_QQ_APP_ID",
|
"app_id": "YOUR_QQ_APP_ID",
|
||||||
"app_secret": "YOUR_QQ_APP_SECRET",
|
"app_secret": "YOUR_QQ_APP_SECRET",
|
||||||
"allow_from": [],
|
"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": {
|
"maixcam": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
|
||||||
|
|
@ -42,15 +42,19 @@ const (
|
||||||
typingSeconds = 10
|
typingSeconds = 10
|
||||||
bytesPerMiB = 1024 * 1024
|
bytesPerMiB = 1024 * 1024
|
||||||
|
|
||||||
// Reconnection constants
|
// Reconnection constants (used as defaults when config values are 0)
|
||||||
reconnectInitial = 5 * time.Second
|
reconnectInitialDefault = 5 * time.Second
|
||||||
reconnectMax = 5 * time.Minute
|
reconnectMaxDefault = 5 * time.Minute
|
||||||
reconnectMultiplier = 2.0
|
reconnectMultiplier = 2.0
|
||||||
|
|
||||||
// Retry constants
|
// Retry constants (used as defaults when config values are 0)
|
||||||
maxRetries = 3
|
maxRetriesDefault = 3
|
||||||
retryInitialDelay = 500 * time.Millisecond
|
retryInitialDelayDefault = 500 * time.Millisecond
|
||||||
retryMaxDelay = 10 * time.Second
|
retryMaxDelayDefault = 10 * time.Second
|
||||||
|
|
||||||
|
// Rate limit defaults
|
||||||
|
rateLimitGroupDefault = 500 * time.Millisecond
|
||||||
|
rateLimitDirectDefault = 200 * time.Millisecond
|
||||||
)
|
)
|
||||||
|
|
||||||
type qqAPI interface {
|
type qqAPI interface {
|
||||||
|
|
@ -108,13 +112,23 @@ func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel,
|
||||||
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
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{
|
return &QQChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
dedup: make(map[string]time.Time),
|
dedup: make(map[string]time.Time),
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
groupRateLimiter: newRateLimiter(500 * time.Millisecond), // 20 msg/min with headroom
|
groupRateLimiter: newRateLimiter(time.Duration(groupRateMs) * time.Millisecond),
|
||||||
directRateLimiter: newRateLimiter(200 * time.Millisecond), // 5 msg/sec with headroom
|
directRateLimiter: newRateLimiter(time.Duration(directRateMs) * time.Millisecond),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,10 +264,11 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route to group or C2C with retry.
|
// Route to group or C2C with retry.
|
||||||
|
maxRetries := c.maxRetries()
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
backoff := calculateBackoff(attempt - 1)
|
backoff := c.calculateBackoff(attempt - 1)
|
||||||
logger.InfoCF("qq", "Retrying send", map[string]any{
|
logger.InfoCF("qq", "Retrying send", map[string]any{
|
||||||
"attempt": attempt,
|
"attempt": attempt,
|
||||||
"chat_id": msg.ChatID,
|
"chat_id": msg.ChatID,
|
||||||
|
|
@ -1030,7 +1045,7 @@ func (c *QQChannel) reconnect() {
|
||||||
c.reconnectMu.Unlock()
|
c.reconnectMu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
backoff := reconnectInitial
|
backoff := c.reconnectInitial()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-c.ctx.Done():
|
case <-c.ctx.Done():
|
||||||
|
|
@ -1064,6 +1079,7 @@ func (c *QQChannel) reconnect() {
|
||||||
case <-c.stopReconnect:
|
case <-c.stopReconnect:
|
||||||
return
|
return
|
||||||
case <-time.After(backoff):
|
case <-time.After(backoff):
|
||||||
|
reconnectMax := c.reconnectMax()
|
||||||
if backoff < reconnectMax {
|
if backoff < reconnectMax {
|
||||||
backoff = time.Duration(float64(backoff) * reconnectMultiplier)
|
backoff = time.Duration(float64(backoff) * reconnectMultiplier)
|
||||||
if backoff > reconnectMax {
|
if backoff > reconnectMax {
|
||||||
|
|
@ -1123,10 +1139,52 @@ func isRetryableError(err error) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateBackoff returns the next backoff duration with exponential increase.
|
// calculateBackoff returns the next backoff duration with exponential increase.
|
||||||
func calculateBackoff(attempt int) time.Duration {
|
func (c *QQChannel) calculateBackoff(attempt int) time.Duration {
|
||||||
backoff := retryInitialDelay * time.Duration(1<<uint(attempt))
|
initialDelay := c.retryInitialDelay()
|
||||||
if backoff > retryMaxDelay {
|
maxDelay := c.retryMaxDelay()
|
||||||
backoff = retryMaxDelay
|
backoff := initialDelay * time.Duration(1<<uint(attempt))
|
||||||
|
if backoff > maxDelay {
|
||||||
|
backoff = maxDelay
|
||||||
}
|
}
|
||||||
return backoff
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -374,7 +374,8 @@ func TestCalculateBackoff(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run("", func(t *testing.T) {
|
t.Run("", func(t *testing.T) {
|
||||||
result := calculateBackoff(tt.attempt)
|
ch := newTestQQChannel(t, &mockQQAPI{})
|
||||||
|
result := ch.calculateBackoff(tt.attempt)
|
||||||
if result != tt.expected {
|
if result != tt.expected {
|
||||||
t.Errorf("calculateBackoff(%d) = %v, want %v", tt.attempt, 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
|
// Verify mockQQAPI implements qqAPI interface at compile time
|
||||||
var _ qqAPI = (*mockQQAPI)(nil)
|
var _ qqAPI = (*mockQQAPI)(nil)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -358,6 +358,15 @@ type QQConfig struct {
|
||||||
MaxBase64FileSizeMiB int64 `json:"max_base64_file_size_mib" env:"PICOCLAW_CHANNELS_QQ_MAX_BASE64_FILE_SIZE_MIB"`
|
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"`
|
SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"`
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
|
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 {
|
type DingTalkConfig struct {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue