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
24 changes: 24 additions & 0 deletions chatapps/feishu/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"sync"
"time"

"github.com/hrygo/hotplex/chatapps"
"github.com/hrygo/hotplex/chatapps/base"
"github.com/hrygo/hotplex/chatapps/command"
"github.com/hrygo/hotplex/engine"
Expand Down Expand Up @@ -39,6 +40,9 @@ type Adapter struct {
interactiveHandler *InteractiveHandler
// Command registry
commandRegistry *command.Registry

// Processor chain for message processing (Issue #172)
processorChain *chatapps.ProcessorChain
}

// Compile-time interface compliance checks
Expand Down Expand Up @@ -100,6 +104,12 @@ func NewAdapter(config *Config, logger *slog.Logger, opts ...base.AdapterOption)
// Initialize command handler (Phase 2.3) after base adapter is created
a.commandHandler = NewCommandHandler(a, a.commandRegistry)

// Initialize processor chain with Feishu-specific converter (Issue #172)
feishuConverter := NewFeishuConverter()
a.processorChain = chatapps.NewDefaultProcessorChain(context.Background(), logger, chatapps.ProcessorChainOptions{
FormatConverter: feishuConverter,
})

// Set default sender
a.sender.SetSender(a.defaultSender)

Expand Down Expand Up @@ -153,11 +163,25 @@ func (a *Adapter) NewStreamWriter(ctx context.Context, userID, chatID, _ string)

// defaultSender sends message via Feishu API
// Routes different message types to appropriate handlers
// Issue #172: Routes through ProcessorChain for message processing
func (a *Adapter) defaultSender(ctx context.Context, sessionID string, msg *base.ChatMessage) error {
if a.client == nil {
return ErrMessageSendFailed
}

// Issue #172: Process message through processor chain before sending
if a.processorChain != nil {
processedMsg, err := a.processorChain.Process(ctx, msg)
if err != nil {
return err
}
if processedMsg == nil {
// Message was filtered/dropped by processor chain
return nil
}
msg = processedMsg
}

// Get access token with context
token, err := a.GetAppTokenWithContext(ctx)
if err != nil {
Expand Down
118 changes: 118 additions & 0 deletions chatapps/feishu/card_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package feishu
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"time"
)

const (
Expand Down Expand Up @@ -126,3 +129,118 @@ func (c *Client) SendCardMessage(ctx context.Context, token, chatID, cardID stri

return msgResp.Data.MessageID, nil
}

// SendCardWithRetry sends a card message with exponential backoff retry
// Retry strategy:
// - Initial delay: 100ms
// - Max delay: 5s
// - Max attempts: 3
// - Retry on: network errors and 5xx server errors
// - No retry on: 4xx client errors
func (c *Client) SendCardWithRetry(ctx context.Context, token, chatID, cardID string) (string, error) {
const (
maxAttempts = 3
initialDelay = 100 * time.Millisecond
maxDelay = 5 * time.Second
backoffFactor = 2.0
)

var lastErr error

for attempt := 1; attempt <= maxAttempts; attempt++ {
messageID, err := c.SendCardMessage(ctx, token, chatID, cardID)
if err == nil {
// Success
return messageID, nil
}

lastErr = err

// Check if error is retryable
if !c.isRetryableError(err) {
// Non-retryable error (e.g., 4xx client error)
return "", err
}

// Don't sleep after last attempt
if attempt < maxAttempts {
delay := c.calculateBackoff(attempt, initialDelay, maxDelay, backoffFactor)
c.logger.Warn("SendCardMessage failed, retrying",
"attempt", attempt,
"max_attempts", maxAttempts,
"delay_ms", delay.Milliseconds(),
"error", err)

select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(delay):
// Continue to next attempt
}
}
}

// All retries exhausted
return "", fmt.Errorf("send card message failed after %d attempts: %w", maxAttempts, lastErr)
}

// isRetryableError determines if an error is retryable
// Retryable: network errors, 5xx server errors
// Non-retryable: 4xx client errors
func (c *Client) isRetryableError(err error) bool {
if err == nil {
return false
}

// Check for network errors (timeout, connection refused, etc.)
if isNetworkError(err) {
return true
}

// Check for API errors
var apiErr *APIError
if errors.As(err, &apiErr) {
// 5xx server errors are retryable
// 4xx client errors are not retryable
return apiErr.Code >= 500 && apiErr.Code < 600
}

// Unknown error type - be conservative and don't retry
return false
}

// isNetworkError checks if an error is a network-level error
func isNetworkError(err error) bool {
if err == nil {
return false
}

// Check for net.Error (timeout, temporary errors)
var netErr net.Error
if errors.As(err, &netErr) {
return true
}

// Check for common network error types
// This includes: connection refused, timeout, DNS errors, etc.
return isNetOpError(err)
}

// isNetOpError checks if error is a net.OpError
func isNetOpError(err error) bool {
var netErr *net.OpError
return errors.As(err, &netErr)
}

// calculateBackoff calculates the backoff delay for a given attempt
func (c *Client) calculateBackoff(attempt int, initialDelay, maxDelay time.Duration, factor float64) time.Duration {
delay := initialDelay
for i := 1; i < attempt; i++ {
delay = time.Duration(float64(delay) * factor)
if delay > maxDelay {
delay = maxDelay
break
}
}
return delay
}
Loading
Loading