chore: resolve conflicts with upstream/main
This commit is contained in:
commit
62bdece7f5
96 changed files with 11203 additions and 1610 deletions
35
Makefile
35
Makefile
|
|
@ -87,11 +87,46 @@ build: generate
|
||||||
@echo "Build complete: $(BINARY_PATH)"
|
@echo "Build complete: $(BINARY_PATH)"
|
||||||
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
|
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
||||||
|
build-whatsapp-native: generate
|
||||||
|
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
||||||
|
@echo "Building for multiple platforms..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||||
|
GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||||
|
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
|
## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||||
|
@echo "Build complete"
|
||||||
|
## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
|
## build-linux-arm: Build for Linux ARMv7 (e.g. Raspberry Pi Zero 2 W 32-bit)
|
||||||
|
build-linux-arm: generate
|
||||||
|
@echo "Building for linux/arm (GOARM=7)..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm"
|
||||||
|
|
||||||
|
## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit)
|
||||||
|
build-linux-arm64: generate
|
||||||
|
@echo "Building for linux/arm64..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
|
||||||
|
|
||||||
|
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
|
||||||
|
build-pi-zero: build-linux-arm build-linux-arm64
|
||||||
|
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
|
||||||
|
|
||||||
## build-all: Build picoclaw for all platforms
|
## build-all: Build picoclaw for all platforms
|
||||||
build-all: generate
|
build-all: generate
|
||||||
@echo "Building for multiple platforms..."
|
@echo "Building for multiple platforms..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||||
|
|
|
||||||
41
README.md
41
README.md
|
|
@ -154,10 +154,15 @@ make build
|
||||||
# Build for multiple platforms
|
# Build for multiple platforms
|
||||||
make build-all
|
make build-all
|
||||||
|
|
||||||
|
# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||||
|
make build-pi-zero
|
||||||
|
|
||||||
# Build And Install
|
# Build And Install
|
||||||
make install
|
make install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS → `make build-linux-arm` (output: `build/picoclaw-linux-arm`); 64-bit → `make build-linux-arm64` (output: `build/picoclaw-linux-arm64`). Or run `make build-pi-zero` to build both.
|
||||||
|
|
||||||
## 🐳 Docker Compose
|
## 🐳 Docker Compose
|
||||||
|
|
||||||
You can also run PicoClaw using Docker Compose without installing anything locally.
|
You can also run PicoClaw using Docker Compose without installing anything locally.
|
||||||
|
|
@ -288,12 +293,13 @@ That's it! You have a working AI assistant in 2 minutes.
|
||||||
|
|
||||||
## 💬 Chat Apps
|
## 💬 Chat Apps
|
||||||
|
|
||||||
Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
|
Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or WeCom
|
||||||
|
|
||||||
| Channel | Setup |
|
| Channel | Setup |
|
||||||
| ------------ | ---------------------------------- |
|
| ------------ | ---------------------------------- |
|
||||||
| **Telegram** | Easy (just a token) |
|
| **Telegram** | Easy (just a token) |
|
||||||
| **Discord** | Easy (bot token + intents) |
|
| **Discord** | Easy (bot token + intents) |
|
||||||
|
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
|
||||||
| **QQ** | Easy (AppID + AppSecret) |
|
| **QQ** | Easy (AppID + AppSecret) |
|
||||||
| **DingTalk** | Medium (app credentials) |
|
| **DingTalk** | Medium (app credentials) |
|
||||||
| **LINE** | Medium (credentials + webhook URL) |
|
| **LINE** | Medium (credentials + webhook URL) |
|
||||||
|
|
@ -384,6 +390,33 @@ picoclaw gateway
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>WhatsApp</b> (native via whatsmeow)</summary>
|
||||||
|
|
||||||
|
PicoClaw can connect to WhatsApp in two ways:
|
||||||
|
|
||||||
|
- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`).
|
||||||
|
- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false.
|
||||||
|
|
||||||
|
**Configure (native)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"whatsapp": {
|
||||||
|
"enabled": true,
|
||||||
|
"use_native": true,
|
||||||
|
"session_store_path": "",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `session_store_path` is empty, the session is stored in `<workspace>/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>QQ</b></summary>
|
<summary><b>QQ</b></summary>
|
||||||
|
|
||||||
|
|
@ -1070,7 +1103,11 @@ picoclaw agent -m "Hello"
|
||||||
"allow_from": [""]
|
"allow_from": [""]
|
||||||
},
|
},
|
||||||
"whatsapp": {
|
"whatsapp": {
|
||||||
"enabled": false
|
"enabled": false,
|
||||||
|
"bridge_url": "ws://localhost:3001",
|
||||||
|
"use_native": false,
|
||||||
|
"session_store_path": "",
|
||||||
|
"allow_from": []
|
||||||
},
|
},
|
||||||
"feishu": {
|
"feishu": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 366 KiB |
|
|
@ -48,6 +48,7 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
|
defer msgBus.Close()
|
||||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
// Print agent startup info (only for interactive mode)
|
// Print agent startup info (only for interactive mode)
|
||||||
|
|
|
||||||
|
|
@ -2,29 +2,39 @@ package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/agent"
|
"github.com/sipeed/picoclaw/pkg/agent"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/slack"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/telegram"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/cron"
|
"github.com/sipeed/picoclaw/pkg/cron"
|
||||||
"github.com/sipeed/picoclaw/pkg/devices"
|
"github.com/sipeed/picoclaw/pkg/devices"
|
||||||
"github.com/sipeed/picoclaw/pkg/health"
|
"github.com/sipeed/picoclaw/pkg/health"
|
||||||
"github.com/sipeed/picoclaw/pkg/heartbeat"
|
"github.com/sipeed/picoclaw/pkg/heartbeat"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/state"
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
"github.com/sipeed/picoclaw/pkg/voice"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func gatewayCmd(debug bool) error {
|
func gatewayCmd(debug bool) error {
|
||||||
|
|
@ -105,49 +115,23 @@ func gatewayCmd(debug bool) error {
|
||||||
return tools.SilentResult(response)
|
return tools.SilentResult(response)
|
||||||
})
|
})
|
||||||
|
|
||||||
channelManager, err := channels.NewManager(cfg, msgBus)
|
// Create media store for file lifecycle management with TTL cleanup
|
||||||
|
mediaStore := media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
|
||||||
|
Enabled: cfg.Tools.MediaCleanup.Enabled,
|
||||||
|
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
|
||||||
|
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
|
||||||
|
})
|
||||||
|
mediaStore.Start()
|
||||||
|
|
||||||
|
channelManager, err := channels.NewManager(cfg, msgBus, mediaStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
mediaStore.Stop()
|
||||||
return fmt.Errorf("error creating channel manager: %w", err)
|
return fmt.Errorf("error creating channel manager: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject channel manager into agent loop for command handling
|
// Inject channel manager and media store into agent loop
|
||||||
agentLoop.SetChannelManager(channelManager)
|
agentLoop.SetChannelManager(channelManager)
|
||||||
|
agentLoop.SetMediaStore(mediaStore)
|
||||||
var transcriber *voice.GroqTranscriber
|
|
||||||
groqAPIKey := cfg.Providers.Groq.APIKey
|
|
||||||
if groqAPIKey == "" {
|
|
||||||
for _, mc := range cfg.ModelList {
|
|
||||||
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
|
|
||||||
groqAPIKey = mc.APIKey
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if groqAPIKey != "" {
|
|
||||||
transcriber = voice.NewGroqTranscriber(groqAPIKey)
|
|
||||||
logger.InfoC("voice", "Groq voice transcription enabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
if transcriber != nil {
|
|
||||||
if telegramChannel, ok := channelManager.GetChannel("telegram"); ok {
|
|
||||||
if tc, ok := telegramChannel.(*channels.TelegramChannel); ok {
|
|
||||||
tc.SetTranscriber(transcriber)
|
|
||||||
logger.InfoC("voice", "Groq transcription attached to Telegram channel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if discordChannel, ok := channelManager.GetChannel("discord"); ok {
|
|
||||||
if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
|
|
||||||
dc.SetTranscriber(transcriber)
|
|
||||||
logger.InfoC("voice", "Groq transcription attached to Discord channel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if slackChannel, ok := channelManager.GetChannel("slack"); ok {
|
|
||||||
if sc, ok := slackChannel.(*channels.SlackChannel); ok {
|
|
||||||
sc.SetTranscriber(transcriber)
|
|
||||||
logger.InfoC("voice", "Groq transcription attached to Slack channel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enabledChannels := channelManager.GetEnabledChannels()
|
enabledChannels := channelManager.GetEnabledChannels()
|
||||||
if len(enabledChannels) > 0 {
|
if len(enabledChannels) > 0 {
|
||||||
|
|
@ -184,16 +168,15 @@ func gatewayCmd(debug bool) error {
|
||||||
fmt.Println("✓ Device event service started")
|
fmt.Println("✓ Device event service started")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup shared HTTP server with health endpoints and webhook handlers
|
||||||
|
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
|
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
|
channelManager.SetupHTTPServer(addr, healthServer)
|
||||||
|
|
||||||
if err := channelManager.StartAll(ctx); err != nil {
|
if err := channelManager.StartAll(ctx); err != nil {
|
||||||
fmt.Printf("Error starting channels: %v\n", err)
|
fmt.Printf("Error starting channels: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
|
||||||
go func() {
|
|
||||||
if err := healthServer.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()})
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
|
|
||||||
go agentLoop.Run(ctx)
|
go agentLoop.Run(ctx)
|
||||||
|
|
@ -207,12 +190,19 @@ func gatewayCmd(debug bool) error {
|
||||||
cp.Close()
|
cp.Close()
|
||||||
}
|
}
|
||||||
cancel()
|
cancel()
|
||||||
healthServer.Stop(context.Background())
|
msgBus.Close()
|
||||||
|
|
||||||
|
// Use a fresh context with timeout for graceful shutdown,
|
||||||
|
// since the original ctx is already canceled.
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
|
||||||
|
channelManager.StopAll(shutdownCtx)
|
||||||
deviceService.Stop()
|
deviceService.Stop()
|
||||||
heartbeatService.Stop()
|
heartbeatService.Stop()
|
||||||
cronService.Stop()
|
cronService.Stop()
|
||||||
|
mediaStore.Stop()
|
||||||
agentLoop.Stop()
|
agentLoop.Stop()
|
||||||
channelManager.StopAll(ctx)
|
|
||||||
fmt.Println("✓ Gateway stopped")
|
fmt.Println("✓ Gateway stopped")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -52,30 +52,37 @@
|
||||||
"proxy": "",
|
"proxy": "",
|
||||||
"allow_from": [
|
"allow_from": [
|
||||||
"YOUR_USER_ID"
|
"YOUR_USER_ID"
|
||||||
]
|
],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"token": "YOUR_DISCORD_BOT_TOKEN",
|
"token": "YOUR_DISCORD_BOT_TOKEN",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"mention_only": false
|
"mention_only": false,
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"qq": {
|
"qq": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"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": ""
|
||||||
},
|
},
|
||||||
"maixcam": {
|
"maixcam": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 18790,
|
"port": 18790,
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"whatsapp": {
|
"whatsapp": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"bridge_url": "ws://localhost:3001",
|
"bridge_url": "ws://localhost:3001",
|
||||||
"allow_from": []
|
"use_native": false,
|
||||||
|
"session_store_path": "",
|
||||||
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"feishu": {
|
"feishu": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
@ -83,19 +90,22 @@
|
||||||
"app_secret": "",
|
"app_secret": "",
|
||||||
"encrypt_key": "",
|
"encrypt_key": "",
|
||||||
"verification_token": "",
|
"verification_token": "",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"dingtalk": {
|
"dingtalk": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"client_id": "YOUR_CLIENT_ID",
|
"client_id": "YOUR_CLIENT_ID",
|
||||||
"client_secret": "YOUR_CLIENT_SECRET",
|
"client_secret": "YOUR_CLIENT_SECRET",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"slack": {
|
"slack": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"bot_token": "xoxb-YOUR-BOT-TOKEN",
|
"bot_token": "xoxb-YOUR-BOT-TOKEN",
|
||||||
"app_token": "xapp-YOUR-APP-TOKEN",
|
"app_token": "xapp-YOUR-APP-TOKEN",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"line": {
|
"line": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
@ -104,7 +114,8 @@
|
||||||
"webhook_host": "0.0.0.0",
|
"webhook_host": "0.0.0.0",
|
||||||
"webhook_port": 18791,
|
"webhook_port": 18791,
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"onebot": {
|
"onebot": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
@ -112,7 +123,8 @@
|
||||||
"access_token": "",
|
"access_token": "",
|
||||||
"reconnect_interval": 5,
|
"reconnect_interval": 5,
|
||||||
"group_trigger_prefix": [],
|
"group_trigger_prefix": [],
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"wecom": {
|
"wecom": {
|
||||||
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
|
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
|
||||||
|
|
@ -124,7 +136,8 @@
|
||||||
"webhook_port": 18793,
|
"webhook_port": 18793,
|
||||||
"webhook_path": "/webhook/wecom",
|
"webhook_path": "/webhook/wecom",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reply_timeout": 5
|
"reply_timeout": 5,
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"wecom_app": {
|
"wecom_app": {
|
||||||
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md",
|
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md",
|
||||||
|
|
@ -138,7 +151,8 @@
|
||||||
"webhook_port": 18792,
|
"webhook_port": 18792,
|
||||||
"webhook_path": "/webhook/wecom-app",
|
"webhook_path": "/webhook/wecom-app",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reply_timeout": 5
|
"reply_timeout": 5,
|
||||||
|
"reasoning_channel_id": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
|
||||||
61
docs/design/issue-783-investigation-and-fix-plan.zh.md
Normal file
61
docs/design/issue-783-investigation-and-fix-plan.zh.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Issue #783 调研与修复执行文档
|
||||||
|
|
||||||
|
## 1. 问题澄清(已确认)
|
||||||
|
|
||||||
|
- 现象:当 `agents.*.model.primary/fallbacks` 使用 `model_name` 别名(如 `step-3.5-flash`)时,fallback 链路将别名当作真实 `provider/model` 解析,导致 `provider` 可能为空、`model` 可能错误。
|
||||||
|
- 根因:`ResolveCandidates` 仅对字符串做 `ParseModelRef`,未先通过 `model_list` 将别名映射到真实 `model` 字段。
|
||||||
|
- 影响:
|
||||||
|
- fallback 执行可能把别名直接发给 OpenAI-compatible provider,触发 `Unknown Model`。
|
||||||
|
- `defaults.provider` 为空时,日志出现 `provider=` 空值。
|
||||||
|
|
||||||
|
## 2. 本次目标
|
||||||
|
|
||||||
|
- 修复 fallback 候选解析:优先通过 `model_list` 解析别名。
|
||||||
|
- 兼容旧行为:若未命中 `model_list`,继续走原有 `ParseModelRef` 兜底。
|
||||||
|
- 补充测试:覆盖别名、嵌套路径模型(如 `openrouter/stepfun/...`)、空默认 provider。
|
||||||
|
- 验证代码风格:与当前仓库风格保持一致(命名、错误处理、测试结构)。
|
||||||
|
|
||||||
|
## 3. 联网最佳实践调研结论(已完成)
|
||||||
|
|
||||||
|
- [x] 查阅 OpenAI-compatible 网关(如 OpenRouter)对 `model` 字段的推荐处理。
|
||||||
|
- [x] 查阅多 provider/fallback 设计最佳实践(候选解析、日志可观测性)。
|
||||||
|
- [x] 将外部建议映射为本仓库可执行约束。
|
||||||
|
|
||||||
|
外部参考要点(来自 OpenRouter/LiteLLM/Cloudflare AI Gateway 等官方文档):
|
||||||
|
|
||||||
|
- 优先显式配置,不依赖字符串切分推断 provider。
|
||||||
|
- 对网关模型标识应保留完整路径语义,避免截断导致 Unknown Model。
|
||||||
|
- fallback 与 primary 应复用同一解析策略,避免“主路径正确、降级路径错误”。
|
||||||
|
|
||||||
|
参考链接:
|
||||||
|
|
||||||
|
- OpenRouter Provider Routing: https://openrouter.ai/docs/guides/routing/provider-selection
|
||||||
|
- OpenRouter Model Fallbacks: https://openrouter.ai/docs/guides/routing/model-fallbacks
|
||||||
|
- OpenRouter Chat Completion API: https://openrouter.ai/docs/api-reference/chat-completion
|
||||||
|
- LiteLLM Router Architecture: https://docs.litellm.ai/docs/router_architecture
|
||||||
|
- Cloudflare AI Gateway Chat Completion: https://developers.cloudflare.com/ai-gateway/usage/chat-completion/
|
||||||
|
|
||||||
|
与本仓库对应的可执行约束:
|
||||||
|
|
||||||
|
- 在 fallback candidate 构建阶段先做 `model_name -> model_list.model` 映射。
|
||||||
|
- 未命中映射时保留旧解析行为,保证兼容性。
|
||||||
|
- 用新增测试锁定“别名 + 嵌套模型路径 + 空默认 provider”场景。
|
||||||
|
|
||||||
|
## 4. 实施步骤(顺序执行)
|
||||||
|
|
||||||
|
- [x] Step 1: 对齐现有代码模式,定位最小改动点(`pkg/agent` + `pkg/providers`)。
|
||||||
|
- [x] Step 2: 实现“基于 model_list 的 fallback 候选解析”。
|
||||||
|
- [x] Step 3: 增加/更新单元测试,覆盖 issue 场景。
|
||||||
|
- [x] Step 4: 代码风格一致性复核(与现有文件风格对照)。
|
||||||
|
- [x] Step 5: 运行质量门禁(LSP + `make check`)。
|
||||||
|
|
||||||
|
## 5. 执行记录
|
||||||
|
|
||||||
|
- 状态:已完成
|
||||||
|
- 已完成改动:
|
||||||
|
- `pkg/providers/fallback.go`:新增 `ResolveCandidatesWithLookup`,并保持 `ResolveCandidates` 向后兼容。
|
||||||
|
- `pkg/agent/instance.go`:在构建 fallback candidates 前,优先通过 `model_list` 解析别名,并对无协议模型补齐默认 `openai/` 前缀后再解析。
|
||||||
|
- `pkg/providers/fallback_test.go`:新增别名解析与去重测试。
|
||||||
|
- `pkg/agent/instance_test.go`:新增 agent 侧别名解析到嵌套模型路径、无协议模型解析测试。
|
||||||
|
- 风格对齐检查(完成):与 `pkg/providers/fallback_test.go`、`pkg/providers/model_ref_test.go` 现有模式一致。
|
||||||
|
- 质量验证(完成):先 `make generate`,后 `make check` 全量通过。
|
||||||
43
docs/troubleshooting.md
Normal file
43
docs/troubleshooting.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
## "model ... not found in model_list" or OpenRouter "free is not a valid model ID"
|
||||||
|
|
||||||
|
**Symptom:** You see either:
|
||||||
|
|
||||||
|
- `Error creating provider: model "openrouter/free" not found in model_list`
|
||||||
|
- OpenRouter returns 400: `"free is not a valid model ID"`
|
||||||
|
|
||||||
|
**Cause:** The `model` field in your `model_list` entry is what gets sent to the API. For OpenRouter you must use the **full** model ID, not a shorthand.
|
||||||
|
|
||||||
|
- **Wrong:** `"model": "free"` → OpenRouter receives `free` and rejects it.
|
||||||
|
- **Right:** `"model": "openrouter/free"` → OpenRouter receives `openrouter/free` (auto free-tier routing).
|
||||||
|
|
||||||
|
**Fix:** In `~/.picoclaw/config.json` (or your config path):
|
||||||
|
|
||||||
|
1. **agents.defaults.model** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`).
|
||||||
|
2. That entry’s **model** must be a valid OpenRouter model ID, for example:
|
||||||
|
- `"openrouter/free"` – auto free-tier
|
||||||
|
- `"google/gemini-2.0-flash-exp:free"`
|
||||||
|
- `"meta-llama/llama-3.1-8b-instruct:free"`
|
||||||
|
|
||||||
|
Example snippet:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "openrouter-free"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "openrouter-free",
|
||||||
|
"model": "openrouter/free",
|
||||||
|
"api_key": "sk-or-v1-YOUR_OPENROUTER_KEY",
|
||||||
|
"api_base": "https://openrouter.ai/api/v1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Get your key at [OpenRouter Keys](https://openrouter.ai/keys).
|
||||||
26
go.mod
26
go.mod
|
|
@ -11,6 +11,7 @@ require (
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
github.com/mymmrac/telego v1.6.0
|
github.com/mymmrac/telego v1.6.0
|
||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||||
github.com/openai/openai-go/v3 v3.22.0
|
github.com/openai/openai-go/v3 v3.22.0
|
||||||
|
|
@ -18,15 +19,40 @@ require (
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/tencent-connect/botgo v0.2.1
|
github.com/tencent-connect/botgo v0.2.1
|
||||||
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
||||||
golang.org/x/oauth2 v0.35.0
|
golang.org/x/oauth2 v0.35.0
|
||||||
|
golang.org/x/time v0.14.0
|
||||||
|
google.golang.org/protobuf v1.36.11
|
||||||
|
modernc.org/sqlite v1.46.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/beeper/argo-go v1.1.2 // indirect
|
||||||
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rs/zerolog v1.34.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||||
|
go.mau.fi/libsignal v0.2.1 // indirect
|
||||||
|
go.mau.fi/util v0.9.6 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||||
|
golang.org/x/term v0.40.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.67.6 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
rsc.io/qr v0.2.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|
|
||||||
102
go.sum
102
go.sum
|
|
@ -1,10 +1,20 @@
|
||||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
||||||
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
||||||
|
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||||
|
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||||
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||||
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0=
|
github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
||||||
|
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
||||||
|
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
|
||||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
|
@ -25,12 +35,19 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
||||||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
|
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||||
|
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
||||||
|
|
@ -42,6 +59,7 @@ github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2m
|
||||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
|
@ -63,6 +81,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
|
@ -72,6 +92,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
||||||
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
|
@ -91,8 +113,21 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||||
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
||||||
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
|
@ -105,13 +140,23 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv
|
||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||||
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
|
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
|
||||||
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
||||||
|
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
|
||||||
|
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
|
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||||
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
||||||
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
|
@ -153,11 +198,19 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy
|
||||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||||
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
||||||
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
|
||||||
|
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
|
||||||
|
go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts=
|
||||||
|
go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI=
|
||||||
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ=
|
||||||
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
|
@ -171,10 +224,14 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
||||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||||
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
|
@ -217,8 +274,11 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
|
@ -227,6 +287,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||||
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
|
@ -234,8 +296,10 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
|
@ -243,6 +307,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|
@ -255,6 +321,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
|
@ -269,3 +337,33 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
|
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||||
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||||
|
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||||
|
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||||
|
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||||
|
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,47 @@ func NewAgentInstance(
|
||||||
Primary: model,
|
Primary: model,
|
||||||
Fallbacks: fallbacks,
|
Fallbacks: fallbacks,
|
||||||
}
|
}
|
||||||
candidates := providers.ResolveCandidates(modelCfg, defaults.Provider)
|
resolveFromModelList := func(raw string) (string, bool) {
|
||||||
|
ensureProtocol := func(model string) string {
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
if model == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.Contains(model, "/") {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
return "openai/" + model
|
||||||
|
}
|
||||||
|
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg != nil {
|
||||||
|
if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
|
||||||
|
return ensureProtocol(mc.Model), true
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
|
||||||
|
if fullModel == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fullModel == raw {
|
||||||
|
return ensureProtocol(fullModel), true
|
||||||
|
}
|
||||||
|
_, modelID := providers.ExtractProtocol(fullModel)
|
||||||
|
if modelID == raw {
|
||||||
|
return ensureProtocol(fullModel), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
|
||||||
|
|
||||||
return &AgentInstance{
|
return &AgentInstance{
|
||||||
ID: agentID,
|
ID: agentID,
|
||||||
|
|
|
||||||
|
|
@ -93,3 +93,77 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
|
||||||
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
|
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "step-3.5-flash",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "step-3.5-flash",
|
||||||
|
Model: "openrouter/stepfun/step-3.5-flash:free",
|
||||||
|
APIBase: "https://openrouter.ai/api/v1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := &mockProvider{}
|
||||||
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
||||||
|
if len(agent.Candidates) != 1 {
|
||||||
|
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Provider != "openrouter" {
|
||||||
|
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openrouter")
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Model != "stepfun/step-3.5-flash:free" {
|
||||||
|
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "stepfun/step-3.5-flash:free")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "glm-5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "glm-5",
|
||||||
|
Model: "glm-5",
|
||||||
|
APIBase: "https://api.z.ai/api/coding/paas/v4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := &mockProvider{}
|
||||||
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
||||||
|
if len(agent.Candidates) != 1 {
|
||||||
|
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Provider != "openai" {
|
||||||
|
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openai")
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Model != "glm-5" {
|
||||||
|
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
@ -21,6 +22,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/constants"
|
"github.com/sipeed/picoclaw/pkg/constants"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/routing"
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
|
@ -38,6 +40,7 @@ type AgentLoop struct {
|
||||||
summarizing sync.Map
|
summarizing sync.Map
|
||||||
fallback *providers.FallbackChain
|
fallback *providers.FallbackChain
|
||||||
channelManager *channels.Manager
|
channelManager *channels.Manager
|
||||||
|
mediaStore media.MediaStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// processOptions configures how a message is processed
|
// processOptions configures how a message is processed
|
||||||
|
|
@ -52,6 +55,8 @@ type processOptions struct {
|
||||||
NoHistory bool // If true, don't load session history (for heartbeat)
|
NoHistory bool // If true, don't load session history (for heartbeat)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
|
||||||
|
|
||||||
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
|
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
|
||||||
registry := NewAgentRegistry(cfg, provider)
|
registry := NewAgentRegistry(cfg, provider)
|
||||||
|
|
||||||
|
|
@ -119,12 +124,13 @@ func registerSharedTools(
|
||||||
// Message tool
|
// Message tool
|
||||||
messageTool := tools.NewMessageTool()
|
messageTool := tools.NewMessageTool()
|
||||||
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
||||||
msgBus.PublishOutbound(bus.OutboundMessage{
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
Content: content,
|
Content: content,
|
||||||
})
|
})
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
agent.Tools.Register(messageTool)
|
agent.Tools.Register(messageTool)
|
||||||
|
|
||||||
|
|
@ -165,6 +171,21 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process message
|
||||||
|
func() {
|
||||||
|
// TODO: Re-enable media cleanup after inbound media is properly consumed by the agent.
|
||||||
|
// Currently disabled because files are deleted before the LLM can access their content.
|
||||||
|
// defer func() {
|
||||||
|
// if al.mediaStore != nil && msg.MediaScope != "" {
|
||||||
|
// if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil {
|
||||||
|
// logger.WarnCF("agent", "Failed to release media", map[string]any{
|
||||||
|
// "scope": msg.MediaScope,
|
||||||
|
// "error": releaseErr.Error(),
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }()
|
||||||
|
|
||||||
response, err := al.processMessage(ctx, msg)
|
response, err := al.processMessage(ctx, msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response = fmt.Sprintf("Error processing message: %v", err)
|
response = fmt.Sprintf("Error processing message: %v", err)
|
||||||
|
|
@ -185,13 +206,26 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if !alreadySent {
|
if !alreadySent {
|
||||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
Channel: msg.Channel,
|
Channel: msg.Channel,
|
||||||
ChatID: msg.ChatID,
|
ChatID: msg.ChatID,
|
||||||
Content: response,
|
Content: response,
|
||||||
})
|
})
|
||||||
|
logger.InfoCF("agent", "Published outbound response",
|
||||||
|
map[string]any{
|
||||||
|
"channel": msg.Channel,
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"content_len": len(response),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.DebugCF(
|
||||||
|
"agent",
|
||||||
|
"Skipped outbound (message tool already sent)",
|
||||||
|
map[string]any{"channel": msg.Channel},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,6 +248,41 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
|
||||||
al.channelManager = cm
|
al.channelManager = cm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMediaStore injects a MediaStore for media lifecycle management.
|
||||||
|
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
|
||||||
|
al.mediaStore = s
|
||||||
|
}
|
||||||
|
|
||||||
|
// inferMediaType determines the media type ("image", "audio", "video", "file")
|
||||||
|
// from a filename and MIME content type.
|
||||||
|
func inferMediaType(filename, contentType string) string {
|
||||||
|
ct := strings.ToLower(contentType)
|
||||||
|
fn := strings.ToLower(filename)
|
||||||
|
|
||||||
|
if strings.HasPrefix(ct, "image/") {
|
||||||
|
return "image"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" {
|
||||||
|
return "audio"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(ct, "video/") {
|
||||||
|
return "video"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: infer from extension
|
||||||
|
ext := filepath.Ext(fn)
|
||||||
|
switch ext {
|
||||||
|
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
|
||||||
|
return "image"
|
||||||
|
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus":
|
||||||
|
return "audio"
|
||||||
|
case ".mp4", ".avi", ".mov", ".webm", ".mkv":
|
||||||
|
return "video"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "file"
|
||||||
|
}
|
||||||
|
|
||||||
// RecordLastChannel records the last active channel for this workspace.
|
// RecordLastChannel records the last active channel for this workspace.
|
||||||
// This uses the atomic state save mechanism to prevent data loss on crash.
|
// This uses the atomic state save mechanism to prevent data loss on crash.
|
||||||
func (al *AgentLoop) RecordLastChannel(channel string) error {
|
func (al *AgentLoop) RecordLastChannel(channel string) error {
|
||||||
|
|
@ -255,12 +324,15 @@ func (al *AgentLoop) ProcessDirectWithChannel(
|
||||||
// Each heartbeat is independent and doesn't accumulate context.
|
// Each heartbeat is independent and doesn't accumulate context.
|
||||||
func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) {
|
func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) {
|
||||||
agent := al.registry.GetDefaultAgent()
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
return "", fmt.Errorf("no default agent for heartbeat")
|
||||||
|
}
|
||||||
return al.runAgentLoop(ctx, agent, processOptions{
|
return al.runAgentLoop(ctx, agent, processOptions{
|
||||||
SessionKey: "heartbeat",
|
SessionKey: "heartbeat",
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
UserMessage: content,
|
UserMessage: content,
|
||||||
DefaultResponse: "I've completed processing but have no response to give.",
|
DefaultResponse: defaultResponse,
|
||||||
EnableSummary: false,
|
EnableSummary: false,
|
||||||
SendResponse: false,
|
SendResponse: false,
|
||||||
NoHistory: true, // Don't load session history for heartbeat
|
NoHistory: true, // Don't load session history for heartbeat
|
||||||
|
|
@ -307,6 +379,16 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
if !ok {
|
if !ok {
|
||||||
agent = al.registry.GetDefaultAgent()
|
agent = al.registry.GetDefaultAgent()
|
||||||
}
|
}
|
||||||
|
if agent == nil {
|
||||||
|
return "", fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
|
||||||
|
if tool, ok := agent.Tools.Get("message"); ok {
|
||||||
|
if mt, ok := tool.(tools.ContextualTool); ok {
|
||||||
|
mt.SetContext(msg.Channel, msg.ChatID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Use routed session key, but honor pre-set agent-scoped keys (for ProcessDirect/cron)
|
// Use routed session key, but honor pre-set agent-scoped keys (for ProcessDirect/cron)
|
||||||
sessionKey := route.SessionKey
|
sessionKey := route.SessionKey
|
||||||
|
|
@ -326,7 +408,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
Channel: msg.Channel,
|
Channel: msg.Channel,
|
||||||
ChatID: msg.ChatID,
|
ChatID: msg.ChatID,
|
||||||
UserMessage: msg.Content,
|
UserMessage: msg.Content,
|
||||||
DefaultResponse: "I've completed processing but have no response to give.",
|
DefaultResponse: defaultResponse,
|
||||||
EnableSummary: true,
|
EnableSummary: true,
|
||||||
SendResponse: false,
|
SendResponse: false,
|
||||||
})
|
})
|
||||||
|
|
@ -373,6 +455,9 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
|
||||||
|
|
||||||
// Use default agent for system messages
|
// Use default agent for system messages
|
||||||
agent := al.registry.GetDefaultAgent()
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
return "", fmt.Errorf("no default agent for system message")
|
||||||
|
}
|
||||||
|
|
||||||
// Use the origin session for context
|
// Use the origin session for context
|
||||||
sessionKey := routing.BuildAgentMainSessionKey(agent.ID)
|
sessionKey := routing.BuildAgentMainSessionKey(agent.ID)
|
||||||
|
|
@ -448,7 +533,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
|
|
||||||
// 8. Optional: send response via bus
|
// 8. Optional: send response via bus
|
||||||
if opts.SendResponse {
|
if opts.SendResponse {
|
||||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
Channel: opts.Channel,
|
Channel: opts.Channel,
|
||||||
ChatID: opts.ChatID,
|
ChatID: opts.ChatID,
|
||||||
Content: finalContent,
|
Content: finalContent,
|
||||||
|
|
@ -468,6 +553,34 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
return finalContent, nil
|
return finalContent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) {
|
||||||
|
if al.channelManager == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if ch, ok := al.channelManager.GetChannel(channelName); ok {
|
||||||
|
return ch.ReasoningChannelID()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, channelName, channelID string) {
|
||||||
|
if reasoningContent == "" || channelName == "" || channelID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check context cancellation before attempting to publish,
|
||||||
|
// since PublishOutbound's select may race between send and ctx.Done().
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
|
Channel: channelName,
|
||||||
|
ChatID: channelID,
|
||||||
|
Content: reasoningContent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// runLLMIteration executes the LLM call loop with tool handling.
|
// runLLMIteration executes the LLM call loop with tool handling.
|
||||||
func (al *AgentLoop) runLLMIteration(
|
func (al *AgentLoop) runLLMIteration(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|
@ -565,7 +678,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
})
|
})
|
||||||
|
|
||||||
if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
|
if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
|
||||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
Channel: opts.Channel,
|
Channel: opts.Channel,
|
||||||
ChatID: opts.ChatID,
|
ChatID: opts.ChatID,
|
||||||
Content: "Context window exceeded. Compressing history and retrying...",
|
Content: "Context window exceeded. Compressing history and retrying...",
|
||||||
|
|
@ -594,6 +707,18 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
|
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel))
|
||||||
|
|
||||||
|
logger.DebugCF("agent", "LLM response",
|
||||||
|
map[string]any{
|
||||||
|
"agent_id": agent.ID,
|
||||||
|
"iteration": iteration,
|
||||||
|
"content_chars": len(response.Content),
|
||||||
|
"tool_calls": len(response.ToolCalls),
|
||||||
|
"reasoning": response.Reasoning,
|
||||||
|
"target_channel": al.targetReasoningChannelID(opts.Channel),
|
||||||
|
"channel": opts.Channel,
|
||||||
|
})
|
||||||
// Check if no tool calls - we're done
|
// Check if no tool calls - we're done
|
||||||
if len(response.ToolCalls) == 0 {
|
if len(response.ToolCalls) == 0 {
|
||||||
finalContent = response.Content
|
finalContent = response.Content
|
||||||
|
|
@ -695,7 +820,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
|
|
||||||
// Send ForUser content to user immediately if not Silent
|
// Send ForUser content to user immediately if not Silent
|
||||||
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
|
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
|
||||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
Channel: opts.Channel,
|
Channel: opts.Channel,
|
||||||
ChatID: opts.ChatID,
|
ChatID: opts.ChatID,
|
||||||
Content: toolResult.ForUser,
|
Content: toolResult.ForUser,
|
||||||
|
|
@ -707,6 +832,28 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If tool returned media refs, publish them as outbound media
|
||||||
|
if len(toolResult.Media) > 0 && opts.SendResponse {
|
||||||
|
parts := make([]bus.MediaPart, 0, len(toolResult.Media))
|
||||||
|
for _, ref := range toolResult.Media {
|
||||||
|
part := bus.MediaPart{Ref: ref}
|
||||||
|
// Populate metadata from MediaStore when available
|
||||||
|
if al.mediaStore != nil {
|
||||||
|
if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil {
|
||||||
|
part.Filename = meta.Filename
|
||||||
|
part.ContentType = meta.ContentType
|
||||||
|
part.Type = inferMediaType(meta.Filename, meta.ContentType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{
|
||||||
|
Channel: opts.Channel,
|
||||||
|
ChatID: opts.ChatID,
|
||||||
|
Parts: parts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Determine content for LLM based on tool result
|
// Determine content for LLM based on tool result
|
||||||
contentForLLM := toolResult.ForLLM
|
contentForLLM := toolResult.ForLLM
|
||||||
if contentForLLM == "" && toolResult.Err != nil {
|
if contentForLLM == "" && toolResult.Err != nil {
|
||||||
|
|
@ -1119,21 +1266,20 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractPeer extracts the routing peer from inbound message metadata.
|
// extractPeer extracts the routing peer from the inbound message's structured Peer field.
|
||||||
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||||
peerKind := msg.Metadata["peer_kind"]
|
if msg.Peer.Kind == "" {
|
||||||
if peerKind == "" {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
peerID := msg.Metadata["peer_id"]
|
peerID := msg.Peer.ID
|
||||||
if peerID == "" {
|
if peerID == "" {
|
||||||
if peerKind == "direct" {
|
if msg.Peer.Kind == "direct" {
|
||||||
peerID = msg.SenderID
|
peerID = msg.SenderID
|
||||||
} else {
|
} else {
|
||||||
peerID = msg.ChatID
|
peerID = msg.ChatID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &routing.RoutePeer{Kind: peerKind, ID: peerID}
|
return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
|
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,23 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type fakeChannel struct{ id string }
|
||||||
|
|
||||||
|
func (f *fakeChannel) Name() string { return "fake" }
|
||||||
|
func (f *fakeChannel) Start(ctx context.Context) error { return nil }
|
||||||
|
func (f *fakeChannel) Stop(ctx context.Context) error { return nil }
|
||||||
|
func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil }
|
||||||
|
func (f *fakeChannel) IsRunning() bool { return true }
|
||||||
|
func (f *fakeChannel) IsAllowed(string) bool { return true }
|
||||||
|
func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true }
|
||||||
|
func (f *fakeChannel) ReasoningChannelID() string { return f.id }
|
||||||
|
|
||||||
func TestRecordLastChannel(t *testing.T) {
|
func TestRecordLastChannel(t *testing.T) {
|
||||||
// Create temp workspace
|
// Create temp workspace
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
|
@ -620,3 +632,158 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
||||||
t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory))
|
t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{})
|
||||||
|
chManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create channel manager: %v", err)
|
||||||
|
}
|
||||||
|
for name, id := range map[string]string{
|
||||||
|
"whatsapp": "rid-whatsapp",
|
||||||
|
"telegram": "rid-telegram",
|
||||||
|
"feishu": "rid-feishu",
|
||||||
|
"discord": "rid-discord",
|
||||||
|
"maixcam": "rid-maixcam",
|
||||||
|
"qq": "rid-qq",
|
||||||
|
"dingtalk": "rid-dingtalk",
|
||||||
|
"slack": "rid-slack",
|
||||||
|
"line": "rid-line",
|
||||||
|
"onebot": "rid-onebot",
|
||||||
|
"wecom": "rid-wecom",
|
||||||
|
"wecom_app": "rid-wecom-app",
|
||||||
|
} {
|
||||||
|
chManager.RegisterChannel(name, &fakeChannel{id: id})
|
||||||
|
}
|
||||||
|
al.SetChannelManager(chManager)
|
||||||
|
tests := []struct {
|
||||||
|
channel string
|
||||||
|
wantID string
|
||||||
|
}{
|
||||||
|
{channel: "whatsapp", wantID: "rid-whatsapp"},
|
||||||
|
{channel: "telegram", wantID: "rid-telegram"},
|
||||||
|
{channel: "feishu", wantID: "rid-feishu"},
|
||||||
|
{channel: "discord", wantID: "rid-discord"},
|
||||||
|
{channel: "maixcam", wantID: "rid-maixcam"},
|
||||||
|
{channel: "qq", wantID: "rid-qq"},
|
||||||
|
{channel: "dingtalk", wantID: "rid-dingtalk"},
|
||||||
|
{channel: "slack", wantID: "rid-slack"},
|
||||||
|
{channel: "line", wantID: "rid-line"},
|
||||||
|
{channel: "onebot", wantID: "rid-onebot"},
|
||||||
|
{channel: "wecom", wantID: "rid-wecom"},
|
||||||
|
{channel: "wecom_app", wantID: "rid-wecom-app"},
|
||||||
|
{channel: "unknown", wantID: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.channel, func(t *testing.T) {
|
||||||
|
got := al.targetReasoningChannelID(tt.channel)
|
||||||
|
if got != tt.wantID {
|
||||||
|
t.Fatalf("targetReasoningChannelID(%q) = %q, want %q", tt.channel, got, tt.wantID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleReasoning(t *testing.T) {
|
||||||
|
newLoop := func(t *testing.T) (*AgentLoop, *bus.MessageBus) {
|
||||||
|
t.Helper()
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
return NewAgentLoop(cfg, msgBus, &mockProvider{}), msgBus
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("skips when any required field is empty", func(t *testing.T) {
|
||||||
|
al, msgBus := newLoop(t)
|
||||||
|
al.handleReasoning(context.Background(), "reasoning", "telegram", "")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if msg, ok := msgBus.SubscribeOutbound(ctx); ok {
|
||||||
|
t.Fatalf("expected no outbound message, got %+v", msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("publishes one message for non telegram", func(t *testing.T) {
|
||||||
|
al, msgBus := newLoop(t)
|
||||||
|
al.handleReasoning(context.Background(), "hello reasoning", "slack", "channel-1")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
msg, ok := msgBus.SubscribeOutbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected an outbound message")
|
||||||
|
}
|
||||||
|
if msg.Channel != "slack" || msg.ChatID != "channel-1" || msg.Content != "hello reasoning" {
|
||||||
|
t.Fatalf("unexpected outbound message: %+v", msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("publishes one message for telegram", func(t *testing.T) {
|
||||||
|
al, msgBus := newLoop(t)
|
||||||
|
reasoning := "hello telegram reasoning"
|
||||||
|
al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
msg, ok := msgBus.SubscribeOutbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected outbound message")
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Channel != "telegram" {
|
||||||
|
t.Fatalf("expected telegram channel message, got %+v", msg)
|
||||||
|
}
|
||||||
|
if msg.ChatID != "tg-chat" {
|
||||||
|
t.Fatalf("expected chatID tg-chat, got %+v", msg)
|
||||||
|
}
|
||||||
|
if msg.Content != reasoning {
|
||||||
|
t.Fatalf("content mismatch: got %q want %q", msg.Content, reasoning)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("expired ctx", func(t *testing.T) {
|
||||||
|
al, msgBus := newLoop(t)
|
||||||
|
reasoning := "hello telegram reasoning"
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
al.handleReasoning(ctx, reasoning, "telegram", "tg-chat")
|
||||||
|
|
||||||
|
ctx, cancel = context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
msg, ok := msgBus.SubscribeOutbound(ctx)
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("expected no outbound message, got %+v", msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
153
pkg/bus/bus.go
153
pkg/bus/bus.go
|
|
@ -2,81 +2,156 @@ package bus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sync"
|
"errors"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrBusClosed is returned when publishing to a closed MessageBus.
|
||||||
|
var ErrBusClosed = errors.New("message bus closed")
|
||||||
|
|
||||||
|
const defaultBusBufferSize = 64
|
||||||
|
|
||||||
type MessageBus struct {
|
type MessageBus struct {
|
||||||
inbound chan InboundMessage
|
inbound chan InboundMessage
|
||||||
outbound chan OutboundMessage
|
outbound chan OutboundMessage
|
||||||
handlers map[string]MessageHandler
|
outboundMedia chan OutboundMediaMessage
|
||||||
closed bool
|
done chan struct{}
|
||||||
mu sync.RWMutex
|
closed atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMessageBus() *MessageBus {
|
func NewMessageBus() *MessageBus {
|
||||||
return &MessageBus{
|
return &MessageBus{
|
||||||
inbound: make(chan InboundMessage, 100),
|
inbound: make(chan InboundMessage, defaultBusBufferSize),
|
||||||
outbound: make(chan OutboundMessage, 100),
|
outbound: make(chan OutboundMessage, defaultBusBufferSize),
|
||||||
handlers: make(map[string]MessageHandler),
|
outboundMedia: make(chan OutboundMediaMessage, defaultBusBufferSize),
|
||||||
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) PublishInbound(msg InboundMessage) {
|
func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error {
|
||||||
mb.mu.RLock()
|
if mb.closed.Load() {
|
||||||
defer mb.mu.RUnlock()
|
return ErrBusClosed
|
||||||
if mb.closed {
|
}
|
||||||
return
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case mb.inbound <- msg:
|
||||||
|
return nil
|
||||||
|
case <-mb.done:
|
||||||
|
return ErrBusClosed
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
mb.inbound <- msg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) {
|
func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) {
|
||||||
select {
|
select {
|
||||||
case msg := <-mb.inbound:
|
case msg, ok := <-mb.inbound:
|
||||||
return msg, true
|
return msg, ok
|
||||||
|
case <-mb.done:
|
||||||
|
return InboundMessage{}, false
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return InboundMessage{}, false
|
return InboundMessage{}, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) PublishOutbound(msg OutboundMessage) {
|
func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error {
|
||||||
mb.mu.RLock()
|
if mb.closed.Load() {
|
||||||
defer mb.mu.RUnlock()
|
return ErrBusClosed
|
||||||
if mb.closed {
|
}
|
||||||
return
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case mb.outbound <- msg:
|
||||||
|
return nil
|
||||||
|
case <-mb.done:
|
||||||
|
return ErrBusClosed
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
mb.outbound <- msg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) {
|
func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) {
|
||||||
select {
|
select {
|
||||||
case msg := <-mb.outbound:
|
case msg, ok := <-mb.outbound:
|
||||||
return msg, true
|
return msg, ok
|
||||||
|
case <-mb.done:
|
||||||
|
return OutboundMessage{}, false
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return OutboundMessage{}, false
|
return OutboundMessage{}, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) RegisterHandler(channel string, handler MessageHandler) {
|
func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error {
|
||||||
mb.mu.Lock()
|
if mb.closed.Load() {
|
||||||
defer mb.mu.Unlock()
|
return ErrBusClosed
|
||||||
mb.handlers[channel] = handler
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case mb.outboundMedia <- msg:
|
||||||
|
return nil
|
||||||
|
case <-mb.done:
|
||||||
|
return ErrBusClosed
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) GetHandler(channel string) (MessageHandler, bool) {
|
func (mb *MessageBus) SubscribeOutboundMedia(ctx context.Context) (OutboundMediaMessage, bool) {
|
||||||
mb.mu.RLock()
|
select {
|
||||||
defer mb.mu.RUnlock()
|
case msg, ok := <-mb.outboundMedia:
|
||||||
handler, ok := mb.handlers[channel]
|
return msg, ok
|
||||||
return handler, ok
|
case <-mb.done:
|
||||||
|
return OutboundMediaMessage{}, false
|
||||||
|
case <-ctx.Done():
|
||||||
|
return OutboundMediaMessage{}, false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) Close() {
|
func (mb *MessageBus) Close() {
|
||||||
mb.mu.Lock()
|
if mb.closed.CompareAndSwap(false, true) {
|
||||||
defer mb.mu.Unlock()
|
close(mb.done)
|
||||||
if mb.closed {
|
|
||||||
return
|
// Drain buffered channels so messages aren't silently lost.
|
||||||
|
// Channels are NOT closed to avoid send-on-closed panics from concurrent publishers.
|
||||||
|
drained := 0
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-mb.inbound:
|
||||||
|
drained++
|
||||||
|
default:
|
||||||
|
goto doneInbound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doneInbound:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-mb.outbound:
|
||||||
|
drained++
|
||||||
|
default:
|
||||||
|
goto doneOutbound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doneOutbound:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-mb.outboundMedia:
|
||||||
|
drained++
|
||||||
|
default:
|
||||||
|
goto doneMedia
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doneMedia:
|
||||||
|
if drained > 0 {
|
||||||
|
logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{
|
||||||
|
"count": drained,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
mb.closed = true
|
|
||||||
close(mb.inbound)
|
|
||||||
close(mb.outbound)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
229
pkg/bus/bus_test.go
Normal file
229
pkg/bus/bus_test.go
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
package bus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublishConsume(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
msg := InboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "hello",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mb.PublishInbound(ctx, msg); err != nil {
|
||||||
|
t.Fatalf("PublishInbound failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := mb.ConsumeInbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ConsumeInbound returned ok=false")
|
||||||
|
}
|
||||||
|
if got.Content != "hello" {
|
||||||
|
t.Fatalf("expected content 'hello', got %q", got.Content)
|
||||||
|
}
|
||||||
|
if got.Channel != "test" {
|
||||||
|
t.Fatalf("expected channel 'test', got %q", got.Channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishOutboundSubscribe(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
msg := OutboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
ChatID: "123",
|
||||||
|
Content: "world",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mb.PublishOutbound(ctx, msg); err != nil {
|
||||||
|
t.Fatalf("PublishOutbound failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := mb.SubscribeOutbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("SubscribeOutbound returned ok=false")
|
||||||
|
}
|
||||||
|
if got.Content != "world" {
|
||||||
|
t.Fatalf("expected content 'world', got %q", got.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishInbound_ContextCancel(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
// Fill the buffer
|
||||||
|
ctx := context.Background()
|
||||||
|
for i := range defaultBusBufferSize {
|
||||||
|
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
|
||||||
|
t.Fatalf("fill failed at %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now buffer is full; publish with a canceled context
|
||||||
|
cancelCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err := mb.PublishInbound(cancelCtx, InboundMessage{Content: "overflow"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error from canceled context, got nil")
|
||||||
|
}
|
||||||
|
if err != context.Canceled {
|
||||||
|
t.Fatalf("expected context.Canceled, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishInbound_BusClosed(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"})
|
||||||
|
if err != ErrBusClosed {
|
||||||
|
t.Fatalf("expected ErrBusClosed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishOutbound_BusClosed(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
err := mb.PublishOutbound(context.Background(), OutboundMessage{Content: "test"})
|
||||||
|
if err != ErrBusClosed {
|
||||||
|
t.Fatalf("expected ErrBusClosed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeInbound_ContextCancel(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, ok := mb.ConsumeInbound(ctx)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected ok=false when context is canceled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeInbound_BusClosed(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, ok := mb.ConsumeInbound(ctx)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected ok=false when bus is closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscribeOutbound_BusClosed(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, ok := mb.SubscribeOutbound(ctx)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected ok=false when bus is closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentPublishClose(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const numGoroutines = 100
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(numGoroutines + 1)
|
||||||
|
|
||||||
|
// Spawn many goroutines trying to publish
|
||||||
|
for range numGoroutines {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
// Use a short timeout context so we don't block forever after close
|
||||||
|
publishCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
// Errors are expected; we just must not panic or deadlock
|
||||||
|
_ = mb.PublishInbound(publishCtx, InboundMessage{Content: "concurrent"})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close from another goroutine
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
mb.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Must complete without deadlock
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// success
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("test timed out - possible deadlock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishInbound_FullBuffer(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Fill the buffer
|
||||||
|
for i := range defaultBusBufferSize {
|
||||||
|
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
|
||||||
|
t.Fatalf("fill failed at %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer is full; publish with short timeout
|
||||||
|
timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := mb.PublishInbound(timeoutCtx, InboundMessage{Content: "overflow"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when buffer is full and context times out")
|
||||||
|
}
|
||||||
|
if err != context.DeadlineExceeded {
|
||||||
|
t.Fatalf("expected context.DeadlineExceeded, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseIdempotent(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
|
||||||
|
// Multiple Close calls must not panic
|
||||||
|
mb.Close()
|
||||||
|
mb.Close()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
// After close, publish should return ErrBusClosed
|
||||||
|
err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"})
|
||||||
|
if err != ErrBusClosed {
|
||||||
|
t.Fatalf("expected ErrBusClosed after multiple closes, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,30 @@
|
||||||
package bus
|
package bus
|
||||||
|
|
||||||
|
// Peer identifies the routing peer for a message (direct, group, channel, etc.)
|
||||||
|
type Peer struct {
|
||||||
|
Kind string `json:"kind"` // "direct" | "group" | "channel" | ""
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SenderInfo provides structured sender identity information.
|
||||||
|
type SenderInfo struct {
|
||||||
|
Platform string `json:"platform,omitempty"` // "telegram", "discord", "slack", ...
|
||||||
|
PlatformID string `json:"platform_id,omitempty"` // raw platform ID, e.g. "123456"
|
||||||
|
CanonicalID string `json:"canonical_id,omitempty"` // "platform:id" format
|
||||||
|
Username string `json:"username,omitempty"` // username (e.g. @alice)
|
||||||
|
DisplayName string `json:"display_name,omitempty"` // display name
|
||||||
|
}
|
||||||
|
|
||||||
type InboundMessage struct {
|
type InboundMessage struct {
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
SenderID string `json:"sender_id"`
|
SenderID string `json:"sender_id"`
|
||||||
|
Sender SenderInfo `json:"sender"`
|
||||||
ChatID string `json:"chat_id"`
|
ChatID string `json:"chat_id"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Media []string `json:"media,omitempty"`
|
Media []string `json:"media,omitempty"`
|
||||||
|
Peer Peer `json:"peer"` // routing peer
|
||||||
|
MessageID string `json:"message_id,omitempty"` // platform message ID
|
||||||
|
MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope
|
||||||
SessionKey string `json:"session_key"`
|
SessionKey string `json:"session_key"`
|
||||||
Metadata map[string]string `json:"metadata,omitempty"`
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
@ -16,4 +35,18 @@ type OutboundMessage struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageHandler func(InboundMessage) error
|
// MediaPart describes a single media attachment to send.
|
||||||
|
type MediaPart struct {
|
||||||
|
Type string `json:"type"` // "image" | "audio" | "video" | "file"
|
||||||
|
Ref string `json:"ref"` // media store ref, e.g. "media://abc123"
|
||||||
|
Caption string `json:"caption,omitempty"` // optional caption text
|
||||||
|
Filename string `json:"filename,omitempty"` // original filename hint
|
||||||
|
ContentType string `json:"content_type,omitempty"` // MIME type hint
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutboundMediaMessage carries media attachments from Agent to channels via the bus.
|
||||||
|
type OutboundMediaMessage struct {
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
Parts []MediaPart `json:"parts"`
|
||||||
|
}
|
||||||
|
|
|
||||||
1331
pkg/channels/README.md
Normal file
1331
pkg/channels/README.md
Normal file
File diff suppressed because it is too large
Load diff
1331
pkg/channels/README.zh.md
Normal file
1331
pkg/channels/README.zh.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,11 +2,44 @@ package channels
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
uniqueIDCounter uint64
|
||||||
|
uniqueIDPrefix string
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// One-time read from crypto/rand for a unique prefix (single syscall).
|
||||||
|
var b [8]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
// fallback to time-based prefix
|
||||||
|
binary.BigEndian.PutUint64(b[:], uint64(time.Now().UnixNano()))
|
||||||
|
}
|
||||||
|
uniqueIDPrefix = hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniqueID generates a process-unique ID using a random prefix and an atomic counter.
|
||||||
|
// This ID is intended for internal correlation (e.g. media scope keys) and is NOT
|
||||||
|
// cryptographically secure — it must not be used in contexts where unpredictability matters.
|
||||||
|
func uniqueID() string {
|
||||||
|
n := atomic.AddUint64(&uniqueIDCounter, 1)
|
||||||
|
return uniqueIDPrefix + strconv.FormatUint(n, 16)
|
||||||
|
}
|
||||||
|
|
||||||
type Channel interface {
|
type Channel interface {
|
||||||
Name() string
|
Name() string
|
||||||
Start(ctx context.Context) error
|
Start(ctx context.Context) error
|
||||||
|
|
@ -14,32 +47,126 @@ type Channel interface {
|
||||||
Send(ctx context.Context, msg bus.OutboundMessage) error
|
Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
IsRunning() bool
|
IsRunning() bool
|
||||||
IsAllowed(senderID string) bool
|
IsAllowed(senderID string) bool
|
||||||
|
IsAllowedSender(sender bus.SenderInfo) bool
|
||||||
|
ReasoningChannelID() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseChannelOption is a functional option for configuring a BaseChannel.
|
||||||
|
type BaseChannelOption func(*BaseChannel)
|
||||||
|
|
||||||
|
// WithMaxMessageLength sets the maximum message length (in runes) for a channel.
|
||||||
|
// Messages exceeding this limit will be automatically split by the Manager.
|
||||||
|
// A value of 0 means no limit.
|
||||||
|
func WithMaxMessageLength(n int) BaseChannelOption {
|
||||||
|
return func(c *BaseChannel) { c.maxMessageLength = n }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithGroupTrigger sets the group trigger configuration for a channel.
|
||||||
|
func WithGroupTrigger(gt config.GroupTriggerConfig) BaseChannelOption {
|
||||||
|
return func(c *BaseChannel) { c.groupTrigger = gt }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithReasoningChannelID sets the reasoning channel ID where thoughts should be sent.
|
||||||
|
func WithReasoningChannelID(id string) BaseChannelOption {
|
||||||
|
return func(c *BaseChannel) { c.reasoningChannelID = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageLengthProvider is an opt-in interface that channels implement
|
||||||
|
// to advertise their maximum message length. The Manager uses this via
|
||||||
|
// type assertion to decide whether to split outbound messages.
|
||||||
|
type MessageLengthProvider interface {
|
||||||
|
MaxMessageLength() int
|
||||||
}
|
}
|
||||||
|
|
||||||
type BaseChannel struct {
|
type BaseChannel struct {
|
||||||
config any
|
config any
|
||||||
bus *bus.MessageBus
|
bus *bus.MessageBus
|
||||||
running bool
|
running atomic.Bool
|
||||||
name string
|
name string
|
||||||
allowList []string
|
allowList []string
|
||||||
|
maxMessageLength int
|
||||||
|
groupTrigger config.GroupTriggerConfig
|
||||||
|
mediaStore media.MediaStore
|
||||||
|
placeholderRecorder PlaceholderRecorder
|
||||||
|
owner Channel // the concrete channel that embeds this BaseChannel
|
||||||
|
reasoningChannelID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBaseChannel(name string, config any, bus *bus.MessageBus, allowList []string) *BaseChannel {
|
func NewBaseChannel(
|
||||||
return &BaseChannel{
|
name string,
|
||||||
|
config any,
|
||||||
|
bus *bus.MessageBus,
|
||||||
|
allowList []string,
|
||||||
|
opts ...BaseChannelOption,
|
||||||
|
) *BaseChannel {
|
||||||
|
bc := &BaseChannel{
|
||||||
config: config,
|
config: config,
|
||||||
bus: bus,
|
bus: bus,
|
||||||
name: name,
|
name: name,
|
||||||
allowList: allowList,
|
allowList: allowList,
|
||||||
running: false,
|
|
||||||
}
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(bc)
|
||||||
|
}
|
||||||
|
return bc
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxMessageLength returns the maximum message length (in runes) for this channel.
|
||||||
|
// A value of 0 means no limit.
|
||||||
|
func (c *BaseChannel) MaxMessageLength() int {
|
||||||
|
return c.maxMessageLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldRespondInGroup determines whether the bot should respond in a group chat.
|
||||||
|
// Each channel is responsible for:
|
||||||
|
// 1. Detecting isMentioned (platform-specific)
|
||||||
|
// 2. Stripping bot mention from content (platform-specific)
|
||||||
|
// 3. Calling this method to get the group response decision
|
||||||
|
//
|
||||||
|
// Logic:
|
||||||
|
// - If isMentioned → always respond
|
||||||
|
// - If mention_only configured and not mentioned → ignore
|
||||||
|
// - If prefixes configured → respond if content starts with any prefix (strip it)
|
||||||
|
// - If prefixes configured but no match and not mentioned → ignore
|
||||||
|
// - Otherwise (no group_trigger configured) → respond to all (permissive default)
|
||||||
|
func (c *BaseChannel) ShouldRespondInGroup(isMentioned bool, content string) (bool, string) {
|
||||||
|
gt := c.groupTrigger
|
||||||
|
|
||||||
|
// Mentioned → always respond
|
||||||
|
if isMentioned {
|
||||||
|
return true, strings.TrimSpace(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mention_only → require mention
|
||||||
|
if gt.MentionOnly {
|
||||||
|
return false, content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix matching
|
||||||
|
if len(gt.Prefixes) > 0 {
|
||||||
|
for _, prefix := range gt.Prefixes {
|
||||||
|
if prefix != "" && strings.HasPrefix(content, prefix) {
|
||||||
|
return true, strings.TrimSpace(strings.TrimPrefix(content, prefix))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Prefixes configured but none matched and not mentioned → ignore
|
||||||
|
return false, content
|
||||||
|
}
|
||||||
|
|
||||||
|
// No group_trigger configured → permissive (respond to all)
|
||||||
|
return true, strings.TrimSpace(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *BaseChannel) Name() string {
|
func (c *BaseChannel) Name() string {
|
||||||
return c.name
|
return c.name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *BaseChannel) ReasoningChannelID() string {
|
||||||
|
return c.reasoningChannelID
|
||||||
|
}
|
||||||
|
|
||||||
func (c *BaseChannel) IsRunning() bool {
|
func (c *BaseChannel) IsRunning() bool {
|
||||||
return c.running
|
return c.running.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *BaseChannel) IsAllowed(senderID string) bool {
|
func (c *BaseChannel) IsAllowed(senderID string) bool {
|
||||||
|
|
@ -81,23 +208,130 @@ func (c *BaseChannel) IsAllowed(senderID string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []string, metadata map[string]string) {
|
// IsAllowedSender checks whether a structured SenderInfo is permitted by the allow-list.
|
||||||
|
// It delegates to identity.MatchAllowed for each entry, providing unified matching
|
||||||
|
// across all legacy formats and the new canonical "platform:id" format.
|
||||||
|
func (c *BaseChannel) IsAllowedSender(sender bus.SenderInfo) bool {
|
||||||
|
if len(c.allowList) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, allowed := range c.allowList {
|
||||||
|
if identity.MatchAllowed(sender, allowed) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *BaseChannel) HandleMessage(
|
||||||
|
ctx context.Context,
|
||||||
|
peer bus.Peer,
|
||||||
|
messageID, senderID, chatID, content string,
|
||||||
|
media []string,
|
||||||
|
metadata map[string]string,
|
||||||
|
senderOpts ...bus.SenderInfo,
|
||||||
|
) {
|
||||||
|
// Use SenderInfo-based allow check when available, else fall back to string
|
||||||
|
var sender bus.SenderInfo
|
||||||
|
if len(senderOpts) > 0 {
|
||||||
|
sender = senderOpts[0]
|
||||||
|
}
|
||||||
|
if sender.CanonicalID != "" || sender.PlatformID != "" {
|
||||||
|
if !c.IsAllowedSender(sender) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if !c.IsAllowed(senderID) {
|
if !c.IsAllowed(senderID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set SenderID to canonical if available, otherwise keep the raw senderID
|
||||||
|
resolvedSenderID := senderID
|
||||||
|
if sender.CanonicalID != "" {
|
||||||
|
resolvedSenderID = sender.CanonicalID
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := BuildMediaScope(c.name, chatID, messageID)
|
||||||
|
|
||||||
msg := bus.InboundMessage{
|
msg := bus.InboundMessage{
|
||||||
Channel: c.name,
|
Channel: c.name,
|
||||||
SenderID: senderID,
|
SenderID: resolvedSenderID,
|
||||||
|
Sender: sender,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
Content: content,
|
Content: content,
|
||||||
Media: media,
|
Media: media,
|
||||||
|
Peer: peer,
|
||||||
|
MessageID: messageID,
|
||||||
|
MediaScope: scope,
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.bus.PublishInbound(msg)
|
// Auto-trigger typing indicator, message reaction, and placeholder before publishing.
|
||||||
|
// Each capability is independent — all three may fire for the same message.
|
||||||
|
if c.owner != nil && c.placeholderRecorder != nil {
|
||||||
|
// Typing — independent pipeline
|
||||||
|
if tc, ok := c.owner.(TypingCapable); ok {
|
||||||
|
if stop, err := tc.StartTyping(ctx, chatID); err == nil {
|
||||||
|
c.placeholderRecorder.RecordTypingStop(c.name, chatID, stop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reaction — independent pipeline
|
||||||
|
if rc, ok := c.owner.(ReactionCapable); ok && messageID != "" {
|
||||||
|
if undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil {
|
||||||
|
c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Placeholder — independent pipeline
|
||||||
|
if pc, ok := c.owner.(PlaceholderCapable); ok {
|
||||||
|
if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" {
|
||||||
|
c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.bus.PublishInbound(ctx, msg); err != nil {
|
||||||
|
logger.ErrorCF("channels", "Failed to publish inbound message", map[string]any{
|
||||||
|
"channel": c.name,
|
||||||
|
"chat_id": chatID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *BaseChannel) setRunning(running bool) {
|
func (c *BaseChannel) SetRunning(running bool) {
|
||||||
c.running = running
|
c.running.Store(running)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMediaStore injects a MediaStore into the channel.
|
||||||
|
func (c *BaseChannel) SetMediaStore(s media.MediaStore) { c.mediaStore = s }
|
||||||
|
|
||||||
|
// GetMediaStore returns the injected MediaStore (may be nil).
|
||||||
|
func (c *BaseChannel) GetMediaStore() media.MediaStore { return c.mediaStore }
|
||||||
|
|
||||||
|
// SetPlaceholderRecorder injects a PlaceholderRecorder into the channel.
|
||||||
|
func (c *BaseChannel) SetPlaceholderRecorder(r PlaceholderRecorder) {
|
||||||
|
c.placeholderRecorder = r
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlaceholderRecorder returns the injected PlaceholderRecorder (may be nil).
|
||||||
|
func (c *BaseChannel) GetPlaceholderRecorder() PlaceholderRecorder {
|
||||||
|
return c.placeholderRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOwner injects the concrete channel that embeds this BaseChannel.
|
||||||
|
// This allows HandleMessage to auto-trigger TypingCapable / ReactionCapable / PlaceholderCapable.
|
||||||
|
func (c *BaseChannel) SetOwner(ch Channel) {
|
||||||
|
c.owner = ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildMediaScope constructs a scope key for media lifecycle tracking.
|
||||||
|
func BuildMediaScope(channel, chatID, messageID string) string {
|
||||||
|
id := messageID
|
||||||
|
if id == "" {
|
||||||
|
id = uniqueID()
|
||||||
|
}
|
||||||
|
return channel + ":" + chatID + ":" + id
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
package channels
|
package channels
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
func TestBaseChannelIsAllowed(t *testing.T) {
|
func TestBaseChannelIsAllowed(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|
@ -50,3 +55,211 @@ func TestBaseChannelIsAllowed(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShouldRespondInGroup(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
gt config.GroupTriggerConfig
|
||||||
|
isMentioned bool
|
||||||
|
content string
|
||||||
|
wantRespond bool
|
||||||
|
wantContent string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no config - permissive default",
|
||||||
|
gt: config.GroupTriggerConfig{},
|
||||||
|
isMentioned: false,
|
||||||
|
content: "hello world",
|
||||||
|
wantRespond: true,
|
||||||
|
wantContent: "hello world",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no config - mentioned",
|
||||||
|
gt: config.GroupTriggerConfig{},
|
||||||
|
isMentioned: true,
|
||||||
|
content: "hello world",
|
||||||
|
wantRespond: true,
|
||||||
|
wantContent: "hello world",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mention_only - not mentioned",
|
||||||
|
gt: config.GroupTriggerConfig{MentionOnly: true},
|
||||||
|
isMentioned: false,
|
||||||
|
content: "hello world",
|
||||||
|
wantRespond: false,
|
||||||
|
wantContent: "hello world",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mention_only - mentioned",
|
||||||
|
gt: config.GroupTriggerConfig{MentionOnly: true},
|
||||||
|
isMentioned: true,
|
||||||
|
content: "hello world",
|
||||||
|
wantRespond: true,
|
||||||
|
wantContent: "hello world",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prefix match",
|
||||||
|
gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}},
|
||||||
|
isMentioned: false,
|
||||||
|
content: "/ask hello",
|
||||||
|
wantRespond: true,
|
||||||
|
wantContent: "hello",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prefix no match - not mentioned",
|
||||||
|
gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}},
|
||||||
|
isMentioned: false,
|
||||||
|
content: "hello world",
|
||||||
|
wantRespond: false,
|
||||||
|
wantContent: "hello world",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prefix no match - but mentioned",
|
||||||
|
gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}},
|
||||||
|
isMentioned: true,
|
||||||
|
content: "hello world",
|
||||||
|
wantRespond: true,
|
||||||
|
wantContent: "hello world",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple prefixes - second matches",
|
||||||
|
gt: config.GroupTriggerConfig{Prefixes: []string{"/ask", "/bot"}},
|
||||||
|
isMentioned: false,
|
||||||
|
content: "/bot help me",
|
||||||
|
wantRespond: true,
|
||||||
|
wantContent: "help me",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mention_only with prefixes - mentioned overrides",
|
||||||
|
gt: config.GroupTriggerConfig{MentionOnly: true, Prefixes: []string{"/ask"}},
|
||||||
|
isMentioned: true,
|
||||||
|
content: "hello",
|
||||||
|
wantRespond: true,
|
||||||
|
wantContent: "hello",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mention_only with prefixes - not mentioned, no prefix",
|
||||||
|
gt: config.GroupTriggerConfig{MentionOnly: true, Prefixes: []string{"/ask"}},
|
||||||
|
isMentioned: false,
|
||||||
|
content: "hello",
|
||||||
|
wantRespond: false,
|
||||||
|
wantContent: "hello",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty prefix in list is skipped",
|
||||||
|
gt: config.GroupTriggerConfig{Prefixes: []string{"", "/ask"}},
|
||||||
|
isMentioned: false,
|
||||||
|
content: "/ask test",
|
||||||
|
wantRespond: true,
|
||||||
|
wantContent: "test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prefix strips leading whitespace after prefix",
|
||||||
|
gt: config.GroupTriggerConfig{Prefixes: []string{"/ask "}},
|
||||||
|
isMentioned: false,
|
||||||
|
content: "/ask hello",
|
||||||
|
wantRespond: true,
|
||||||
|
wantContent: "hello",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ch := NewBaseChannel("test", nil, nil, nil, WithGroupTrigger(tt.gt))
|
||||||
|
gotRespond, gotContent := ch.ShouldRespondInGroup(tt.isMentioned, tt.content)
|
||||||
|
if gotRespond != tt.wantRespond {
|
||||||
|
t.Errorf("ShouldRespondInGroup() respond = %v, want %v", gotRespond, tt.wantRespond)
|
||||||
|
}
|
||||||
|
if gotContent != tt.wantContent {
|
||||||
|
t.Errorf("ShouldRespondInGroup() content = %q, want %q", gotContent, tt.wantContent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAllowedSender(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
allowList []string
|
||||||
|
sender bus.SenderInfo
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty allowlist allows all",
|
||||||
|
allowList: nil,
|
||||||
|
sender: bus.SenderInfo{PlatformID: "anyone"},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "numeric ID matches PlatformID",
|
||||||
|
allowList: []string{"123456"},
|
||||||
|
sender: bus.SenderInfo{
|
||||||
|
Platform: "telegram",
|
||||||
|
PlatformID: "123456",
|
||||||
|
CanonicalID: "telegram:123456",
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "canonical format matches",
|
||||||
|
allowList: []string{"telegram:123456"},
|
||||||
|
sender: bus.SenderInfo{
|
||||||
|
Platform: "telegram",
|
||||||
|
PlatformID: "123456",
|
||||||
|
CanonicalID: "telegram:123456",
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "canonical format wrong platform",
|
||||||
|
allowList: []string{"discord:123456"},
|
||||||
|
sender: bus.SenderInfo{
|
||||||
|
Platform: "telegram",
|
||||||
|
PlatformID: "123456",
|
||||||
|
CanonicalID: "telegram:123456",
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "@username matches",
|
||||||
|
allowList: []string{"@alice"},
|
||||||
|
sender: bus.SenderInfo{
|
||||||
|
Platform: "telegram",
|
||||||
|
PlatformID: "123456",
|
||||||
|
CanonicalID: "telegram:123456",
|
||||||
|
Username: "alice",
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "compound id|username matches by ID",
|
||||||
|
allowList: []string{"123456|alice"},
|
||||||
|
sender: bus.SenderInfo{
|
||||||
|
Platform: "telegram",
|
||||||
|
PlatformID: "123456",
|
||||||
|
CanonicalID: "telegram:123456",
|
||||||
|
Username: "alice",
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non matching sender denied",
|
||||||
|
allowList: []string{"654321"},
|
||||||
|
sender: bus.SenderInfo{
|
||||||
|
Platform: "telegram",
|
||||||
|
PlatformID: "123456",
|
||||||
|
CanonicalID: "telegram:123456",
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ch := NewBaseChannel("test", nil, nil, tt.allowList)
|
||||||
|
if got := ch.IsAllowedSender(tt.sender); got != tt.want {
|
||||||
|
t.Fatalf("IsAllowedSender(%+v) = %v, want %v", tt.sender, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
// DingTalk channel implementation using Stream Mode
|
// DingTalk channel implementation using Stream Mode
|
||||||
|
|
||||||
package channels
|
package dingtalk
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -12,7 +12,9 @@ import (
|
||||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
|
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
@ -20,7 +22,7 @@ import (
|
||||||
// DingTalkChannel implements the Channel interface for DingTalk (钉钉)
|
// DingTalkChannel implements the Channel interface for DingTalk (钉钉)
|
||||||
// It uses WebSocket for receiving messages via stream mode and API for sending
|
// It uses WebSocket for receiving messages via stream mode and API for sending
|
||||||
type DingTalkChannel struct {
|
type DingTalkChannel struct {
|
||||||
*BaseChannel
|
*channels.BaseChannel
|
||||||
config config.DingTalkConfig
|
config config.DingTalkConfig
|
||||||
clientID string
|
clientID string
|
||||||
clientSecret string
|
clientSecret string
|
||||||
|
|
@ -37,7 +39,11 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (
|
||||||
return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
|
return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
base := NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom)
|
base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom,
|
||||||
|
channels.WithMaxMessageLength(20000),
|
||||||
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||||
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
|
)
|
||||||
|
|
||||||
return &DingTalkChannel{
|
return &DingTalkChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
|
|
@ -70,7 +76,7 @@ func (c *DingTalkChannel) Start(ctx context.Context) error {
|
||||||
return fmt.Errorf("failed to start stream client: %w", err)
|
return fmt.Errorf("failed to start stream client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.setRunning(true)
|
c.SetRunning(true)
|
||||||
logger.InfoC("dingtalk", "DingTalk channel started (Stream Mode)")
|
logger.InfoC("dingtalk", "DingTalk channel started (Stream Mode)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +93,7 @@ func (c *DingTalkChannel) Stop(ctx context.Context) error {
|
||||||
c.streamClient.Close()
|
c.streamClient.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
c.setRunning(false)
|
c.SetRunning(false)
|
||||||
logger.InfoC("dingtalk", "DingTalk channel stopped")
|
logger.InfoC("dingtalk", "DingTalk channel stopped")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -95,7 +101,7 @@ func (c *DingTalkChannel) Stop(ctx context.Context) error {
|
||||||
// Send sends a message to DingTalk via the chatbot reply API
|
// Send sends a message to DingTalk via the chatbot reply API
|
||||||
func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return fmt.Errorf("dingtalk channel not running")
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get session webhook from storage
|
// Get session webhook from storage
|
||||||
|
|
@ -159,12 +165,17 @@ func (c *DingTalkChannel) onChatBotMessageReceived(
|
||||||
"session_webhook": data.SessionWebhook,
|
"session_webhook": data.SessionWebhook,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var peer bus.Peer
|
||||||
if data.ConversationType == "1" {
|
if data.ConversationType == "1" {
|
||||||
metadata["peer_kind"] = "direct"
|
peer = bus.Peer{Kind: "direct", ID: senderID}
|
||||||
metadata["peer_id"] = senderID
|
|
||||||
} else {
|
} else {
|
||||||
metadata["peer_kind"] = "group"
|
peer = bus.Peer{Kind: "group", ID: data.ConversationId}
|
||||||
metadata["peer_id"] = data.ConversationId
|
// In group chats, apply unified group trigger filtering
|
||||||
|
respond, cleaned := c.ShouldRespondInGroup(false, content)
|
||||||
|
if !respond {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
content = cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("dingtalk", "Received message", map[string]any{
|
logger.DebugCF("dingtalk", "Received message", map[string]any{
|
||||||
|
|
@ -173,8 +184,20 @@ func (c *DingTalkChannel) onChatBotMessageReceived(
|
||||||
"preview": utils.Truncate(content, 50),
|
"preview": utils.Truncate(content, 50),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Build sender info
|
||||||
|
sender := bus.SenderInfo{
|
||||||
|
Platform: "dingtalk",
|
||||||
|
PlatformID: senderID,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("dingtalk", senderID),
|
||||||
|
DisplayName: senderNick,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.IsAllowedSender(sender) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Handle the message through the base channel
|
// Handle the message through the base channel
|
||||||
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
c.HandleMessage(ctx, peer, "", senderID, chatID, content, nil, metadata, sender)
|
||||||
|
|
||||||
// Return nil to indicate we've handled the message asynchronously
|
// Return nil to indicate we've handled the message asynchronously
|
||||||
// The response will be sent through the message bus
|
// The response will be sent through the message bus
|
||||||
|
|
@ -197,7 +220,7 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c
|
||||||
contentBytes,
|
contentBytes,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send reply: %w", err)
|
return fmt.Errorf("dingtalk send: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
13
pkg/channels/dingtalk/init.go
Normal file
13
pkg/channels/dingtalk/init.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package dingtalk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("dingtalk", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewDingTalkChannel(cfg.Channels.DingTalk, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package channels
|
package discord
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -11,23 +11,24 @@ import (
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
"github.com/sipeed/picoclaw/pkg/voice"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
transcriptionTimeout = 30 * time.Second
|
|
||||||
sendTimeout = 10 * time.Second
|
sendTimeout = 10 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type DiscordChannel struct {
|
type DiscordChannel struct {
|
||||||
*BaseChannel
|
*channels.BaseChannel
|
||||||
session *discordgo.Session
|
session *discordgo.Session
|
||||||
config config.DiscordConfig
|
config config.DiscordConfig
|
||||||
transcriber *voice.GroqTranscriber
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
typingMu sync.Mutex
|
typingMu sync.Mutex
|
||||||
typingStop map[string]chan struct{} // chatID → stop signal
|
typingStop map[string]chan struct{} // chatID → stop signal
|
||||||
botUserID string // stored for mention checking
|
botUserID string // stored for mention checking
|
||||||
|
|
@ -39,33 +40,25 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
|
||||||
return nil, fmt.Errorf("failed to create discord session: %w", err)
|
return nil, fmt.Errorf("failed to create discord session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
base := NewBaseChannel("discord", cfg, bus, cfg.AllowFrom)
|
base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom,
|
||||||
|
channels.WithMaxMessageLength(2000),
|
||||||
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||||
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
|
)
|
||||||
|
|
||||||
return &DiscordChannel{
|
return &DiscordChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
session: session,
|
session: session,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
transcriber: nil,
|
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
typingStop: make(map[string]chan struct{}),
|
typingStop: make(map[string]chan struct{}),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DiscordChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
|
|
||||||
c.transcriber = transcriber
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DiscordChannel) getContext() context.Context {
|
|
||||||
if c.ctx == nil {
|
|
||||||
return context.Background()
|
|
||||||
}
|
|
||||||
return c.ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DiscordChannel) Start(ctx context.Context) error {
|
func (c *DiscordChannel) Start(ctx context.Context) error {
|
||||||
logger.InfoC("discord", "Starting Discord bot")
|
logger.InfoC("discord", "Starting Discord bot")
|
||||||
|
|
||||||
c.ctx = ctx
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
// Get bot user ID before opening session to avoid race condition
|
// Get bot user ID before opening session to avoid race condition
|
||||||
botUser, err := c.session.User("@me")
|
botUser, err := c.session.User("@me")
|
||||||
|
|
@ -80,7 +73,7 @@ func (c *DiscordChannel) Start(ctx context.Context) error {
|
||||||
return fmt.Errorf("failed to open discord session: %w", err)
|
return fmt.Errorf("failed to open discord session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.setRunning(true)
|
c.SetRunning(true)
|
||||||
|
|
||||||
logger.InfoCF("discord", "Discord bot connected", map[string]any{
|
logger.InfoCF("discord", "Discord bot connected", map[string]any{
|
||||||
"username": botUser.Username,
|
"username": botUser.Username,
|
||||||
|
|
@ -92,7 +85,7 @@ func (c *DiscordChannel) Start(ctx context.Context) error {
|
||||||
|
|
||||||
func (c *DiscordChannel) Stop(ctx context.Context) error {
|
func (c *DiscordChannel) Stop(ctx context.Context) error {
|
||||||
logger.InfoC("discord", "Stopping Discord bot")
|
logger.InfoC("discord", "Stopping Discord bot")
|
||||||
c.setRunning(false)
|
c.SetRunning(false)
|
||||||
|
|
||||||
// Stop all typing goroutines before closing session
|
// Stop all typing goroutines before closing session
|
||||||
c.typingMu.Lock()
|
c.typingMu.Lock()
|
||||||
|
|
@ -102,6 +95,11 @@ func (c *DiscordChannel) Stop(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
c.typingMu.Unlock()
|
c.typingMu.Unlock()
|
||||||
|
|
||||||
|
// Cancel our context so typing goroutines using c.ctx.Done() exit
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
if err := c.session.Close(); err != nil {
|
if err := c.session.Close(); err != nil {
|
||||||
return fmt.Errorf("failed to close discord session: %w", err)
|
return fmt.Errorf("failed to close discord session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -110,10 +108,8 @@ func (c *DiscordChannel) Stop(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
c.stopTyping(msg.ChatID)
|
|
||||||
|
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return fmt.Errorf("discord bot not running")
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
channelID := msg.ChatID
|
channelID := msg.ChatID
|
||||||
|
|
@ -121,20 +117,133 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
|
||||||
return fmt.Errorf("channel ID is empty")
|
return fmt.Errorf("channel ID is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
runes := []rune(msg.Content)
|
if len([]rune(msg.Content)) == 0 {
|
||||||
if len(runes) == 0 {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
chunks := utils.SplitMessage(msg.Content, 2000) // Split messages into chunks, Discord length limit: 2000 chars
|
return c.sendChunk(ctx, channelID, msg.Content)
|
||||||
|
}
|
||||||
|
|
||||||
for _, chunk := range chunks {
|
// SendMedia implements the channels.MediaSender interface.
|
||||||
if err := c.sendChunk(ctx, channelID, chunk); err != nil {
|
func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
channelID := msg.ChatID
|
||||||
|
if channelID == "" {
|
||||||
|
return fmt.Errorf("channel ID is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
store := c.GetMediaStore()
|
||||||
|
if store == nil {
|
||||||
|
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all files into a single ChannelMessageSendComplex call
|
||||||
|
files := make([]*discordgo.File, 0, len(msg.Parts))
|
||||||
|
var caption string
|
||||||
|
|
||||||
|
for _, part := range msg.Parts {
|
||||||
|
localPath, err := store.Resolve(part.Ref)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("discord", "Failed to resolve media ref", map[string]any{
|
||||||
|
"ref": part.Ref,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(localPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("discord", "Failed to open media file", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Note: discordgo reads from the Reader and we can't close it before send
|
||||||
|
|
||||||
|
filename := part.Filename
|
||||||
|
if filename == "" {
|
||||||
|
filename = "file"
|
||||||
|
}
|
||||||
|
|
||||||
|
files = append(files, &discordgo.File{
|
||||||
|
Name: filename,
|
||||||
|
ContentType: part.ContentType,
|
||||||
|
Reader: file,
|
||||||
|
})
|
||||||
|
|
||||||
|
if part.Caption != "" && caption == "" {
|
||||||
|
caption = part.Caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
|
||||||
|
Content: caption,
|
||||||
|
Files: files,
|
||||||
|
})
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
// Close all file readers
|
||||||
|
for _, f := range files {
|
||||||
|
if closer, ok := f.Reader.(*os.File); ok {
|
||||||
|
closer.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("discord send media: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case <-sendCtx.Done():
|
||||||
|
// Close all file readers
|
||||||
|
for _, f := range files {
|
||||||
|
if closer, ok := f.Reader.(*os.File); ok {
|
||||||
|
closer.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sendCtx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditMessage implements channels.MessageEditor.
|
||||||
|
func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
||||||
|
_, err := c.session.ChannelMessageEdit(chatID, messageID, content)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendPlaceholder implements channels.PlaceholderCapable.
|
||||||
|
// It sends a placeholder message that will later be edited to the actual
|
||||||
|
// response via EditMessage (channels.MessageEditor).
|
||||||
|
func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
||||||
|
if !c.config.Placeholder.Enabled {
|
||||||
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
text := c.config.Placeholder.Text
|
||||||
|
if text == "" {
|
||||||
|
text = "Thinking... 💭"
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := c.session.ChannelMessageSend(chatID, text)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
|
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
|
||||||
|
|
@ -151,11 +260,11 @@ func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content strin
|
||||||
select {
|
select {
|
||||||
case err := <-done:
|
case err := <-done:
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send discord message: %w", err)
|
return fmt.Errorf("discord send: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
case <-sendCtx.Done():
|
case <-sendCtx.Done():
|
||||||
return fmt.Errorf("send message timeout: %w", sendCtx.Err())
|
return sendCtx.Err()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,17 +285,32 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check allowlist first to avoid downloading attachments and transcribing for rejected users
|
// Check allowlist first to avoid downloading attachments for rejected users
|
||||||
if !c.IsAllowed(m.Author.ID) {
|
sender := bus.SenderInfo{
|
||||||
|
Platform: "discord",
|
||||||
|
PlatformID: m.Author.ID,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("discord", m.Author.ID),
|
||||||
|
Username: m.Author.Username,
|
||||||
|
}
|
||||||
|
// Build display name
|
||||||
|
displayName := m.Author.Username
|
||||||
|
if m.Author.Discriminator != "" && m.Author.Discriminator != "0" {
|
||||||
|
displayName += "#" + m.Author.Discriminator
|
||||||
|
}
|
||||||
|
sender.DisplayName = displayName
|
||||||
|
|
||||||
|
if !c.IsAllowedSender(sender) {
|
||||||
logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{
|
logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{
|
||||||
"user_id": m.Author.ID,
|
"user_id": m.Author.ID,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If configured to only respond to mentions, check if bot is mentioned
|
content := m.Content
|
||||||
// Skip this check for DMs (GuildID is empty) - DMs should always be responded to
|
|
||||||
if c.config.MentionOnly && m.GuildID != "" {
|
// In guild (group) channels, apply unified group trigger filtering
|
||||||
|
// DMs (GuildID is empty) always get a response
|
||||||
|
if m.GuildID != "" {
|
||||||
isMentioned := false
|
isMentioned := false
|
||||||
for _, mention := range m.Mentions {
|
for _, mention := range m.Mentions {
|
||||||
if mention.ID == c.botUserID {
|
if mention.ID == c.botUserID {
|
||||||
|
|
@ -194,36 +318,39 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !isMentioned {
|
content = c.stripBotMention(content)
|
||||||
logger.DebugCF("discord", "Message ignored - bot not mentioned", map[string]any{
|
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
||||||
|
if !respond {
|
||||||
|
logger.DebugCF("discord", "Group message ignored by group trigger", map[string]any{
|
||||||
"user_id": m.Author.ID,
|
"user_id": m.Author.ID,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
content = cleaned
|
||||||
|
} else {
|
||||||
|
// DMs: just strip bot mention without filtering
|
||||||
|
content = c.stripBotMention(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
senderID := m.Author.ID
|
senderID := m.Author.ID
|
||||||
senderName := m.Author.Username
|
|
||||||
if m.Author.Discriminator != "" && m.Author.Discriminator != "0" {
|
|
||||||
senderName += "#" + m.Author.Discriminator
|
|
||||||
}
|
|
||||||
|
|
||||||
content := m.Content
|
|
||||||
content = c.stripBotMention(content)
|
|
||||||
mediaPaths := make([]string, 0, len(m.Attachments))
|
mediaPaths := make([]string, 0, len(m.Attachments))
|
||||||
localFiles := make([]string, 0, len(m.Attachments))
|
|
||||||
|
|
||||||
// Ensure temp files are cleaned up when function returns
|
scope := channels.BuildMediaScope("discord", m.ChannelID, m.ID)
|
||||||
defer func() {
|
|
||||||
for _, file := range localFiles {
|
// Helper to register a local file with the media store
|
||||||
if err := os.Remove(file); err != nil {
|
storeMedia := func(localPath, filename string) string {
|
||||||
logger.DebugCF("discord", "Failed to cleanup temp file", map[string]any{
|
if store := c.GetMediaStore(); store != nil {
|
||||||
"file": file,
|
ref, err := store.Store(localPath, media.MediaMeta{
|
||||||
"error": err.Error(),
|
Filename: filename,
|
||||||
})
|
Source: "discord",
|
||||||
|
}, scope)
|
||||||
|
if err == nil {
|
||||||
|
return ref
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
return localPath // fallback
|
||||||
|
}
|
||||||
|
|
||||||
for _, attachment := range m.Attachments {
|
for _, attachment := range m.Attachments {
|
||||||
isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType)
|
isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType)
|
||||||
|
|
@ -231,30 +358,8 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||||
if isAudio {
|
if isAudio {
|
||||||
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
|
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
|
||||||
if localPath != "" {
|
if localPath != "" {
|
||||||
localFiles = append(localFiles, localPath)
|
mediaPaths = append(mediaPaths, storeMedia(localPath, attachment.Filename))
|
||||||
|
content = appendContent(content, fmt.Sprintf("[audio: %s]", attachment.Filename))
|
||||||
var transcribedText string
|
|
||||||
if c.transcriber != nil && c.transcriber.IsAvailable() {
|
|
||||||
ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout)
|
|
||||||
result, err := c.transcriber.Transcribe(ctx, localPath)
|
|
||||||
cancel() // Release context resources immediately to avoid leaks in for loop
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("discord", "Voice transcription failed", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
transcribedText = fmt.Sprintf("[audio: %s (transcription failed)]", attachment.Filename)
|
|
||||||
} else {
|
|
||||||
transcribedText = fmt.Sprintf("[audio transcription: %s]", result.Text)
|
|
||||||
logger.DebugCF("discord", "Audio transcribed successfully", map[string]any{
|
|
||||||
"text": result.Text,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
transcribedText = fmt.Sprintf("[audio: %s]", attachment.Filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
content = appendContent(content, transcribedText)
|
|
||||||
} else {
|
} else {
|
||||||
logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{
|
logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{
|
||||||
"url": attachment.URL,
|
"url": attachment.URL,
|
||||||
|
|
@ -277,11 +382,8 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||||
content = "[media only]"
|
content = "[media only]"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start typing after all early returns — guaranteed to have a matching Send()
|
|
||||||
c.startTyping(m.ChannelID)
|
|
||||||
|
|
||||||
logger.DebugCF("discord", "Received message", map[string]any{
|
logger.DebugCF("discord", "Received message", map[string]any{
|
||||||
"sender_name": senderName,
|
"sender_name": sender.DisplayName,
|
||||||
"sender_id": senderID,
|
"sender_id": senderID,
|
||||||
"preview": utils.Truncate(content, 50),
|
"preview": utils.Truncate(content, 50),
|
||||||
})
|
})
|
||||||
|
|
@ -293,19 +395,18 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||||
peerID = senderID
|
peerID = senderID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
peer := bus.Peer{Kind: peerKind, ID: peerID}
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"message_id": m.ID,
|
|
||||||
"user_id": senderID,
|
"user_id": senderID,
|
||||||
"username": m.Author.Username,
|
"username": m.Author.Username,
|
||||||
"display_name": senderName,
|
"display_name": sender.DisplayName,
|
||||||
"guild_id": m.GuildID,
|
"guild_id": m.GuildID,
|
||||||
"channel_id": m.ChannelID,
|
"channel_id": m.ChannelID,
|
||||||
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
|
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
|
||||||
"peer_kind": peerKind,
|
|
||||||
"peer_id": peerID,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata)
|
c.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata, sender)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startTyping starts a continuous typing indicator loop for the given chatID.
|
// startTyping starts a continuous typing indicator loop for the given chatID.
|
||||||
|
|
@ -354,6 +455,13 @@ func (c *DiscordChannel) stopTyping(chatID string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StartTyping implements channels.TypingCapable.
|
||||||
|
// It starts a continuous typing indicator and returns an idempotent stop function.
|
||||||
|
func (c *DiscordChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
||||||
|
c.startTyping(chatID)
|
||||||
|
return func() { c.stopTyping(chatID) }, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *DiscordChannel) downloadAttachment(url, filename string) string {
|
func (c *DiscordChannel) downloadAttachment(url, filename string) string {
|
||||||
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||||
LoggerPrefix: "discord",
|
LoggerPrefix: "discord",
|
||||||
13
pkg/channels/discord/init.go
Normal file
13
pkg/channels/discord/init.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package discord
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("discord", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewDiscordChannel(cfg.Channels.Discord, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
21
pkg/channels/errors.go
Normal file
21
pkg/channels/errors.go
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrNotRunning indicates the channel is not running.
|
||||||
|
// Manager will not retry.
|
||||||
|
ErrNotRunning = errors.New("channel not running")
|
||||||
|
|
||||||
|
// ErrRateLimit indicates the platform returned a rate-limit response (e.g. HTTP 429).
|
||||||
|
// Manager will wait a fixed delay and retry.
|
||||||
|
ErrRateLimit = errors.New("rate limited")
|
||||||
|
|
||||||
|
// ErrTemporary indicates a transient failure (e.g. network timeout, 5xx).
|
||||||
|
// Manager will use exponential backoff and retry.
|
||||||
|
ErrTemporary = errors.New("temporary failure")
|
||||||
|
|
||||||
|
// ErrSendFailed indicates a permanent failure (e.g. invalid chat ID, 4xx non-429).
|
||||||
|
// Manager will not retry.
|
||||||
|
ErrSendFailed = errors.New("send failed")
|
||||||
|
)
|
||||||
56
pkg/channels/errors_test.go
Normal file
56
pkg/channels/errors_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestErrorsIs(t *testing.T) {
|
||||||
|
wrapped := fmt.Errorf("telegram API: %w", ErrRateLimit)
|
||||||
|
if !errors.Is(wrapped, ErrRateLimit) {
|
||||||
|
t.Error("wrapped ErrRateLimit should match")
|
||||||
|
}
|
||||||
|
if errors.Is(wrapped, ErrTemporary) {
|
||||||
|
t.Error("wrapped ErrRateLimit should not match ErrTemporary")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorsIsAllTypes(t *testing.T) {
|
||||||
|
sentinels := []error{ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed}
|
||||||
|
|
||||||
|
for _, sentinel := range sentinels {
|
||||||
|
wrapped := fmt.Errorf("context: %w", sentinel)
|
||||||
|
if !errors.Is(wrapped, sentinel) {
|
||||||
|
t.Errorf("wrapped %v should match itself", sentinel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it doesn't match other sentinel errors
|
||||||
|
for _, other := range sentinels {
|
||||||
|
if other == sentinel {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if errors.Is(wrapped, other) {
|
||||||
|
t.Errorf("wrapped %v should not match %v", sentinel, other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorMessages(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
err error
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{ErrNotRunning, "channel not running"},
|
||||||
|
{ErrRateLimit, "rate limited"},
|
||||||
|
{ErrTemporary, "temporary failure"},
|
||||||
|
{ErrSendFailed, "send failed"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := tt.err.Error(); got != tt.want {
|
||||||
|
t.Errorf("error message = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
pkg/channels/errutil.go
Normal file
30
pkg/channels/errutil.go
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClassifySendError wraps a raw error with the appropriate sentinel based on
|
||||||
|
// an HTTP status code. Channels that perform HTTP API calls should use this
|
||||||
|
// in their Send path.
|
||||||
|
func ClassifySendError(statusCode int, rawErr error) error {
|
||||||
|
switch {
|
||||||
|
case statusCode == http.StatusTooManyRequests:
|
||||||
|
return fmt.Errorf("%w: %v", ErrRateLimit, rawErr)
|
||||||
|
case statusCode >= 500:
|
||||||
|
return fmt.Errorf("%w: %v", ErrTemporary, rawErr)
|
||||||
|
case statusCode >= 400:
|
||||||
|
return fmt.Errorf("%w: %v", ErrSendFailed, rawErr)
|
||||||
|
default:
|
||||||
|
return rawErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifyNetError wraps a network/timeout error as ErrTemporary.
|
||||||
|
func ClassifyNetError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: %v", ErrTemporary, err)
|
||||||
|
}
|
||||||
97
pkg/channels/errutil_test.go
Normal file
97
pkg/channels/errutil_test.go
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClassifySendError(t *testing.T) {
|
||||||
|
raw := fmt.Errorf("some API error")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
statusCode int
|
||||||
|
wantIs error
|
||||||
|
wantNil bool
|
||||||
|
}{
|
||||||
|
{"429 -> ErrRateLimit", 429, ErrRateLimit, false},
|
||||||
|
{"500 -> ErrTemporary", 500, ErrTemporary, false},
|
||||||
|
{"502 -> ErrTemporary", 502, ErrTemporary, false},
|
||||||
|
{"503 -> ErrTemporary", 503, ErrTemporary, false},
|
||||||
|
{"400 -> ErrSendFailed", 400, ErrSendFailed, false},
|
||||||
|
{"403 -> ErrSendFailed", 403, ErrSendFailed, false},
|
||||||
|
{"404 -> ErrSendFailed", 404, ErrSendFailed, false},
|
||||||
|
{"200 -> raw error", 200, nil, false},
|
||||||
|
{"201 -> raw error", 201, nil, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ClassifySendError(tt.statusCode, raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected non-nil error")
|
||||||
|
}
|
||||||
|
if tt.wantIs != nil {
|
||||||
|
if !errors.Is(err, tt.wantIs) {
|
||||||
|
t.Errorf("errors.Is(err, %v) = false, want true; err = %v", tt.wantIs, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Should return the raw error unchanged
|
||||||
|
if err != raw {
|
||||||
|
t.Errorf("expected raw error to be returned unchanged for status %d, got %v", tt.statusCode, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifySendErrorNoFalsePositive(t *testing.T) {
|
||||||
|
raw := fmt.Errorf("some error")
|
||||||
|
|
||||||
|
// 429 should NOT match ErrTemporary or ErrSendFailed
|
||||||
|
err := ClassifySendError(429, raw)
|
||||||
|
if errors.Is(err, ErrTemporary) {
|
||||||
|
t.Error("429 should not match ErrTemporary")
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrSendFailed) {
|
||||||
|
t.Error("429 should not match ErrSendFailed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 500 should NOT match ErrRateLimit or ErrSendFailed
|
||||||
|
err = ClassifySendError(500, raw)
|
||||||
|
if errors.Is(err, ErrRateLimit) {
|
||||||
|
t.Error("500 should not match ErrRateLimit")
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrSendFailed) {
|
||||||
|
t.Error("500 should not match ErrSendFailed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 400 should NOT match ErrRateLimit or ErrTemporary
|
||||||
|
err = ClassifySendError(400, raw)
|
||||||
|
if errors.Is(err, ErrRateLimit) {
|
||||||
|
t.Error("400 should not match ErrRateLimit")
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrTemporary) {
|
||||||
|
t.Error("400 should not match ErrTemporary")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyNetError(t *testing.T) {
|
||||||
|
t.Run("nil error returns nil", func(t *testing.T) {
|
||||||
|
if err := ClassifyNetError(nil); err != nil {
|
||||||
|
t.Errorf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-nil error wraps as ErrTemporary", func(t *testing.T) {
|
||||||
|
raw := fmt.Errorf("connection refused")
|
||||||
|
err := ClassifyNetError(raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected non-nil error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrTemporary) {
|
||||||
|
t.Errorf("errors.Is(err, ErrTemporary) = false, want true; err = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
9
pkg/channels/feishu/common.go
Normal file
9
pkg/channels/feishu/common.go
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
package feishu
|
||||||
|
|
||||||
|
// stringValue safely dereferences a *string pointer.
|
||||||
|
func stringValue(v *string) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *v
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,19 @@
|
||||||
//go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64
|
//go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64
|
||||||
|
|
||||||
package channels
|
package feishu
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FeishuChannel is a stub implementation for 32-bit architectures
|
// FeishuChannel is a stub implementation for 32-bit architectures
|
||||||
type FeishuChannel struct {
|
type FeishuChannel struct {
|
||||||
*BaseChannel
|
*channels.BaseChannel
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported
|
// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
|
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
|
||||||
|
|
||||||
package channels
|
package feishu
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -15,13 +15,15 @@ import (
|
||||||
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FeishuChannel struct {
|
type FeishuChannel struct {
|
||||||
*BaseChannel
|
*channels.BaseChannel
|
||||||
config config.FeishuConfig
|
config config.FeishuConfig
|
||||||
client *lark.Client
|
client *lark.Client
|
||||||
wsClient *larkws.Client
|
wsClient *larkws.Client
|
||||||
|
|
@ -31,7 +33,10 @@ type FeishuChannel struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
||||||
base := NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom)
|
base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom,
|
||||||
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||||
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
|
)
|
||||||
|
|
||||||
return &FeishuChannel{
|
return &FeishuChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
|
|
@ -60,7 +65,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
|
||||||
wsClient := c.wsClient
|
wsClient := c.wsClient
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
c.setRunning(true)
|
c.SetRunning(true)
|
||||||
logger.InfoC("feishu", "Feishu channel started (websocket mode)")
|
logger.InfoC("feishu", "Feishu channel started (websocket mode)")
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -83,14 +88,14 @@ func (c *FeishuChannel) Stop(ctx context.Context) error {
|
||||||
c.wsClient = nil
|
c.wsClient = nil
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
c.setRunning(false)
|
c.SetRunning(false)
|
||||||
logger.InfoC("feishu", "Feishu channel stopped")
|
logger.InfoC("feishu", "Feishu channel stopped")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return fmt.Errorf("feishu channel not running")
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.ChatID == "" {
|
if msg.ChatID == "" {
|
||||||
|
|
@ -114,11 +119,11 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
|
|
||||||
resp, err := c.client.Im.V1.Message.Create(ctx, req)
|
resp, err := c.client.Im.V1.Message.Create(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send feishu message: %w", err)
|
return fmt.Errorf("feishu send: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !resp.Success() {
|
if !resp.Success() {
|
||||||
return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg)
|
return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("feishu", "Feishu message sent", map[string]any{
|
logger.DebugCF("feishu", "Feishu message sent", map[string]any{
|
||||||
|
|
@ -128,7 +133,7 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2MessageReceiveV1) error {
|
func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
|
||||||
if event == nil || event.Event == nil || event.Event.Message == nil {
|
if event == nil || event.Event == nil || event.Event.Message == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -152,8 +157,9 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata := map[string]string{}
|
metadata := map[string]string{}
|
||||||
if messageID := stringValue(message.MessageId); messageID != "" {
|
messageID := ""
|
||||||
metadata["message_id"] = messageID
|
if mid := stringValue(message.MessageId); mid != "" {
|
||||||
|
messageID = mid
|
||||||
}
|
}
|
||||||
if messageType := stringValue(message.MessageType); messageType != "" {
|
if messageType := stringValue(message.MessageType); messageType != "" {
|
||||||
metadata["message_type"] = messageType
|
metadata["message_type"] = messageType
|
||||||
|
|
@ -166,12 +172,17 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
|
||||||
}
|
}
|
||||||
|
|
||||||
chatType := stringValue(message.ChatType)
|
chatType := stringValue(message.ChatType)
|
||||||
|
var peer bus.Peer
|
||||||
if chatType == "p2p" {
|
if chatType == "p2p" {
|
||||||
metadata["peer_kind"] = "direct"
|
peer = bus.Peer{Kind: "direct", ID: senderID}
|
||||||
metadata["peer_id"] = senderID
|
|
||||||
} else {
|
} else {
|
||||||
metadata["peer_kind"] = "group"
|
peer = bus.Peer{Kind: "group", ID: chatID}
|
||||||
metadata["peer_id"] = chatID
|
// In group chats, apply unified group trigger filtering
|
||||||
|
respond, cleaned := c.ShouldRespondInGroup(false, content)
|
||||||
|
if !respond {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
content = cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InfoCF("feishu", "Feishu message received", map[string]any{
|
logger.InfoCF("feishu", "Feishu message received", map[string]any{
|
||||||
|
|
@ -180,7 +191,17 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
|
||||||
"preview": utils.Truncate(content, 80),
|
"preview": utils.Truncate(content, 80),
|
||||||
})
|
})
|
||||||
|
|
||||||
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
senderInfo := bus.SenderInfo{
|
||||||
|
Platform: "feishu",
|
||||||
|
PlatformID: senderID,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("feishu", senderID),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.IsAllowedSender(senderInfo) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, senderInfo)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,10 +239,3 @@ func extractFeishuMessageContent(message *larkim.EventMessage) string {
|
||||||
|
|
||||||
return *message.Content
|
return *message.Content
|
||||||
}
|
}
|
||||||
|
|
||||||
func stringValue(v *string) string {
|
|
||||||
if v == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return *v
|
|
||||||
}
|
|
||||||
13
pkg/channels/feishu/init.go
Normal file
13
pkg/channels/feishu/init.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package feishu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("feishu", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewFeishuChannel(cfg.Channels.Feishu, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
41
pkg/channels/interfaces.go
Normal file
41
pkg/channels/interfaces.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// TypingCapable — channels that can show a typing/thinking indicator.
|
||||||
|
// StartTyping begins the indicator and returns a stop function.
|
||||||
|
// The stop function MUST be idempotent and safe to call multiple times.
|
||||||
|
type TypingCapable interface {
|
||||||
|
StartTyping(ctx context.Context, chatID string) (stop func(), err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageEditor — channels that can edit an existing message.
|
||||||
|
// messageID is always string; channels convert platform-specific types internally.
|
||||||
|
type MessageEditor interface {
|
||||||
|
EditMessage(ctx context.Context, chatID string, messageID string, content string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReactionCapable — channels that can add a reaction (e.g. 👀) to an inbound message.
|
||||||
|
// ReactToMessage adds a reaction and returns an undo function to remove it.
|
||||||
|
// The undo function MUST be idempotent and safe to call multiple times.
|
||||||
|
type ReactionCapable interface {
|
||||||
|
ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaceholderCapable — channels that can send a placeholder message
|
||||||
|
// (e.g. "Thinking... 💭") that will later be edited to the actual response.
|
||||||
|
// The channel MUST also implement MessageEditor for the placeholder to be useful.
|
||||||
|
// SendPlaceholder returns the platform message ID of the placeholder so that
|
||||||
|
// Manager.preSend can later edit it via MessageEditor.EditMessage.
|
||||||
|
type PlaceholderCapable interface {
|
||||||
|
SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaceholderRecorder is injected into channels by Manager.
|
||||||
|
// Channels call these methods on inbound to register typing/placeholder state.
|
||||||
|
// Manager uses the registered state on outbound to stop typing and edit placeholders.
|
||||||
|
type PlaceholderRecorder interface {
|
||||||
|
RecordPlaceholder(channel, chatID, placeholderID string)
|
||||||
|
RecordTypingStop(channel, chatID string, stop func())
|
||||||
|
RecordReactionUndo(channel, chatID string, undo func())
|
||||||
|
}
|
||||||
13
pkg/channels/line/init.go
Normal file
13
pkg/channels/line/init.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package line
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("line", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewLINEChannel(cfg.Channels.LINE, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package channels
|
package line
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|
@ -10,14 +10,16 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -41,9 +43,8 @@ type replyTokenEntry struct {
|
||||||
// using the LINE Messaging API with HTTP webhook for receiving messages
|
// using the LINE Messaging API with HTTP webhook for receiving messages
|
||||||
// and REST API for sending messages.
|
// and REST API for sending messages.
|
||||||
type LINEChannel struct {
|
type LINEChannel struct {
|
||||||
*BaseChannel
|
*channels.BaseChannel
|
||||||
config config.LINEConfig
|
config config.LINEConfig
|
||||||
httpServer *http.Server
|
|
||||||
botUserID string // Bot's user ID
|
botUserID string // Bot's user ID
|
||||||
botBasicID string // Bot's basic ID (e.g. @216ru...)
|
botBasicID string // Bot's basic ID (e.g. @216ru...)
|
||||||
botDisplayName string // Bot's display name for text-based mention detection
|
botDisplayName string // Bot's display name for text-based mention detection
|
||||||
|
|
@ -59,7 +60,11 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha
|
||||||
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
|
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
base := NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom)
|
base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom,
|
||||||
|
channels.WithMaxMessageLength(5000),
|
||||||
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||||
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
|
)
|
||||||
|
|
||||||
return &LINEChannel{
|
return &LINEChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
|
|
@ -67,7 +72,7 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start launches the HTTP webhook server.
|
// Start initializes the LINE channel.
|
||||||
func (c *LINEChannel) Start(ctx context.Context) error {
|
func (c *LINEChannel) Start(ctx context.Context) error {
|
||||||
logger.InfoC("line", "Starting LINE channel (Webhook Mode)")
|
logger.InfoC("line", "Starting LINE channel (Webhook Mode)")
|
||||||
|
|
||||||
|
|
@ -86,32 +91,7 @@ func (c *LINEChannel) Start(ctx context.Context) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
c.SetRunning(true)
|
||||||
path := c.config.WebhookPath
|
|
||||||
if path == "" {
|
|
||||||
path = "/webhook/line"
|
|
||||||
}
|
|
||||||
mux.HandleFunc(path, c.webhookHandler)
|
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
|
|
||||||
c.httpServer = &http.Server{
|
|
||||||
Addr: addr,
|
|
||||||
Handler: mux,
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
logger.InfoCF("line", "LINE webhook server listening", map[string]any{
|
|
||||||
"addr": addr,
|
|
||||||
"path": path,
|
|
||||||
})
|
|
||||||
if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
logger.ErrorCF("line", "Webhook server error", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
c.setRunning(true)
|
|
||||||
logger.InfoC("line", "LINE channel started (Webhook Mode)")
|
logger.InfoC("line", "LINE channel started (Webhook Mode)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +130,7 @@ func (c *LINEChannel) fetchBotInfo() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop gracefully shuts down the HTTP server.
|
// Stop gracefully stops the LINE channel.
|
||||||
func (c *LINEChannel) Stop(ctx context.Context) error {
|
func (c *LINEChannel) Stop(ctx context.Context) error {
|
||||||
logger.InfoC("line", "Stopping LINE channel")
|
logger.InfoC("line", "Stopping LINE channel")
|
||||||
|
|
||||||
|
|
@ -158,21 +138,24 @@ func (c *LINEChannel) Stop(ctx context.Context) error {
|
||||||
c.cancel()
|
c.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.httpServer != nil {
|
c.SetRunning(false)
|
||||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := c.httpServer.Shutdown(shutdownCtx); err != nil {
|
|
||||||
logger.ErrorCF("line", "Webhook server shutdown error", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.setRunning(false)
|
|
||||||
logger.InfoC("line", "LINE channel stopped")
|
logger.InfoC("line", "LINE channel stopped")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebhookPath returns the path for registering on the shared HTTP server.
|
||||||
|
func (c *LINEChannel) WebhookPath() string {
|
||||||
|
if c.config.WebhookPath != "" {
|
||||||
|
return c.config.WebhookPath
|
||||||
|
}
|
||||||
|
return "/webhook/line"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP implements http.Handler for the shared HTTP server.
|
||||||
|
func (c *LINEChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c.webhookHandler(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
// webhookHandler handles incoming LINE webhook requests.
|
// webhookHandler handles incoming LINE webhook requests.
|
||||||
func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
|
|
@ -284,14 +267,6 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// In group chats, only respond when the bot is mentioned
|
|
||||||
if isGroup && !c.isBotMentioned(msg) {
|
|
||||||
logger.DebugCF("line", "Ignoring group message without mention", map[string]any{
|
|
||||||
"chat_id": chatID,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store reply token for later use
|
// Store reply token for later use
|
||||||
if event.ReplyToken != "" {
|
if event.ReplyToken != "" {
|
||||||
c.replyTokens.Store(chatID, replyTokenEntry{
|
c.replyTokens.Store(chatID, replyTokenEntry{
|
||||||
|
|
@ -307,18 +282,22 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
|
|
||||||
var content string
|
var content string
|
||||||
var mediaPaths []string
|
var mediaPaths []string
|
||||||
localFiles := []string{}
|
|
||||||
|
|
||||||
defer func() {
|
scope := channels.BuildMediaScope("line", chatID, msg.ID)
|
||||||
for _, file := range localFiles {
|
|
||||||
if err := os.Remove(file); err != nil {
|
// Helper to register a local file with the media store
|
||||||
logger.DebugCF("line", "Failed to cleanup temp file", map[string]any{
|
storeMedia := func(localPath, filename string) string {
|
||||||
"file": file,
|
if store := c.GetMediaStore(); store != nil {
|
||||||
"error": err.Error(),
|
ref, err := store.Store(localPath, media.MediaMeta{
|
||||||
})
|
Filename: filename,
|
||||||
|
Source: "line",
|
||||||
|
}, scope)
|
||||||
|
if err == nil {
|
||||||
|
return ref
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
return localPath // fallback
|
||||||
|
}
|
||||||
|
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case "text":
|
case "text":
|
||||||
|
|
@ -330,22 +309,19 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
case "image":
|
case "image":
|
||||||
localPath := c.downloadContent(msg.ID, "image.jpg")
|
localPath := c.downloadContent(msg.ID, "image.jpg")
|
||||||
if localPath != "" {
|
if localPath != "" {
|
||||||
localFiles = append(localFiles, localPath)
|
mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg"))
|
||||||
mediaPaths = append(mediaPaths, localPath)
|
|
||||||
content = "[image]"
|
content = "[image]"
|
||||||
}
|
}
|
||||||
case "audio":
|
case "audio":
|
||||||
localPath := c.downloadContent(msg.ID, "audio.m4a")
|
localPath := c.downloadContent(msg.ID, "audio.m4a")
|
||||||
if localPath != "" {
|
if localPath != "" {
|
||||||
localFiles = append(localFiles, localPath)
|
mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a"))
|
||||||
mediaPaths = append(mediaPaths, localPath)
|
|
||||||
content = "[audio]"
|
content = "[audio]"
|
||||||
}
|
}
|
||||||
case "video":
|
case "video":
|
||||||
localPath := c.downloadContent(msg.ID, "video.mp4")
|
localPath := c.downloadContent(msg.ID, "video.mp4")
|
||||||
if localPath != "" {
|
if localPath != "" {
|
||||||
localFiles = append(localFiles, localPath)
|
mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4"))
|
||||||
mediaPaths = append(mediaPaths, localPath)
|
|
||||||
content = "[video]"
|
content = "[video]"
|
||||||
}
|
}
|
||||||
case "file":
|
case "file":
|
||||||
|
|
@ -360,18 +336,29 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In group chats, apply unified group trigger filtering
|
||||||
|
if isGroup {
|
||||||
|
isMentioned := c.isBotMentioned(msg)
|
||||||
|
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
||||||
|
if !respond {
|
||||||
|
logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{
|
||||||
|
"chat_id": chatID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content = cleaned
|
||||||
|
}
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"platform": "line",
|
"platform": "line",
|
||||||
"source_type": event.Source.Type,
|
"source_type": event.Source.Type,
|
||||||
"message_id": msg.ID,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var peer bus.Peer
|
||||||
if isGroup {
|
if isGroup {
|
||||||
metadata["peer_kind"] = "group"
|
peer = bus.Peer{Kind: "group", ID: chatID}
|
||||||
metadata["peer_id"] = chatID
|
|
||||||
} else {
|
} else {
|
||||||
metadata["peer_kind"] = "direct"
|
peer = bus.Peer{Kind: "direct", ID: senderID}
|
||||||
metadata["peer_id"] = senderID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("line", "Received message", map[string]any{
|
logger.DebugCF("line", "Received message", map[string]any{
|
||||||
|
|
@ -382,10 +369,17 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
"preview": utils.Truncate(content, 50),
|
"preview": utils.Truncate(content, 50),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Show typing/loading indicator (requires user ID, not group ID)
|
sender := bus.SenderInfo{
|
||||||
c.sendLoading(senderID)
|
Platform: "line",
|
||||||
|
PlatformID: senderID,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("line", senderID),
|
||||||
|
}
|
||||||
|
|
||||||
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
|
if !c.IsAllowedSender(sender) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata, sender)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isBotMentioned checks if the bot is mentioned in the message.
|
// isBotMentioned checks if the bot is mentioned in the message.
|
||||||
|
|
@ -491,7 +485,7 @@ func (c *LINEChannel) resolveChatID(source lineSource) string {
|
||||||
// using a cached reply token, then falls back to the Push API.
|
// using a cached reply token, then falls back to the Push API.
|
||||||
func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return fmt.Errorf("line channel not running")
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load and consume quote token for this chat
|
// Load and consume quote token for this chat
|
||||||
|
|
@ -519,6 +513,36 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken)
|
return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendMedia implements the channels.MediaSender interface.
|
||||||
|
// LINE requires media to be accessible via public URL; since we only have local files,
|
||||||
|
// we fall back to sending a text message with the filename/caption.
|
||||||
|
// For full support, an external file hosting service would be needed.
|
||||||
|
func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
store := c.GetMediaStore()
|
||||||
|
if store == nil {
|
||||||
|
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LINE Messaging API requires publicly accessible URLs for media messages.
|
||||||
|
// Since we only have local file paths, send caption text as fallback.
|
||||||
|
for _, part := range msg.Parts {
|
||||||
|
caption := part.Caption
|
||||||
|
if caption == "" {
|
||||||
|
caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.sendPush(ctx, msg.ChatID, caption, ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// buildTextMessage creates a text message object, optionally with quoteToken.
|
// buildTextMessage creates a text message object, optionally with quoteToken.
|
||||||
func buildTextMessage(content, quoteToken string) map[string]string {
|
func buildTextMessage(content, quoteToken string) map[string]string {
|
||||||
msg := map[string]string{
|
msg := map[string]string{
|
||||||
|
|
@ -551,17 +575,58 @@ func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken stri
|
||||||
return c.callAPI(ctx, linePushEndpoint, payload)
|
return c.callAPI(ctx, linePushEndpoint, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StartTyping implements channels.TypingCapable using LINE's loading animation.
|
||||||
|
//
|
||||||
|
// NOTE: The LINE loading animation API only works for 1:1 chats.
|
||||||
|
// Group/room chat IDs (starting with "C" or "R") are detected automatically;
|
||||||
|
// for these, a no-op stop function is returned without calling the API.
|
||||||
|
func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
||||||
|
if chatID == "" {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group/room chats: LINE loading animation is 1:1 only.
|
||||||
|
if strings.HasPrefix(chatID, "C") || strings.HasPrefix(chatID, "R") {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
typingCtx, cancel := context.WithCancel(ctx)
|
||||||
|
var once sync.Once
|
||||||
|
stop := func() { once.Do(cancel) }
|
||||||
|
|
||||||
|
// Send immediately, then refresh periodically for long-running tasks.
|
||||||
|
if err := c.sendLoading(typingCtx, chatID); err != nil {
|
||||||
|
stop()
|
||||||
|
return stop, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(50 * time.Second)
|
||||||
|
go func() {
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-typingCtx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := c.sendLoading(typingCtx, chatID); err != nil {
|
||||||
|
logger.DebugCF("line", "Failed to refresh loading indicator", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return stop, nil
|
||||||
|
}
|
||||||
|
|
||||||
// sendLoading sends a loading animation indicator to the chat.
|
// sendLoading sends a loading animation indicator to the chat.
|
||||||
func (c *LINEChannel) sendLoading(chatID string) {
|
func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error {
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"chatId": chatID,
|
"chatId": chatID,
|
||||||
"loadingSeconds": 60,
|
"loadingSeconds": 60,
|
||||||
}
|
}
|
||||||
if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil {
|
return c.callAPI(ctx, lineLoadingEndpoint, payload)
|
||||||
logger.DebugCF("line", "Failed to send loading indicator", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// callAPI makes an authenticated POST request to the LINE API.
|
// callAPI makes an authenticated POST request to the LINE API.
|
||||||
|
|
@ -582,13 +647,13 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any)
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("API request failed: %w", err)
|
return channels.ClassifyNetError(err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
return fmt.Errorf("LINE API error (status %d): %s", resp.StatusCode, string(respBody))
|
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
13
pkg/channels/maixcam/init.go
Normal file
13
pkg/channels/maixcam/init.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package maixcam
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("maixcam", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewMaixCamChannel(cfg.Channels.MaixCam, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package channels
|
package maixcam
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -6,16 +6,21 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MaixCamChannel struct {
|
type MaixCamChannel struct {
|
||||||
*BaseChannel
|
*channels.BaseChannel
|
||||||
config config.MaixCamConfig
|
config config.MaixCamConfig
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
clients map[net.Conn]bool
|
clients map[net.Conn]bool
|
||||||
clientsMux sync.RWMutex
|
clientsMux sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
@ -28,7 +33,13 @@ type MaixCamMessage struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) {
|
func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) {
|
||||||
base := NewBaseChannel("maixcam", cfg, bus, cfg.AllowFrom)
|
base := channels.NewBaseChannel(
|
||||||
|
"maixcam",
|
||||||
|
cfg,
|
||||||
|
bus,
|
||||||
|
cfg.AllowFrom,
|
||||||
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
|
)
|
||||||
|
|
||||||
return &MaixCamChannel{
|
return &MaixCamChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
|
|
@ -40,37 +51,40 @@ func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamC
|
||||||
func (c *MaixCamChannel) Start(ctx context.Context) error {
|
func (c *MaixCamChannel) Start(ctx context.Context) error {
|
||||||
logger.InfoC("maixcam", "Starting MaixCam channel server")
|
logger.InfoC("maixcam", "Starting MaixCam channel server")
|
||||||
|
|
||||||
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%d", c.config.Host, c.config.Port)
|
addr := fmt.Sprintf("%s:%d", c.config.Host, c.config.Port)
|
||||||
listener, err := net.Listen("tcp", addr)
|
listener, err := net.Listen("tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
c.cancel()
|
||||||
return fmt.Errorf("failed to listen on %s: %w", addr, err)
|
return fmt.Errorf("failed to listen on %s: %w", addr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.listener = listener
|
c.listener = listener
|
||||||
c.setRunning(true)
|
c.SetRunning(true)
|
||||||
|
|
||||||
logger.InfoCF("maixcam", "MaixCam server listening", map[string]any{
|
logger.InfoCF("maixcam", "MaixCam server listening", map[string]any{
|
||||||
"host": c.config.Host,
|
"host": c.config.Host,
|
||||||
"port": c.config.Port,
|
"port": c.config.Port,
|
||||||
})
|
})
|
||||||
|
|
||||||
go c.acceptConnections(ctx)
|
go c.acceptConnections()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *MaixCamChannel) acceptConnections(ctx context.Context) {
|
func (c *MaixCamChannel) acceptConnections() {
|
||||||
logger.DebugC("maixcam", "Starting connection acceptor")
|
logger.DebugC("maixcam", "Starting connection acceptor")
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-c.ctx.Done():
|
||||||
logger.InfoC("maixcam", "Stopping connection acceptor")
|
logger.InfoC("maixcam", "Stopping connection acceptor")
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
conn, err := c.listener.Accept()
|
conn, err := c.listener.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if c.running {
|
if c.IsRunning() {
|
||||||
logger.ErrorCF("maixcam", "Failed to accept connection", map[string]any{
|
logger.ErrorCF("maixcam", "Failed to accept connection", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
|
@ -86,12 +100,12 @@ func (c *MaixCamChannel) acceptConnections(ctx context.Context) {
|
||||||
c.clients[conn] = true
|
c.clients[conn] = true
|
||||||
c.clientsMux.Unlock()
|
c.clientsMux.Unlock()
|
||||||
|
|
||||||
go c.handleConnection(conn, ctx)
|
go c.handleConnection(conn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) {
|
func (c *MaixCamChannel) handleConnection(conn net.Conn) {
|
||||||
logger.DebugC("maixcam", "Handling MaixCam connection")
|
logger.DebugC("maixcam", "Handling MaixCam connection")
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|
@ -106,7 +120,7 @@ func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) {
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-c.ctx.Done():
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
var msg MaixCamMessage
|
var msg MaixCamMessage
|
||||||
|
|
@ -170,11 +184,29 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
|
||||||
"y": fmt.Sprintf("%.0f", y),
|
"y": fmt.Sprintf("%.0f", y),
|
||||||
"w": fmt.Sprintf("%.0f", w),
|
"w": fmt.Sprintf("%.0f", w),
|
||||||
"h": fmt.Sprintf("%.0f", h),
|
"h": fmt.Sprintf("%.0f", h),
|
||||||
"peer_kind": "channel",
|
|
||||||
"peer_id": "default",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HandleMessage(senderID, chatID, content, []string{}, metadata)
|
sender := bus.SenderInfo{
|
||||||
|
Platform: "maixcam",
|
||||||
|
PlatformID: "maixcam",
|
||||||
|
CanonicalID: identity.BuildCanonicalID("maixcam", "maixcam"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.IsAllowedSender(sender) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HandleMessage(
|
||||||
|
c.ctx,
|
||||||
|
bus.Peer{Kind: "channel", ID: "default"},
|
||||||
|
"",
|
||||||
|
senderID,
|
||||||
|
chatID,
|
||||||
|
content,
|
||||||
|
[]string{},
|
||||||
|
metadata,
|
||||||
|
sender,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) {
|
func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) {
|
||||||
|
|
@ -185,7 +217,12 @@ func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) {
|
||||||
|
|
||||||
func (c *MaixCamChannel) Stop(ctx context.Context) error {
|
func (c *MaixCamChannel) Stop(ctx context.Context) error {
|
||||||
logger.InfoC("maixcam", "Stopping MaixCam channel")
|
logger.InfoC("maixcam", "Stopping MaixCam channel")
|
||||||
c.setRunning(false)
|
c.SetRunning(false)
|
||||||
|
|
||||||
|
// Cancel context first to signal goroutines to exit
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
if c.listener != nil {
|
if c.listener != nil {
|
||||||
c.listener.Close()
|
c.listener.Close()
|
||||||
|
|
@ -205,7 +242,14 @@ func (c *MaixCamChannel) Stop(ctx context.Context) error {
|
||||||
|
|
||||||
func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return fmt.Errorf("maixcam channel not running")
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ctx before entering write path
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
c.clientsMux.RLock()
|
c.clientsMux.RLock()
|
||||||
|
|
@ -230,13 +274,15 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
|
||||||
|
|
||||||
var sendErr error
|
var sendErr error
|
||||||
for conn := range c.clients {
|
for conn := range c.clients {
|
||||||
|
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
if _, err := conn.Write(data); err != nil {
|
if _, err := conn.Write(data); err != nil {
|
||||||
logger.ErrorCF("maixcam", "Failed to send to client", map[string]any{
|
logger.ErrorCF("maixcam", "Failed to send to client", map[string]any{
|
||||||
"client": conn.RemoteAddr().String(),
|
"client": conn.RemoteAddr().String(),
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
sendErr = err
|
sendErr = fmt.Errorf("maixcam send: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
_ = conn.SetWriteDeadline(time.Time{})
|
||||||
}
|
}
|
||||||
|
|
||||||
return sendErr
|
return sendErr
|
||||||
|
|
@ -8,32 +8,152 @@ package channels
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/constants"
|
"github.com/sipeed/picoclaw/pkg/constants"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/health"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultChannelQueueSize = 16
|
||||||
|
defaultRateLimit = 10 // default 10 msg/s
|
||||||
|
maxRetries = 3
|
||||||
|
rateLimitDelay = 1 * time.Second
|
||||||
|
baseBackoff = 500 * time.Millisecond
|
||||||
|
maxBackoff = 8 * time.Second
|
||||||
|
|
||||||
|
janitorInterval = 10 * time.Second
|
||||||
|
typingStopTTL = 5 * time.Minute
|
||||||
|
placeholderTTL = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// typingEntry wraps a typing stop function with a creation timestamp for TTL eviction.
|
||||||
|
type typingEntry struct {
|
||||||
|
stop func()
|
||||||
|
createdAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// reactionEntry wraps a reaction undo function with a creation timestamp for TTL eviction.
|
||||||
|
type reactionEntry struct {
|
||||||
|
undo func()
|
||||||
|
createdAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeholderEntry wraps a placeholder ID with a creation timestamp for TTL eviction.
|
||||||
|
type placeholderEntry struct {
|
||||||
|
id string
|
||||||
|
createdAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// channelRateConfig maps channel name to per-second rate limit.
|
||||||
|
var channelRateConfig = map[string]float64{
|
||||||
|
"telegram": 20,
|
||||||
|
"discord": 1,
|
||||||
|
"slack": 1,
|
||||||
|
"line": 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
type channelWorker struct {
|
||||||
|
ch Channel
|
||||||
|
queue chan bus.OutboundMessage
|
||||||
|
mediaQueue chan bus.OutboundMediaMessage
|
||||||
|
done chan struct{}
|
||||||
|
mediaDone chan struct{}
|
||||||
|
limiter *rate.Limiter
|
||||||
|
}
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
channels map[string]Channel
|
channels map[string]Channel
|
||||||
|
workers map[string]*channelWorker
|
||||||
bus *bus.MessageBus
|
bus *bus.MessageBus
|
||||||
config *config.Config
|
config *config.Config
|
||||||
|
mediaStore media.MediaStore
|
||||||
dispatchTask *asyncTask
|
dispatchTask *asyncTask
|
||||||
|
mux *http.ServeMux
|
||||||
|
httpServer *http.Server
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
placeholders sync.Map // "channel:chatID" → placeholderID (string)
|
||||||
|
typingStops sync.Map // "channel:chatID" → func()
|
||||||
|
reactionUndos sync.Map // "channel:chatID" → reactionEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
type asyncTask struct {
|
type asyncTask struct {
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(cfg *config.Config, messageBus *bus.MessageBus) (*Manager, error) {
|
// RecordPlaceholder registers a placeholder message for later editing.
|
||||||
|
// Implements PlaceholderRecorder.
|
||||||
|
func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
|
||||||
|
key := channel + ":" + chatID
|
||||||
|
m.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordTypingStop registers a typing stop function for later invocation.
|
||||||
|
// Implements PlaceholderRecorder.
|
||||||
|
func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
|
||||||
|
key := channel + ":" + chatID
|
||||||
|
m.typingStops.Store(key, typingEntry{stop: stop, createdAt: time.Now()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordReactionUndo registers a reaction undo function for later invocation.
|
||||||
|
// Implements PlaceholderRecorder.
|
||||||
|
func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) {
|
||||||
|
key := channel + ":" + chatID
|
||||||
|
m.reactionUndos.Store(key, reactionEntry{undo: undo, createdAt: time.Now()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// preSend handles typing stop, reaction undo, and placeholder editing before sending a message.
|
||||||
|
// Returns true if the message was edited into a placeholder (skip Send).
|
||||||
|
func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool {
|
||||||
|
key := name + ":" + msg.ChatID
|
||||||
|
|
||||||
|
// 1. Stop typing
|
||||||
|
if v, loaded := m.typingStops.LoadAndDelete(key); loaded {
|
||||||
|
if entry, ok := v.(typingEntry); ok {
|
||||||
|
entry.stop() // idempotent, safe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Undo reaction
|
||||||
|
if v, loaded := m.reactionUndos.LoadAndDelete(key); loaded {
|
||||||
|
if entry, ok := v.(reactionEntry); ok {
|
||||||
|
entry.undo() // idempotent, safe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try editing placeholder
|
||||||
|
if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
|
||||||
|
if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
|
||||||
|
if editor, ok := ch.(MessageEditor); ok {
|
||||||
|
if err := editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content); err == nil {
|
||||||
|
return true // edited successfully, skip Send
|
||||||
|
}
|
||||||
|
// edit failed → fall through to normal Send
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.MediaStore) (*Manager, error) {
|
||||||
m := &Manager{
|
m := &Manager{
|
||||||
channels: make(map[string]Channel),
|
channels: make(map[string]Channel),
|
||||||
|
workers: make(map[string]*channelWorker),
|
||||||
bus: messageBus,
|
bus: messageBus,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
mediaStore: store,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.initChannels(); err != nil {
|
if err := m.initChannels(); err != nil {
|
||||||
|
|
@ -43,163 +163,104 @@ func NewManager(cfg *config.Config, messageBus *bus.MessageBus) (*Manager, error
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initChannel is a helper that looks up a factory by name and creates the channel.
|
||||||
|
func (m *Manager) initChannel(name, displayName string) {
|
||||||
|
f, ok := getFactory(name)
|
||||||
|
if !ok {
|
||||||
|
logger.WarnCF("channels", "Factory not registered", map[string]any{
|
||||||
|
"channel": displayName,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.DebugCF("channels", "Attempting to initialize channel", map[string]any{
|
||||||
|
"channel": displayName,
|
||||||
|
})
|
||||||
|
ch, err := f(m.config, m.bus)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("channels", "Failed to initialize channel", map[string]any{
|
||||||
|
"channel": displayName,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Inject MediaStore if channel supports it
|
||||||
|
if m.mediaStore != nil {
|
||||||
|
if setter, ok := ch.(interface{ SetMediaStore(s media.MediaStore) }); ok {
|
||||||
|
setter.SetMediaStore(m.mediaStore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Inject PlaceholderRecorder if channel supports it
|
||||||
|
if setter, ok := ch.(interface{ SetPlaceholderRecorder(r PlaceholderRecorder) }); ok {
|
||||||
|
setter.SetPlaceholderRecorder(m)
|
||||||
|
}
|
||||||
|
// Inject owner reference so BaseChannel.HandleMessage can auto-trigger typing/reaction
|
||||||
|
if setter, ok := ch.(interface{ SetOwner(ch Channel) }); ok {
|
||||||
|
setter.SetOwner(ch)
|
||||||
|
}
|
||||||
|
m.channels[name] = ch
|
||||||
|
logger.InfoCF("channels", "Channel enabled successfully", map[string]any{
|
||||||
|
"channel": displayName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) initChannels() error {
|
func (m *Manager) initChannels() error {
|
||||||
logger.InfoC("channels", "Initializing channel manager")
|
logger.InfoC("channels", "Initializing channel manager")
|
||||||
|
|
||||||
if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "" {
|
if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "" {
|
||||||
logger.DebugC("channels", "Attempting to initialize Telegram channel")
|
m.initChannel("telegram", "Telegram")
|
||||||
telegram, err := NewTelegramChannel(m.config, m.bus)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
m.channels["telegram"] = telegram
|
|
||||||
logger.InfoC("channels", "Telegram channel enabled successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.config.Channels.WhatsApp.Enabled && m.config.Channels.WhatsApp.BridgeURL != "" {
|
if m.config.Channels.WhatsApp.Enabled {
|
||||||
logger.DebugC("channels", "Attempting to initialize WhatsApp channel")
|
waCfg := m.config.Channels.WhatsApp
|
||||||
whatsapp, err := NewWhatsAppChannel(m.config.Channels.WhatsApp, m.bus)
|
if waCfg.UseNative {
|
||||||
if err != nil {
|
m.initChannel("whatsapp_native", "WhatsApp Native")
|
||||||
logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]any{
|
} else if waCfg.BridgeURL != "" {
|
||||||
"error": err.Error(),
|
m.initChannel("whatsapp", "WhatsApp")
|
||||||
})
|
|
||||||
} else {
|
|
||||||
m.channels["whatsapp"] = whatsapp
|
|
||||||
logger.InfoC("channels", "WhatsApp channel enabled successfully")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.config.Channels.Feishu.Enabled {
|
if m.config.Channels.Feishu.Enabled {
|
||||||
logger.DebugC("channels", "Attempting to initialize Feishu channel")
|
m.initChannel("feishu", "Feishu")
|
||||||
feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.bus)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
m.channels["feishu"] = feishu
|
|
||||||
logger.InfoC("channels", "Feishu channel enabled successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "" {
|
if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "" {
|
||||||
logger.DebugC("channels", "Attempting to initialize Discord channel")
|
m.initChannel("discord", "Discord")
|
||||||
discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
m.channels["discord"] = discord
|
|
||||||
logger.InfoC("channels", "Discord channel enabled successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.config.Channels.MaixCam.Enabled {
|
if m.config.Channels.MaixCam.Enabled {
|
||||||
logger.DebugC("channels", "Attempting to initialize MaixCam channel")
|
m.initChannel("maixcam", "MaixCam")
|
||||||
maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.bus)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
m.channels["maixcam"] = maixcam
|
|
||||||
logger.InfoC("channels", "MaixCam channel enabled successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.config.Channels.QQ.Enabled {
|
if m.config.Channels.QQ.Enabled {
|
||||||
logger.DebugC("channels", "Attempting to initialize QQ channel")
|
m.initChannel("qq", "QQ")
|
||||||
qq, err := NewQQChannel(m.config.Channels.QQ, m.bus)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
m.channels["qq"] = qq
|
|
||||||
logger.InfoC("channels", "QQ channel enabled successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.config.Channels.DingTalk.Enabled && m.config.Channels.DingTalk.ClientID != "" {
|
if m.config.Channels.DingTalk.Enabled && m.config.Channels.DingTalk.ClientID != "" {
|
||||||
logger.DebugC("channels", "Attempting to initialize DingTalk channel")
|
m.initChannel("dingtalk", "DingTalk")
|
||||||
dingtalk, err := NewDingTalkChannel(m.config.Channels.DingTalk, m.bus)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
m.channels["dingtalk"] = dingtalk
|
|
||||||
logger.InfoC("channels", "DingTalk channel enabled successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.config.Channels.Slack.Enabled && m.config.Channels.Slack.BotToken != "" {
|
if m.config.Channels.Slack.Enabled && m.config.Channels.Slack.BotToken != "" {
|
||||||
logger.DebugC("channels", "Attempting to initialize Slack channel")
|
m.initChannel("slack", "Slack")
|
||||||
slackCh, err := NewSlackChannel(m.config.Channels.Slack, m.bus)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
m.channels["slack"] = slackCh
|
|
||||||
logger.InfoC("channels", "Slack channel enabled successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" {
|
if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" {
|
||||||
logger.DebugC("channels", "Attempting to initialize LINE channel")
|
m.initChannel("line", "LINE")
|
||||||
line, err := NewLINEChannel(m.config.Channels.LINE, m.bus)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
m.channels["line"] = line
|
|
||||||
logger.InfoC("channels", "LINE channel enabled successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.config.Channels.OneBot.Enabled && m.config.Channels.OneBot.WSUrl != "" {
|
if m.config.Channels.OneBot.Enabled && m.config.Channels.OneBot.WSUrl != "" {
|
||||||
logger.DebugC("channels", "Attempting to initialize OneBot channel")
|
m.initChannel("onebot", "OneBot")
|
||||||
onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.bus)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
m.channels["onebot"] = onebot
|
|
||||||
logger.InfoC("channels", "OneBot channel enabled successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.config.Channels.WeCom.Enabled && m.config.Channels.WeCom.Token != "" {
|
if m.config.Channels.WeCom.Enabled && m.config.Channels.WeCom.Token != "" {
|
||||||
logger.DebugC("channels", "Attempting to initialize WeCom channel")
|
m.initChannel("wecom", "WeCom")
|
||||||
wecom, err := NewWeComBotChannel(m.config.Channels.WeCom, m.bus)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("channels", "Failed to initialize WeCom channel", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
m.channels["wecom"] = wecom
|
|
||||||
logger.InfoC("channels", "WeCom channel enabled successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" {
|
if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" {
|
||||||
logger.DebugC("channels", "Attempting to initialize WeCom App channel")
|
m.initChannel("wecom_app", "WeCom App")
|
||||||
wecomApp, err := NewWeComAppChannel(m.config.Channels.WeComApp, m.bus)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("channels", "Failed to initialize WeCom App channel", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
m.channels["wecom_app"] = wecomApp
|
|
||||||
logger.InfoC("channels", "WeCom App channel enabled successfully")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.config.Channels.Pico.Enabled && m.config.Channels.Pico.Token != "" {
|
||||||
|
m.initChannel("pico", "Pico")
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
||||||
|
|
@ -209,6 +270,43 @@ func (m *Manager) initChannels() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetupHTTPServer creates a shared HTTP server with the given listen address.
|
||||||
|
// It registers health endpoints from the health server and discovers channels
|
||||||
|
// that implement WebhookHandler and/or HealthChecker to register their handlers.
|
||||||
|
func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) {
|
||||||
|
m.mux = http.NewServeMux()
|
||||||
|
|
||||||
|
// Register health endpoints
|
||||||
|
if healthServer != nil {
|
||||||
|
healthServer.RegisterOnMux(m.mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover and register webhook handlers and health checkers
|
||||||
|
for name, ch := range m.channels {
|
||||||
|
if wh, ok := ch.(WebhookHandler); ok {
|
||||||
|
m.mux.Handle(wh.WebhookPath(), wh)
|
||||||
|
logger.InfoCF("channels", "Webhook handler registered", map[string]any{
|
||||||
|
"channel": name,
|
||||||
|
"path": wh.WebhookPath(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if hc, ok := ch.(HealthChecker); ok {
|
||||||
|
m.mux.HandleFunc(hc.HealthPath(), hc.HealthHandler)
|
||||||
|
logger.InfoCF("channels", "Health endpoint registered", map[string]any{
|
||||||
|
"channel": name,
|
||||||
|
"path": hc.HealthPath(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.httpServer = &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: m.mux,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) StartAll(ctx context.Context) error {
|
func (m *Manager) StartAll(ctx context.Context) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
@ -223,8 +321,6 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
||||||
dispatchCtx, cancel := context.WithCancel(ctx)
|
dispatchCtx, cancel := context.WithCancel(ctx)
|
||||||
m.dispatchTask = &asyncTask{cancel: cancel}
|
m.dispatchTask = &asyncTask{cancel: cancel}
|
||||||
|
|
||||||
go m.dispatchOutbound(dispatchCtx)
|
|
||||||
|
|
||||||
for name, channel := range m.channels {
|
for name, channel := range m.channels {
|
||||||
logger.InfoCF("channels", "Starting channel", map[string]any{
|
logger.InfoCF("channels", "Starting channel", map[string]any{
|
||||||
"channel": name,
|
"channel": name,
|
||||||
|
|
@ -234,7 +330,34 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
||||||
"channel": name,
|
"channel": name,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
// Lazily create worker only after channel starts successfully
|
||||||
|
w := newChannelWorker(name, channel)
|
||||||
|
m.workers[name] = w
|
||||||
|
go m.runWorker(dispatchCtx, name, w)
|
||||||
|
go m.runMediaWorker(dispatchCtx, name, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the dispatcher that reads from the bus and routes to workers
|
||||||
|
go m.dispatchOutbound(dispatchCtx)
|
||||||
|
go m.dispatchOutboundMedia(dispatchCtx)
|
||||||
|
|
||||||
|
// Start the TTL janitor that cleans up stale typing/placeholder entries
|
||||||
|
go m.runTTLJanitor(dispatchCtx)
|
||||||
|
|
||||||
|
// Start shared HTTP server if configured
|
||||||
|
if m.httpServer != nil {
|
||||||
|
go func() {
|
||||||
|
logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{
|
||||||
|
"addr": m.httpServer.Addr,
|
||||||
|
})
|
||||||
|
if err := m.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.ErrorCF("channels", "Shared HTTP server error", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InfoC("channels", "All channels started")
|
logger.InfoC("channels", "All channels started")
|
||||||
|
|
@ -247,11 +370,48 @@ func (m *Manager) StopAll(ctx context.Context) error {
|
||||||
|
|
||||||
logger.InfoC("channels", "Stopping all channels")
|
logger.InfoC("channels", "Stopping all channels")
|
||||||
|
|
||||||
|
// Shutdown shared HTTP server first
|
||||||
|
if m.httpServer != nil {
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := m.httpServer.Shutdown(shutdownCtx); err != nil {
|
||||||
|
logger.ErrorCF("channels", "Shared HTTP server shutdown error", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
m.httpServer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel dispatcher
|
||||||
if m.dispatchTask != nil {
|
if m.dispatchTask != nil {
|
||||||
m.dispatchTask.cancel()
|
m.dispatchTask.cancel()
|
||||||
m.dispatchTask = nil
|
m.dispatchTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close all worker queues and wait for them to drain
|
||||||
|
for _, w := range m.workers {
|
||||||
|
if w != nil {
|
||||||
|
close(w.queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, w := range m.workers {
|
||||||
|
if w != nil {
|
||||||
|
<-w.done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Close all media worker queues and wait for them to drain
|
||||||
|
for _, w := range m.workers {
|
||||||
|
if w != nil {
|
||||||
|
close(w.mediaQueue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, w := range m.workers {
|
||||||
|
if w != nil {
|
||||||
|
<-w.mediaDone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop all channels
|
||||||
for name, channel := range m.channels {
|
for name, channel := range m.channels {
|
||||||
logger.InfoCF("channels", "Stopping channel", map[string]any{
|
logger.InfoCF("channels", "Stopping channel", map[string]any{
|
||||||
"channel": name,
|
"channel": name,
|
||||||
|
|
@ -268,18 +428,125 @@ func (m *Manager) StopAll(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newChannelWorker creates a channelWorker with a rate limiter configured
|
||||||
|
// for the given channel name.
|
||||||
|
func newChannelWorker(name string, ch Channel) *channelWorker {
|
||||||
|
rateVal := float64(defaultRateLimit)
|
||||||
|
if r, ok := channelRateConfig[name]; ok {
|
||||||
|
rateVal = r
|
||||||
|
}
|
||||||
|
burst := int(math.Max(1, math.Ceil(rateVal/2)))
|
||||||
|
|
||||||
|
return &channelWorker{
|
||||||
|
ch: ch,
|
||||||
|
queue: make(chan bus.OutboundMessage, defaultChannelQueueSize),
|
||||||
|
mediaQueue: make(chan bus.OutboundMediaMessage, defaultChannelQueueSize),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
mediaDone: make(chan struct{}),
|
||||||
|
limiter: rate.NewLimiter(rate.Limit(rateVal), burst),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runWorker processes outbound messages for a single channel, splitting
|
||||||
|
// messages that exceed the channel's maximum message length.
|
||||||
|
func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) {
|
||||||
|
defer close(w.done)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg, ok := <-w.queue:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
maxLen := 0
|
||||||
|
if mlp, ok := w.ch.(MessageLengthProvider); ok {
|
||||||
|
maxLen = mlp.MaxMessageLength()
|
||||||
|
}
|
||||||
|
if maxLen > 0 && len([]rune(msg.Content)) > maxLen {
|
||||||
|
chunks := SplitMessage(msg.Content, maxLen)
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
chunkMsg := msg
|
||||||
|
chunkMsg.Content = chunk
|
||||||
|
m.sendWithRetry(ctx, name, w, chunkMsg)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m.sendWithRetry(ctx, name, w, msg)
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendWithRetry sends a message through the channel with rate limiting and
|
||||||
|
// retry logic. It classifies errors to determine the retry strategy:
|
||||||
|
// - ErrNotRunning / ErrSendFailed: permanent, no retry
|
||||||
|
// - ErrRateLimit: fixed delay retry
|
||||||
|
// - ErrTemporary / unknown: exponential backoff retry
|
||||||
|
func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) {
|
||||||
|
// Rate limit: wait for token
|
||||||
|
if err := w.limiter.Wait(ctx); err != nil {
|
||||||
|
// ctx canceled, shutting down
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-send: stop typing and try to edit placeholder
|
||||||
|
if m.preSend(ctx, name, msg, w.ch) {
|
||||||
|
return // placeholder was edited successfully, skip Send
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||||
|
lastErr = w.ch.Send(ctx, msg)
|
||||||
|
if lastErr == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permanent failures — don't retry
|
||||||
|
if errors.Is(lastErr, ErrNotRunning) || errors.Is(lastErr, ErrSendFailed) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last attempt exhausted — don't sleep
|
||||||
|
if attempt == maxRetries {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit error — fixed delay
|
||||||
|
if errors.Is(lastErr, ErrRateLimit) {
|
||||||
|
select {
|
||||||
|
case <-time.After(rateLimitDelay):
|
||||||
|
continue
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrTemporary or unknown error — exponential backoff
|
||||||
|
backoff := min(time.Duration(float64(baseBackoff)*math.Pow(2, float64(attempt))), maxBackoff)
|
||||||
|
select {
|
||||||
|
case <-time.After(backoff):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All retries exhausted or permanent failure
|
||||||
|
logger.ErrorCF("channels", "Send failed", map[string]any{
|
||||||
|
"channel": name,
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"error": lastErr.Error(),
|
||||||
|
"retries": maxRetries,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) dispatchOutbound(ctx context.Context) {
|
func (m *Manager) dispatchOutbound(ctx context.Context) {
|
||||||
logger.InfoC("channels", "Outbound dispatcher started")
|
logger.InfoC("channels", "Outbound dispatcher started")
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
logger.InfoC("channels", "Outbound dispatcher stopped")
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
msg, ok := m.bus.SubscribeOutbound(ctx)
|
msg, ok := m.bus.SubscribeOutbound(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
logger.InfoC("channels", "Outbound dispatcher stopped")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Silently skip internal channels
|
// Silently skip internal channels
|
||||||
|
|
@ -288,7 +555,8 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
channel, exists := m.channels[msg.Channel]
|
_, exists := m.channels[msg.Channel]
|
||||||
|
w, wExists := m.workers[msg.Channel]
|
||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
|
|
@ -298,13 +566,179 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := channel.Send(ctx, msg); err != nil {
|
if wExists && w != nil {
|
||||||
logger.ErrorCF("channels", "Error sending message to channel", map[string]any{
|
select {
|
||||||
|
case w.queue <- msg:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if exists {
|
||||||
|
logger.WarnCF("channels", "Channel has no active worker, skipping message", map[string]any{
|
||||||
"channel": msg.Channel,
|
"channel": msg.Channel,
|
||||||
"error": err.Error(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) dispatchOutboundMedia(ctx context.Context) {
|
||||||
|
logger.InfoC("channels", "Outbound media dispatcher started")
|
||||||
|
|
||||||
|
for {
|
||||||
|
msg, ok := m.bus.SubscribeOutboundMedia(ctx)
|
||||||
|
if !ok {
|
||||||
|
logger.InfoC("channels", "Outbound media dispatcher stopped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silently skip internal channels
|
||||||
|
if constants.IsInternalChannel(msg.Channel) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
_, exists := m.channels[msg.Channel]
|
||||||
|
w, wExists := m.workers[msg.Channel]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
logger.WarnCF("channels", "Unknown channel for outbound media message", map[string]any{
|
||||||
|
"channel": msg.Channel,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if wExists && w != nil {
|
||||||
|
select {
|
||||||
|
case w.mediaQueue <- msg:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if exists {
|
||||||
|
logger.WarnCF("channels", "Channel has no active worker, skipping media message", map[string]any{
|
||||||
|
"channel": msg.Channel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runMediaWorker processes outbound media messages for a single channel.
|
||||||
|
func (m *Manager) runMediaWorker(ctx context.Context, name string, w *channelWorker) {
|
||||||
|
defer close(w.mediaDone)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg, ok := <-w.mediaQueue:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.sendMediaWithRetry(ctx, name, w, msg)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMediaWithRetry sends a media message through the channel with rate limiting and
|
||||||
|
// retry logic. If the channel does not implement MediaSender, it silently skips.
|
||||||
|
func (m *Manager) sendMediaWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMediaMessage) {
|
||||||
|
ms, ok := w.ch.(MediaSender)
|
||||||
|
if !ok {
|
||||||
|
logger.DebugCF("channels", "Channel does not support MediaSender, skipping media", map[string]any{
|
||||||
|
"channel": name,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit: wait for token
|
||||||
|
if err := w.limiter.Wait(ctx); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||||
|
lastErr = ms.SendMedia(ctx, msg)
|
||||||
|
if lastErr == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permanent failures — don't retry
|
||||||
|
if errors.Is(lastErr, ErrNotRunning) || errors.Is(lastErr, ErrSendFailed) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last attempt exhausted — don't sleep
|
||||||
|
if attempt == maxRetries {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit error — fixed delay
|
||||||
|
if errors.Is(lastErr, ErrRateLimit) {
|
||||||
|
select {
|
||||||
|
case <-time.After(rateLimitDelay):
|
||||||
|
continue
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrTemporary or unknown error — exponential backoff
|
||||||
|
backoff := min(time.Duration(float64(baseBackoff)*math.Pow(2, float64(attempt))), maxBackoff)
|
||||||
|
select {
|
||||||
|
case <-time.After(backoff):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All retries exhausted or permanent failure
|
||||||
|
logger.ErrorCF("channels", "SendMedia failed", map[string]any{
|
||||||
|
"channel": name,
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"error": lastErr.Error(),
|
||||||
|
"retries": maxRetries,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTTLJanitor periodically scans the typingStops and placeholders maps
|
||||||
|
// and evicts entries that have exceeded their TTL. This prevents memory
|
||||||
|
// accumulation when outbound paths fail to trigger preSend (e.g. LLM errors).
|
||||||
|
func (m *Manager) runTTLJanitor(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(janitorInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case now := <-ticker.C:
|
||||||
|
m.typingStops.Range(func(key, value any) bool {
|
||||||
|
if entry, ok := value.(typingEntry); ok {
|
||||||
|
if now.Sub(entry.createdAt) > typingStopTTL {
|
||||||
|
if _, loaded := m.typingStops.LoadAndDelete(key); loaded {
|
||||||
|
entry.stop() // idempotent, safe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
m.reactionUndos.Range(func(key, value any) bool {
|
||||||
|
if entry, ok := value.(reactionEntry); ok {
|
||||||
|
if now.Sub(entry.createdAt) > typingStopTTL {
|
||||||
|
if _, loaded := m.reactionUndos.LoadAndDelete(key); loaded {
|
||||||
|
entry.undo() // idempotent, safe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
m.placeholders.Range(func(key, value any) bool {
|
||||||
|
if entry, ok := value.(placeholderEntry); ok {
|
||||||
|
if now.Sub(entry.createdAt) > placeholderTTL {
|
||||||
|
m.placeholders.Delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -349,12 +783,20 @@ func (m *Manager) RegisterChannel(name string, channel Channel) {
|
||||||
func (m *Manager) UnregisterChannel(name string) {
|
func (m *Manager) UnregisterChannel(name string) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
if w, ok := m.workers[name]; ok && w != nil {
|
||||||
|
close(w.queue)
|
||||||
|
<-w.done
|
||||||
|
close(w.mediaQueue)
|
||||||
|
<-w.mediaDone
|
||||||
|
}
|
||||||
|
delete(m.workers, name)
|
||||||
delete(m.channels, name)
|
delete(m.channels, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error {
|
func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
channel, exists := m.channels[channelName]
|
_, exists := m.channels[channelName]
|
||||||
|
w, wExists := m.workers[channelName]
|
||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
|
|
@ -367,5 +809,16 @@ func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, conten
|
||||||
Content: content,
|
Content: content,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if wExists && w != nil {
|
||||||
|
select {
|
||||||
|
case w.queue <- msg:
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: direct send (should not happen)
|
||||||
|
channel, _ := m.channels[channelName]
|
||||||
return channel.Send(ctx, msg)
|
return channel.Send(ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
862
pkg/channels/manager_test.go
Normal file
862
pkg/channels/manager_test.go
Normal file
|
|
@ -0,0 +1,862 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockChannel is a test double that delegates Send to a configurable function.
|
||||||
|
type mockChannel struct {
|
||||||
|
BaseChannel
|
||||||
|
sendFn func(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
return m.sendFn(ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockChannel) Start(ctx context.Context) error { return nil }
|
||||||
|
func (m *mockChannel) Stop(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
// newTestManager creates a minimal Manager suitable for unit tests.
|
||||||
|
func newTestManager() *Manager {
|
||||||
|
return &Manager{
|
||||||
|
channels: make(map[string]Channel),
|
||||||
|
workers: make(map[string]*channelWorker),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendWithRetry_Success(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var callCount int
|
||||||
|
ch := &mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
callCount++
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w := &channelWorker{
|
||||||
|
ch: ch,
|
||||||
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
|
||||||
|
|
||||||
|
m.sendWithRetry(ctx, "test", w, msg)
|
||||||
|
|
||||||
|
if callCount != 1 {
|
||||||
|
t.Fatalf("expected 1 Send call, got %d", callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendWithRetry_TemporaryThenSuccess(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var callCount int
|
||||||
|
ch := &mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
callCount++
|
||||||
|
if callCount <= 2 {
|
||||||
|
return fmt.Errorf("network error: %w", ErrTemporary)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w := &channelWorker{
|
||||||
|
ch: ch,
|
||||||
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
|
||||||
|
|
||||||
|
m.sendWithRetry(ctx, "test", w, msg)
|
||||||
|
|
||||||
|
if callCount != 3 {
|
||||||
|
t.Fatalf("expected 3 Send calls (2 failures + 1 success), got %d", callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendWithRetry_PermanentFailure(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var callCount int
|
||||||
|
ch := &mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
callCount++
|
||||||
|
return fmt.Errorf("bad chat ID: %w", ErrSendFailed)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w := &channelWorker{
|
||||||
|
ch: ch,
|
||||||
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
|
||||||
|
|
||||||
|
m.sendWithRetry(ctx, "test", w, msg)
|
||||||
|
|
||||||
|
if callCount != 1 {
|
||||||
|
t.Fatalf("expected 1 Send call (no retry for permanent failure), got %d", callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendWithRetry_NotRunning(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var callCount int
|
||||||
|
ch := &mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
callCount++
|
||||||
|
return ErrNotRunning
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w := &channelWorker{
|
||||||
|
ch: ch,
|
||||||
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
|
||||||
|
|
||||||
|
m.sendWithRetry(ctx, "test", w, msg)
|
||||||
|
|
||||||
|
if callCount != 1 {
|
||||||
|
t.Fatalf("expected 1 Send call (no retry for ErrNotRunning), got %d", callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendWithRetry_RateLimitRetry(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var callCount int
|
||||||
|
ch := &mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
callCount++
|
||||||
|
if callCount == 1 {
|
||||||
|
return fmt.Errorf("429: %w", ErrRateLimit)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w := &channelWorker{
|
||||||
|
ch: ch,
|
||||||
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
m.sendWithRetry(ctx, "test", w, msg)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if callCount != 2 {
|
||||||
|
t.Fatalf("expected 2 Send calls (1 rate limit + 1 success), got %d", callCount)
|
||||||
|
}
|
||||||
|
// Should have waited at least rateLimitDelay (1s) but allow some slack
|
||||||
|
if elapsed < 900*time.Millisecond {
|
||||||
|
t.Fatalf("expected at least ~1s delay for rate limit retry, got %v", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendWithRetry_MaxRetriesExhausted(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var callCount int
|
||||||
|
ch := &mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
callCount++
|
||||||
|
return fmt.Errorf("timeout: %w", ErrTemporary)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w := &channelWorker{
|
||||||
|
ch: ch,
|
||||||
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
|
||||||
|
|
||||||
|
m.sendWithRetry(ctx, "test", w, msg)
|
||||||
|
|
||||||
|
expected := maxRetries + 1 // initial attempt + maxRetries retries
|
||||||
|
if callCount != expected {
|
||||||
|
t.Fatalf("expected %d Send calls, got %d", expected, callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendWithRetry_UnknownError(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var callCount int
|
||||||
|
ch := &mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
callCount++
|
||||||
|
if callCount == 1 {
|
||||||
|
return errors.New("random unexpected error")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w := &channelWorker{
|
||||||
|
ch: ch,
|
||||||
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
|
||||||
|
|
||||||
|
m.sendWithRetry(ctx, "test", w, msg)
|
||||||
|
|
||||||
|
if callCount != 2 {
|
||||||
|
t.Fatalf("expected 2 Send calls (unknown error treated as temporary), got %d", callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendWithRetry_ContextCancelled(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var callCount int
|
||||||
|
ch := &mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
callCount++
|
||||||
|
return fmt.Errorf("timeout: %w", ErrTemporary)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w := &channelWorker{
|
||||||
|
ch: ch,
|
||||||
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
|
||||||
|
|
||||||
|
// Cancel context after first Send attempt returns
|
||||||
|
ch.sendFn = func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
callCount++
|
||||||
|
cancel()
|
||||||
|
return fmt.Errorf("timeout: %w", ErrTemporary)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.sendWithRetry(ctx, "test", w, msg)
|
||||||
|
|
||||||
|
// Should have called Send once, then noticed ctx canceled during backoff
|
||||||
|
if callCount != 1 {
|
||||||
|
t.Fatalf("expected 1 Send call before context cancellation, got %d", callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkerRateLimiter(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var sendTimes []time.Time
|
||||||
|
|
||||||
|
ch := &mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
mu.Lock()
|
||||||
|
sendTimes = append(sendTimes, time.Now())
|
||||||
|
mu.Unlock()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a worker with a low rate: 2 msg/s, burst 1
|
||||||
|
w := &channelWorker{
|
||||||
|
ch: ch,
|
||||||
|
queue: make(chan bus.OutboundMessage, 10),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
limiter: rate.NewLimiter(2, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
go m.runWorker(ctx, "test", w)
|
||||||
|
|
||||||
|
// Enqueue 4 messages
|
||||||
|
for i := range 4 {
|
||||||
|
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: fmt.Sprintf("msg%d", i)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait enough time for all messages to be sent (4 msgs at 2/s = ~2s, give extra margin)
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
times := make([]time.Time, len(sendTimes))
|
||||||
|
copy(times, sendTimes)
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
if len(times) != 4 {
|
||||||
|
t.Fatalf("expected 4 sends, got %d", len(times))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify rate limiting: total duration should be at least 1s
|
||||||
|
// (first message immediate, then ~500ms between each subsequent one at 2/s)
|
||||||
|
totalDuration := times[len(times)-1].Sub(times[0])
|
||||||
|
if totalDuration < 1*time.Second {
|
||||||
|
t.Fatalf("expected total duration >= 1s for 4 msgs at 2/s rate, got %v", totalDuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewChannelWorker_DefaultRate(t *testing.T) {
|
||||||
|
ch := &mockChannel{}
|
||||||
|
w := newChannelWorker("unknown_channel", ch)
|
||||||
|
|
||||||
|
if w.limiter == nil {
|
||||||
|
t.Fatal("expected limiter to be non-nil")
|
||||||
|
}
|
||||||
|
if w.limiter.Limit() != rate.Limit(defaultRateLimit) {
|
||||||
|
t.Fatalf("expected rate limit %v, got %v", rate.Limit(defaultRateLimit), w.limiter.Limit())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewChannelWorker_ConfiguredRate(t *testing.T) {
|
||||||
|
ch := &mockChannel{}
|
||||||
|
|
||||||
|
for name, expectedRate := range channelRateConfig {
|
||||||
|
w := newChannelWorker(name, ch)
|
||||||
|
if w.limiter.Limit() != rate.Limit(expectedRate) {
|
||||||
|
t.Fatalf("channel %s: expected rate %v, got %v", name, expectedRate, w.limiter.Limit())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWorker_MessageSplitting(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var received []string
|
||||||
|
|
||||||
|
ch := &mockChannelWithLength{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
|
||||||
|
mu.Lock()
|
||||||
|
received = append(received, msg.Content)
|
||||||
|
mu.Unlock()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
maxLen: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
w := &channelWorker{
|
||||||
|
ch: ch,
|
||||||
|
queue: make(chan bus.OutboundMessage, 10),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
go m.runWorker(ctx, "test", w)
|
||||||
|
|
||||||
|
// Send a message that should be split
|
||||||
|
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello world"}
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
count := len(received)
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
if count < 2 {
|
||||||
|
t.Fatalf("expected message to be split into at least 2 chunks, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockChannelWithLength implements MessageLengthProvider.
|
||||||
|
type mockChannelWithLength struct {
|
||||||
|
mockChannel
|
||||||
|
maxLen int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockChannelWithLength) MaxMessageLength() int {
|
||||||
|
return m.maxLen
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendWithRetry_ExponentialBackoff(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
var callTimes []time.Time
|
||||||
|
var callCount atomic.Int32
|
||||||
|
ch := &mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
callTimes = append(callTimes, time.Now())
|
||||||
|
callCount.Add(1)
|
||||||
|
return fmt.Errorf("timeout: %w", ErrTemporary)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w := &channelWorker{
|
||||||
|
ch: ch,
|
||||||
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
m.sendWithRetry(ctx, "test", w, msg)
|
||||||
|
totalElapsed := time.Since(start)
|
||||||
|
|
||||||
|
// With maxRetries=3: attempts at 0, ~500ms, ~1.5s, ~3.5s
|
||||||
|
// Total backoff: 500ms + 1s + 2s = 3.5s
|
||||||
|
// Allow some margin
|
||||||
|
if totalElapsed < 3*time.Second {
|
||||||
|
t.Fatalf("expected total elapsed >= 3s for exponential backoff, got %v", totalElapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if int(callCount.Load()) != maxRetries+1 {
|
||||||
|
t.Fatalf("expected %d calls, got %d", maxRetries+1, callCount.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Phase 10: preSend orchestration tests ---
|
||||||
|
|
||||||
|
// mockMessageEditor is a channel that supports MessageEditor.
|
||||||
|
type mockMessageEditor struct {
|
||||||
|
mockChannel
|
||||||
|
editFn func(ctx context.Context, chatID, messageID, content string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockMessageEditor) EditMessage(ctx context.Context, chatID, messageID, content string) error {
|
||||||
|
return m.editFn(ctx, chatID, messageID, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreSend_PlaceholderEditSuccess(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var sendCalled bool
|
||||||
|
var editCalled bool
|
||||||
|
|
||||||
|
ch := &mockMessageEditor{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
sendCalled = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, chatID, messageID, content string) error {
|
||||||
|
editCalled = true
|
||||||
|
if chatID != "123" {
|
||||||
|
t.Fatalf("expected chatID 123, got %s", chatID)
|
||||||
|
}
|
||||||
|
if messageID != "456" {
|
||||||
|
t.Fatalf("expected messageID 456, got %s", messageID)
|
||||||
|
}
|
||||||
|
if content != "hello" {
|
||||||
|
t.Fatalf("expected content 'hello', got %s", content)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register placeholder
|
||||||
|
m.RecordPlaceholder("test", "123", "456")
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
|
||||||
|
edited := m.preSend(context.Background(), "test", msg, ch)
|
||||||
|
|
||||||
|
if !edited {
|
||||||
|
t.Fatal("expected preSend to return true (placeholder edited)")
|
||||||
|
}
|
||||||
|
if !editCalled {
|
||||||
|
t.Fatal("expected EditMessage to be called")
|
||||||
|
}
|
||||||
|
if sendCalled {
|
||||||
|
t.Fatal("expected Send to NOT be called when placeholder edited")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
ch := &mockMessageEditor{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, _, _, _ string) error {
|
||||||
|
return fmt.Errorf("edit failed")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.RecordPlaceholder("test", "123", "456")
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
|
||||||
|
edited := m.preSend(context.Background(), "test", msg, ch)
|
||||||
|
|
||||||
|
if edited {
|
||||||
|
t.Fatal("expected preSend to return false when edit fails")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreSend_TypingStopCalled(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var stopCalled bool
|
||||||
|
|
||||||
|
ch := &mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.RecordTypingStop("test", "123", func() {
|
||||||
|
stopCalled = true
|
||||||
|
})
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
|
||||||
|
m.preSend(context.Background(), "test", msg, ch)
|
||||||
|
|
||||||
|
if !stopCalled {
|
||||||
|
t.Fatal("expected typing stop func to be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreSend_NoRegisteredState(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
ch := &mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
|
||||||
|
edited := m.preSend(context.Background(), "test", msg, ch)
|
||||||
|
|
||||||
|
if edited {
|
||||||
|
t.Fatal("expected preSend to return false with no registered state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreSend_TypingAndPlaceholder(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var stopCalled bool
|
||||||
|
var editCalled bool
|
||||||
|
|
||||||
|
ch := &mockMessageEditor{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, _, _, _ string) error {
|
||||||
|
editCalled = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.RecordTypingStop("test", "123", func() {
|
||||||
|
stopCalled = true
|
||||||
|
})
|
||||||
|
m.RecordPlaceholder("test", "123", "456")
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
|
||||||
|
edited := m.preSend(context.Background(), "test", msg, ch)
|
||||||
|
|
||||||
|
if !stopCalled {
|
||||||
|
t.Fatal("expected typing stop to be called")
|
||||||
|
}
|
||||||
|
if !editCalled {
|
||||||
|
t.Fatal("expected EditMessage to be called")
|
||||||
|
}
|
||||||
|
if !edited {
|
||||||
|
t.Fatal("expected preSend to return true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordPlaceholder_ConcurrentSafe(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range 100 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
chatID := fmt.Sprintf("chat_%d", i%10)
|
||||||
|
m.RecordPlaceholder("test", chatID, fmt.Sprintf("msg_%d", i))
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordTypingStop_ConcurrentSafe(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range 100 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
chatID := fmt.Sprintf("chat_%d", i%10)
|
||||||
|
m.RecordTypingStop("test", chatID, func() {})
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var sendCalled bool
|
||||||
|
|
||||||
|
ch := &mockMessageEditor{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
sendCalled = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, _, _, _ string) error {
|
||||||
|
return nil // edit succeeds
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.RecordPlaceholder("test", "123", "456")
|
||||||
|
|
||||||
|
w := &channelWorker{
|
||||||
|
ch: ch,
|
||||||
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
|
||||||
|
m.sendWithRetry(context.Background(), "test", w, msg)
|
||||||
|
|
||||||
|
if sendCalled {
|
||||||
|
t.Fatal("expected Send to NOT be called when placeholder was edited")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dispatcher exit tests (Step 1) ---
|
||||||
|
|
||||||
|
func TestDispatcherExitsOnCancel(t *testing.T) {
|
||||||
|
mb := bus.NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
m := &Manager{
|
||||||
|
channels: make(map[string]Channel),
|
||||||
|
workers: make(map[string]*channelWorker),
|
||||||
|
bus: mb,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
m.dispatchOutbound(ctx)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Cancel context and verify the dispatcher exits quickly
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// success
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("dispatchOutbound did not exit within 2s after context cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatcherMediaExitsOnCancel(t *testing.T) {
|
||||||
|
mb := bus.NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
m := &Manager{
|
||||||
|
channels: make(map[string]Channel),
|
||||||
|
workers: make(map[string]*channelWorker),
|
||||||
|
bus: mb,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
m.dispatchOutboundMedia(ctx)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// success
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("dispatchOutboundMedia did not exit within 2s after context cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TTL Janitor tests (Step 2) ---
|
||||||
|
|
||||||
|
func TestTypingStopJanitorEviction(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
var stopCalled atomic.Bool
|
||||||
|
// Store a typing entry with a creation time far in the past
|
||||||
|
m.typingStops.Store("test:123", typingEntry{
|
||||||
|
stop: func() { stopCalled.Store(true) },
|
||||||
|
createdAt: time.Now().Add(-10 * time.Minute), // well past typingStopTTL
|
||||||
|
})
|
||||||
|
|
||||||
|
// Run janitor with a short-lived context
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
// Manually trigger the janitor logic once by simulating a tick
|
||||||
|
go func() {
|
||||||
|
// Override janitor to run immediately
|
||||||
|
now := time.Now()
|
||||||
|
m.typingStops.Range(func(key, value any) bool {
|
||||||
|
if entry, ok := value.(typingEntry); ok {
|
||||||
|
if now.Sub(entry.createdAt) > typingStopTTL {
|
||||||
|
if _, loaded := m.typingStops.LoadAndDelete(key); loaded {
|
||||||
|
entry.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
|
||||||
|
if !stopCalled.Load() {
|
||||||
|
t.Fatal("expected typing stop function to be called by janitor eviction")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify entry was deleted
|
||||||
|
if _, loaded := m.typingStops.Load("test:123"); loaded {
|
||||||
|
t.Fatal("expected typing entry to be deleted after eviction")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaceholderJanitorEviction(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
// Store a placeholder entry with a creation time far in the past
|
||||||
|
m.placeholders.Store("test:456", placeholderEntry{
|
||||||
|
id: "msg_old",
|
||||||
|
createdAt: time.Now().Add(-20 * time.Minute), // well past placeholderTTL
|
||||||
|
})
|
||||||
|
|
||||||
|
// Simulate janitor logic
|
||||||
|
now := time.Now()
|
||||||
|
m.placeholders.Range(func(key, value any) bool {
|
||||||
|
if entry, ok := value.(placeholderEntry); ok {
|
||||||
|
if now.Sub(entry.createdAt) > placeholderTTL {
|
||||||
|
m.placeholders.Delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify entry was deleted
|
||||||
|
if _, loaded := m.placeholders.Load("test:456"); loaded {
|
||||||
|
t.Fatal("expected placeholder entry to be deleted after eviction")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreSendStillWorksWithWrappedTypes(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
var stopCalled bool
|
||||||
|
var editCalled bool
|
||||||
|
|
||||||
|
ch := &mockMessageEditor{
|
||||||
|
mockChannel: mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
editFn: func(_ context.Context, chatID, messageID, content string) error {
|
||||||
|
editCalled = true
|
||||||
|
if messageID != "ph_id" {
|
||||||
|
t.Fatalf("expected messageID ph_id, got %s", messageID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the new wrapped types via the public API
|
||||||
|
m.RecordTypingStop("test", "chat1", func() {
|
||||||
|
stopCalled = true
|
||||||
|
})
|
||||||
|
m.RecordPlaceholder("test", "chat1", "ph_id")
|
||||||
|
|
||||||
|
msg := bus.OutboundMessage{Channel: "test", ChatID: "chat1", Content: "response"}
|
||||||
|
edited := m.preSend(context.Background(), "test", msg, ch)
|
||||||
|
|
||||||
|
if !stopCalled {
|
||||||
|
t.Fatal("expected typing stop to be called via wrapped type")
|
||||||
|
}
|
||||||
|
if !editCalled {
|
||||||
|
t.Fatal("expected EditMessage to be called via wrapped type")
|
||||||
|
}
|
||||||
|
if !edited {
|
||||||
|
t.Fatal("expected preSend to return true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lazy worker creation tests (Step 6) ---
|
||||||
|
|
||||||
|
func TestLazyWorkerCreation(t *testing.T) {
|
||||||
|
m := newTestManager()
|
||||||
|
|
||||||
|
ch := &mockChannel{
|
||||||
|
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterChannel should NOT create a worker
|
||||||
|
m.RegisterChannel("lazy", ch)
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
_, chExists := m.channels["lazy"]
|
||||||
|
_, wExists := m.workers["lazy"]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if !chExists {
|
||||||
|
t.Fatal("expected channel to be registered")
|
||||||
|
}
|
||||||
|
if wExists {
|
||||||
|
t.Fatal("expected worker to NOT be created by RegisterChannel (lazy creation)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FastID uniqueness test (Step 5) ---
|
||||||
|
|
||||||
|
func TestBuildMediaScope_FastIDUniqueness(t *testing.T) {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
for range 1000 {
|
||||||
|
scope := BuildMediaScope("test", "chat1", "")
|
||||||
|
if seen[scope] {
|
||||||
|
t.Fatalf("duplicate scope generated: %s", scope)
|
||||||
|
}
|
||||||
|
seen[scope] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify format: "channel:chatID:id"
|
||||||
|
scope := BuildMediaScope("telegram", "42", "")
|
||||||
|
parts := 0
|
||||||
|
for _, c := range scope {
|
||||||
|
if c == ':' {
|
||||||
|
parts++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parts != 2 {
|
||||||
|
t.Fatalf("expected scope to have 2 colons (channel:chatID:id), got: %s", scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMediaScope_WithMessageID(t *testing.T) {
|
||||||
|
scope := BuildMediaScope("discord", "chat99", "msg123")
|
||||||
|
expected := "discord:chat99:msg123"
|
||||||
|
if scope != expected {
|
||||||
|
t.Fatalf("expected %s, got %s", expected, scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
15
pkg/channels/media.go
Normal file
15
pkg/channels/media.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MediaSender is an optional interface for channels that can send
|
||||||
|
// media attachments (images, files, audio, video).
|
||||||
|
// Manager discovers channels implementing this interface via type
|
||||||
|
// assertion and routes OutboundMediaMessage to them.
|
||||||
|
type MediaSender interface {
|
||||||
|
SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error
|
||||||
|
}
|
||||||
13
pkg/channels/onebot/init.go
Normal file
13
pkg/channels/onebot/init.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package onebot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("onebot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewOneBotChannel(cfg.Channels.OneBot, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
package channels
|
package onebot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -14,14 +13,16 @@ import (
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
"github.com/sipeed/picoclaw/pkg/voice"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type OneBotChannel struct {
|
type OneBotChannel struct {
|
||||||
*BaseChannel
|
*channels.BaseChannel
|
||||||
config config.OneBotConfig
|
config config.OneBotConfig
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
|
@ -35,9 +36,7 @@ type OneBotChannel struct {
|
||||||
selfID int64
|
selfID int64
|
||||||
pending map[string]chan json.RawMessage
|
pending map[string]chan json.RawMessage
|
||||||
pendingMu sync.Mutex
|
pendingMu sync.Mutex
|
||||||
transcriber *voice.GroqTranscriber
|
|
||||||
lastMessageID sync.Map
|
lastMessageID sync.Map
|
||||||
pendingEmojiMsg sync.Map
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type oneBotRawEvent struct {
|
type oneBotRawEvent struct {
|
||||||
|
|
@ -98,7 +97,10 @@ type oneBotMessageSegment struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
|
func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
|
||||||
base := NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom)
|
base := channels.NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom,
|
||||||
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||||
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
|
)
|
||||||
|
|
||||||
const dedupSize = 1024
|
const dedupSize = 1024
|
||||||
return &OneBotChannel{
|
return &OneBotChannel{
|
||||||
|
|
@ -111,10 +113,6 @@ func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*One
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OneBotChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
|
|
||||||
c.transcriber = transcriber
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) {
|
func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) {
|
||||||
go func() {
|
go func() {
|
||||||
_, err := c.sendAPIRequest("set_msg_emoji_like", map[string]any{
|
_, err := c.sendAPIRequest("set_msg_emoji_like", map[string]any{
|
||||||
|
|
@ -131,6 +129,22 @@ func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReactToMessage implements channels.ReactionCapable.
|
||||||
|
// It adds an emoji reaction (ID 289) to group messages and returns an undo function.
|
||||||
|
// Private messages return a no-op since reactions are only meaningful in groups.
|
||||||
|
func (c *OneBotChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
|
||||||
|
// Only react in group chats
|
||||||
|
if !strings.HasPrefix(chatID, "group:") {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setMsgEmojiLike(messageID, 289, true)
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
c.setMsgEmojiLike(messageID, 289, false)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *OneBotChannel) Start(ctx context.Context) error {
|
func (c *OneBotChannel) Start(ctx context.Context) error {
|
||||||
if c.config.WSUrl == "" {
|
if c.config.WSUrl == "" {
|
||||||
return fmt.Errorf("OneBot ws_url not configured")
|
return fmt.Errorf("OneBot ws_url not configured")
|
||||||
|
|
@ -159,7 +173,7 @@ func (c *OneBotChannel) Start(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.setRunning(true)
|
c.SetRunning(true)
|
||||||
logger.InfoC("onebot", "OneBot channel started successfully")
|
logger.InfoC("onebot", "OneBot channel started successfully")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -300,7 +314,9 @@ func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.D
|
||||||
}
|
}
|
||||||
|
|
||||||
c.writeMu.Lock()
|
c.writeMu.Lock()
|
||||||
|
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
err = conn.WriteMessage(websocket.TextMessage, data)
|
err = conn.WriteMessage(websocket.TextMessage, data)
|
||||||
|
_ = conn.SetWriteDeadline(time.Time{})
|
||||||
c.writeMu.Unlock()
|
c.writeMu.Unlock()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -309,6 +325,9 @@ func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.D
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case resp := <-ch:
|
case resp := <-ch:
|
||||||
|
if resp == nil {
|
||||||
|
return nil, fmt.Errorf("API request %s: channel stopped", action)
|
||||||
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
case <-time.After(timeout):
|
case <-time.After(timeout):
|
||||||
return nil, fmt.Errorf("API request %s timed out after %v", action, timeout)
|
return nil, fmt.Errorf("API request %s timed out after %v", action, timeout)
|
||||||
|
|
@ -346,7 +365,7 @@ func (c *OneBotChannel) reconnectLoop() {
|
||||||
|
|
||||||
func (c *OneBotChannel) Stop(ctx context.Context) error {
|
func (c *OneBotChannel) Stop(ctx context.Context) error {
|
||||||
logger.InfoC("onebot", "Stopping OneBot channel")
|
logger.InfoC("onebot", "Stopping OneBot channel")
|
||||||
c.setRunning(false)
|
c.SetRunning(false)
|
||||||
|
|
||||||
if c.cancel != nil {
|
if c.cancel != nil {
|
||||||
c.cancel()
|
c.cancel()
|
||||||
|
|
@ -354,7 +373,10 @@ func (c *OneBotChannel) Stop(ctx context.Context) error {
|
||||||
|
|
||||||
c.pendingMu.Lock()
|
c.pendingMu.Lock()
|
||||||
for echo, ch := range c.pending {
|
for echo, ch := range c.pending {
|
||||||
close(ch)
|
select {
|
||||||
|
case ch <- nil: // non-blocking wake for blocked sendAPIRequest goroutines
|
||||||
|
default:
|
||||||
|
}
|
||||||
delete(c.pending, echo)
|
delete(c.pending, echo)
|
||||||
}
|
}
|
||||||
c.pendingMu.Unlock()
|
c.pendingMu.Unlock()
|
||||||
|
|
@ -371,7 +393,14 @@ func (c *OneBotChannel) Stop(ctx context.Context) error {
|
||||||
|
|
||||||
func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return fmt.Errorf("OneBot channel not running")
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ctx before entering write path
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
|
@ -401,20 +430,127 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
}
|
}
|
||||||
|
|
||||||
c.writeMu.Lock()
|
c.writeMu.Lock()
|
||||||
|
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
err = conn.WriteMessage(websocket.TextMessage, data)
|
err = conn.WriteMessage(websocket.TextMessage, data)
|
||||||
|
_ = conn.SetWriteDeadline(time.Time{})
|
||||||
c.writeMu.Unlock()
|
c.writeMu.Unlock()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("onebot", "Failed to send message", map[string]any{
|
logger.ErrorCF("onebot", "Failed to send message", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return err
|
return fmt.Errorf("onebot send: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if msgID, ok := c.pendingEmojiMsg.LoadAndDelete(msg.ChatID); ok {
|
return nil
|
||||||
if mid, ok := msgID.(string); ok && mid != "" {
|
}
|
||||||
c.setMsgEmojiLike(mid, 289, false)
|
|
||||||
|
// SendMedia implements the channels.MediaSender interface.
|
||||||
|
func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if conn == nil {
|
||||||
|
return fmt.Errorf("OneBot WebSocket not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
store := c.GetMediaStore()
|
||||||
|
if store == nil {
|
||||||
|
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build media segments
|
||||||
|
var segments []oneBotMessageSegment
|
||||||
|
for _, part := range msg.Parts {
|
||||||
|
localPath, err := store.Resolve(part.Ref)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("onebot", "Failed to resolve media ref", map[string]any{
|
||||||
|
"ref": part.Ref,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var segType string
|
||||||
|
switch part.Type {
|
||||||
|
case "image":
|
||||||
|
segType = "image"
|
||||||
|
case "video":
|
||||||
|
segType = "video"
|
||||||
|
case "audio":
|
||||||
|
segType = "record"
|
||||||
|
default:
|
||||||
|
segType = "file"
|
||||||
|
}
|
||||||
|
|
||||||
|
segments = append(segments, oneBotMessageSegment{
|
||||||
|
Type: segType,
|
||||||
|
Data: map[string]any{"file": "file://" + localPath},
|
||||||
|
})
|
||||||
|
|
||||||
|
if part.Caption != "" {
|
||||||
|
segments = append(segments, oneBotMessageSegment{
|
||||||
|
Type: "text",
|
||||||
|
Data: map[string]any{"text": part.Caption},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(segments) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
chatID := msg.ChatID
|
||||||
|
var action, idKey string
|
||||||
|
var rawID string
|
||||||
|
if rest, ok := strings.CutPrefix(chatID, "group:"); ok {
|
||||||
|
action, idKey, rawID = "send_group_msg", "group_id", rest
|
||||||
|
} else if rest, ok := strings.CutPrefix(chatID, "private:"); ok {
|
||||||
|
action, idKey, rawID = "send_private_msg", "user_id", rest
|
||||||
|
} else {
|
||||||
|
action, idKey, rawID = "send_private_msg", "user_id", chatID
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.ParseInt(rawID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid %s in chatID: %s: %w", idKey, chatID, channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1))
|
||||||
|
|
||||||
|
req := oneBotAPIRequest{
|
||||||
|
Action: action,
|
||||||
|
Params: map[string]any{idKey: id, "message": segments},
|
||||||
|
Echo: echo,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal OneBot request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.writeMu.Lock()
|
||||||
|
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
err = conn.WriteMessage(websocket.TextMessage, data)
|
||||||
|
_ = conn.SetWriteDeadline(time.Time{})
|
||||||
|
c.writeMu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("onebot", "Failed to send media message", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return fmt.Errorf("onebot send media: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -571,11 +707,15 @@ type parseMessageResult struct {
|
||||||
Text string
|
Text string
|
||||||
IsBotMentioned bool
|
IsBotMentioned bool
|
||||||
Media []string
|
Media []string
|
||||||
LocalFiles []string
|
|
||||||
ReplyTo string
|
ReplyTo string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) parseMessageResult {
|
func (c *OneBotChannel) parseMessageSegments(
|
||||||
|
raw json.RawMessage,
|
||||||
|
selfID int64,
|
||||||
|
store media.MediaStore,
|
||||||
|
scope string,
|
||||||
|
) parseMessageResult {
|
||||||
if len(raw) == 0 {
|
if len(raw) == 0 {
|
||||||
return parseMessageResult{}
|
return parseMessageResult{}
|
||||||
}
|
}
|
||||||
|
|
@ -602,10 +742,23 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
|
||||||
var textParts []string
|
var textParts []string
|
||||||
mentioned := false
|
mentioned := false
|
||||||
selfIDStr := strconv.FormatInt(selfID, 10)
|
selfIDStr := strconv.FormatInt(selfID, 10)
|
||||||
var media []string
|
var mediaRefs []string
|
||||||
var localFiles []string
|
|
||||||
var replyTo string
|
var replyTo string
|
||||||
|
|
||||||
|
// Helper to register a local file with the media store
|
||||||
|
storeFile := func(localPath, filename string) string {
|
||||||
|
if store != nil {
|
||||||
|
ref, err := store.Store(localPath, media.MediaMeta{
|
||||||
|
Filename: filename,
|
||||||
|
Source: "onebot",
|
||||||
|
}, scope)
|
||||||
|
if err == nil {
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return localPath // fallback
|
||||||
|
}
|
||||||
|
|
||||||
for _, seg := range segments {
|
for _, seg := range segments {
|
||||||
segType, _ := seg["type"].(string)
|
segType, _ := seg["type"].(string)
|
||||||
data, _ := seg["data"].(map[string]any)
|
data, _ := seg["data"].(map[string]any)
|
||||||
|
|
@ -641,8 +794,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
|
||||||
LoggerPrefix: "onebot",
|
LoggerPrefix: "onebot",
|
||||||
})
|
})
|
||||||
if localPath != "" {
|
if localPath != "" {
|
||||||
media = append(media, localPath)
|
mediaRefs = append(mediaRefs, storeFile(localPath, filename))
|
||||||
localFiles = append(localFiles, localPath)
|
|
||||||
textParts = append(textParts, fmt.Sprintf("[%s]", segType))
|
textParts = append(textParts, fmt.Sprintf("[%s]", segType))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -656,24 +808,8 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
|
||||||
LoggerPrefix: "onebot",
|
LoggerPrefix: "onebot",
|
||||||
})
|
})
|
||||||
if localPath != "" {
|
if localPath != "" {
|
||||||
localFiles = append(localFiles, localPath)
|
|
||||||
if c.transcriber != nil && c.transcriber.IsAvailable() {
|
|
||||||
tctx, tcancel := context.WithTimeout(c.ctx, 30*time.Second)
|
|
||||||
result, err := c.transcriber.Transcribe(tctx, localPath)
|
|
||||||
tcancel()
|
|
||||||
if err != nil {
|
|
||||||
logger.WarnCF("onebot", "Voice transcription failed", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
textParts = append(textParts, "[voice (transcription failed)]")
|
|
||||||
media = append(media, localPath)
|
|
||||||
} else {
|
|
||||||
textParts = append(textParts, fmt.Sprintf("[voice transcription: %s]", result.Text))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
textParts = append(textParts, "[voice]")
|
textParts = append(textParts, "[voice]")
|
||||||
media = append(media, localPath)
|
mediaRefs = append(mediaRefs, storeFile(localPath, "voice.amr"))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -701,8 +837,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
|
||||||
return parseMessageResult{
|
return parseMessageResult{
|
||||||
Text: strings.TrimSpace(strings.Join(textParts, "")),
|
Text: strings.TrimSpace(strings.Join(textParts, "")),
|
||||||
IsBotMentioned: mentioned,
|
IsBotMentioned: mentioned,
|
||||||
Media: media,
|
Media: mediaRefs,
|
||||||
LocalFiles: localFiles,
|
|
||||||
ReplyTo: replyTo,
|
ReplyTo: replyTo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -711,7 +846,13 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
|
||||||
switch raw.PostType {
|
switch raw.PostType {
|
||||||
case "message":
|
case "message":
|
||||||
if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 {
|
if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 {
|
||||||
if !c.IsAllowed(strconv.FormatInt(userID, 10)) {
|
// Build minimal sender for allowlist check
|
||||||
|
sender := bus.SenderInfo{
|
||||||
|
Platform: "onebot",
|
||||||
|
PlatformID: strconv.FormatInt(userID, 10),
|
||||||
|
CanonicalID: identity.BuildCanonicalID("onebot", strconv.FormatInt(userID, 10)),
|
||||||
|
}
|
||||||
|
if !c.IsAllowedSender(sender) {
|
||||||
logger.DebugCF("onebot", "Message rejected by allowlist", map[string]any{
|
logger.DebugCF("onebot", "Message rejected by allowlist", map[string]any{
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
})
|
})
|
||||||
|
|
@ -794,7 +935,17 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
||||||
selfID = atomic.LoadInt64(&c.selfID)
|
selfID = atomic.LoadInt64(&c.selfID)
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed := c.parseMessageSegments(raw.Message, selfID)
|
// Compute scope for media store before parsing (parsing may download files)
|
||||||
|
var chatIDForScope string
|
||||||
|
switch raw.MessageType {
|
||||||
|
case "group":
|
||||||
|
chatIDForScope = "group:" + strconv.FormatInt(groupID, 10)
|
||||||
|
default:
|
||||||
|
chatIDForScope = "private:" + strconv.FormatInt(userID, 10)
|
||||||
|
}
|
||||||
|
scope := channels.BuildMediaScope("onebot", chatIDForScope, messageID)
|
||||||
|
|
||||||
|
parsed := c.parseMessageSegments(raw.Message, selfID, c.GetMediaStore(), scope)
|
||||||
isBotMentioned := parsed.IsBotMentioned
|
isBotMentioned := parsed.IsBotMentioned
|
||||||
|
|
||||||
content := raw.RawMessage
|
content := raw.RawMessage
|
||||||
|
|
@ -823,20 +974,6 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up temp files when done
|
|
||||||
if len(parsed.LocalFiles) > 0 {
|
|
||||||
defer func() {
|
|
||||||
for _, f := range parsed.LocalFiles {
|
|
||||||
if err := os.Remove(f); err != nil {
|
|
||||||
logger.DebugCF("onebot", "Failed to remove temp file", map[string]any{
|
|
||||||
"path": f,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.isDuplicate(messageID) {
|
if c.isDuplicate(messageID) {
|
||||||
logger.DebugCF("onebot", "Duplicate message, skipping", map[string]any{
|
logger.DebugCF("onebot", "Duplicate message, skipping", map[string]any{
|
||||||
"message_id": messageID,
|
"message_id": messageID,
|
||||||
|
|
@ -854,9 +991,9 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
||||||
senderID := strconv.FormatInt(userID, 10)
|
senderID := strconv.FormatInt(userID, 10)
|
||||||
var chatID string
|
var chatID string
|
||||||
|
|
||||||
metadata := map[string]string{
|
var peer bus.Peer
|
||||||
"message_id": messageID,
|
|
||||||
}
|
metadata := map[string]string{}
|
||||||
|
|
||||||
if parsed.ReplyTo != "" {
|
if parsed.ReplyTo != "" {
|
||||||
metadata["reply_to_message_id"] = parsed.ReplyTo
|
metadata["reply_to_message_id"] = parsed.ReplyTo
|
||||||
|
|
@ -865,14 +1002,12 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
||||||
switch raw.MessageType {
|
switch raw.MessageType {
|
||||||
case "private":
|
case "private":
|
||||||
chatID = "private:" + senderID
|
chatID = "private:" + senderID
|
||||||
metadata["peer_kind"] = "direct"
|
peer = bus.Peer{Kind: "direct", ID: senderID}
|
||||||
metadata["peer_id"] = senderID
|
|
||||||
|
|
||||||
case "group":
|
case "group":
|
||||||
groupIDStr := strconv.FormatInt(groupID, 10)
|
groupIDStr := strconv.FormatInt(groupID, 10)
|
||||||
chatID = "group:" + groupIDStr
|
chatID = "group:" + groupIDStr
|
||||||
metadata["peer_kind"] = "group"
|
peer = bus.Peer{Kind: "group", ID: groupIDStr}
|
||||||
metadata["peer_id"] = groupIDStr
|
|
||||||
metadata["group_id"] = groupIDStr
|
metadata["group_id"] = groupIDStr
|
||||||
|
|
||||||
senderUserID, _ := parseJSONInt64(sender.UserID)
|
senderUserID, _ := parseJSONInt64(sender.UserID)
|
||||||
|
|
@ -886,8 +1021,8 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
||||||
metadata["sender_name"] = sender.Nickname
|
metadata["sender_name"] = sender.Nickname
|
||||||
}
|
}
|
||||||
|
|
||||||
triggered, strippedContent := c.checkGroupTrigger(content, isBotMentioned)
|
respond, strippedContent := c.ShouldRespondInGroup(isBotMentioned, content)
|
||||||
if !triggered {
|
if !respond {
|
||||||
logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]any{
|
logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]any{
|
||||||
"sender": senderID,
|
"sender": senderID,
|
||||||
"group": groupIDStr,
|
"group": groupIDStr,
|
||||||
|
|
@ -922,12 +1057,21 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
||||||
|
|
||||||
c.lastMessageID.Store(chatID, messageID)
|
c.lastMessageID.Store(chatID, messageID)
|
||||||
|
|
||||||
if raw.MessageType == "group" && messageID != "" && messageID != "0" {
|
senderInfo := bus.SenderInfo{
|
||||||
c.setMsgEmojiLike(messageID, 289, true)
|
Platform: "onebot",
|
||||||
c.pendingEmojiMsg.Store(chatID, messageID)
|
PlatformID: senderID,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("onebot", senderID),
|
||||||
|
DisplayName: sender.Nickname,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HandleMessage(senderID, chatID, content, parsed.Media, metadata)
|
if !c.IsAllowedSender(senderInfo) {
|
||||||
|
logger.DebugCF("onebot", "Message rejected by allowlist (senderInfo)", map[string]any{
|
||||||
|
"sender": senderID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, parsed.Media, metadata, senderInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OneBotChannel) isDuplicate(messageID string) bool {
|
func (c *OneBotChannel) isDuplicate(messageID string) bool {
|
||||||
|
|
@ -959,23 +1103,3 @@ func truncate(s string, n int) string {
|
||||||
}
|
}
|
||||||
return string(runes[:n]) + "..."
|
return string(runes[:n]) + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OneBotChannel) checkGroupTrigger(
|
|
||||||
content string,
|
|
||||||
isBotMentioned bool,
|
|
||||||
) (triggered bool, strippedContent string) {
|
|
||||||
if isBotMentioned {
|
|
||||||
return true, strings.TrimSpace(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, prefix := range c.config.GroupTriggerPrefix {
|
|
||||||
if prefix == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if after, ok := strings.CutPrefix(content, prefix); ok {
|
|
||||||
return true, strings.TrimSpace(after)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, content
|
|
||||||
}
|
|
||||||
13
pkg/channels/pico/init.go
Normal file
13
pkg/channels/pico/init.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package pico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("pico", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewPicoChannel(cfg.Channels.Pico, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
462
pkg/channels/pico/pico.go
Normal file
462
pkg/channels/pico/pico.go
Normal file
|
|
@ -0,0 +1,462 @@
|
||||||
|
package pico
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// picoConn represents a single WebSocket connection.
|
||||||
|
type picoConn struct {
|
||||||
|
id string
|
||||||
|
conn *websocket.Conn
|
||||||
|
sessionID string
|
||||||
|
writeMu sync.Mutex
|
||||||
|
closed atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSON sends a JSON message to the connection with write locking.
|
||||||
|
func (pc *picoConn) writeJSON(v any) error {
|
||||||
|
if pc.closed.Load() {
|
||||||
|
return fmt.Errorf("connection closed")
|
||||||
|
}
|
||||||
|
pc.writeMu.Lock()
|
||||||
|
defer pc.writeMu.Unlock()
|
||||||
|
return pc.conn.WriteJSON(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// close closes the connection.
|
||||||
|
func (pc *picoConn) close() {
|
||||||
|
if pc.closed.CompareAndSwap(false, true) {
|
||||||
|
pc.conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PicoChannel implements the native Pico Protocol WebSocket channel.
|
||||||
|
// It serves as the reference implementation for all optional capability interfaces.
|
||||||
|
type PicoChannel struct {
|
||||||
|
*channels.BaseChannel
|
||||||
|
config config.PicoConfig
|
||||||
|
upgrader websocket.Upgrader
|
||||||
|
connections sync.Map // connID → *picoConn
|
||||||
|
connCount atomic.Int32
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPicoChannel creates a new Pico Protocol channel.
|
||||||
|
func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) {
|
||||||
|
if cfg.Token == "" {
|
||||||
|
return nil, fmt.Errorf("pico token is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
base := channels.NewBaseChannel("pico", cfg, messageBus, cfg.AllowFrom)
|
||||||
|
|
||||||
|
allowOrigins := cfg.AllowOrigins
|
||||||
|
checkOrigin := func(r *http.Request) bool {
|
||||||
|
if len(allowOrigins) == 0 {
|
||||||
|
return true // allow all if not configured
|
||||||
|
}
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
for _, allowed := range allowOrigins {
|
||||||
|
if allowed == "*" || allowed == origin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PicoChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
config: cfg,
|
||||||
|
upgrader: websocket.Upgrader{
|
||||||
|
CheckOrigin: checkOrigin,
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start implements Channel.
|
||||||
|
func (c *PicoChannel) Start(ctx context.Context) error {
|
||||||
|
logger.InfoC("pico", "Starting Pico Protocol channel")
|
||||||
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
c.SetRunning(true)
|
||||||
|
logger.InfoC("pico", "Pico Protocol channel started")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop implements Channel.
|
||||||
|
func (c *PicoChannel) Stop(ctx context.Context) error {
|
||||||
|
logger.InfoC("pico", "Stopping Pico Protocol channel")
|
||||||
|
c.SetRunning(false)
|
||||||
|
|
||||||
|
// Close all connections
|
||||||
|
c.connections.Range(func(key, value any) bool {
|
||||||
|
if pc, ok := value.(*picoConn); ok {
|
||||||
|
pc.close()
|
||||||
|
}
|
||||||
|
c.connections.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoC("pico", "Pico Protocol channel stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookPath implements channels.WebhookHandler.
|
||||||
|
func (c *PicoChannel) WebhookPath() string { return "/pico/" }
|
||||||
|
|
||||||
|
// ServeHTTP implements http.Handler for the shared HTTP server.
|
||||||
|
func (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/pico")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case path == "/ws" || path == "/ws/":
|
||||||
|
c.handleWebSocket(w, r)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send implements Channel — sends a message to the appropriate WebSocket connection.
|
||||||
|
func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
outMsg := newMessage(TypeMessageCreate, map[string]any{
|
||||||
|
"content": msg.Content,
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.broadcastToSession(msg.ChatID, outMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditMessage implements channels.MessageEditor.
|
||||||
|
func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
||||||
|
outMsg := newMessage(TypeMessageUpdate, map[string]any{
|
||||||
|
"message_id": messageID,
|
||||||
|
"content": content,
|
||||||
|
})
|
||||||
|
return c.broadcastToSession(chatID, outMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartTyping implements channels.TypingCapable.
|
||||||
|
func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
||||||
|
startMsg := newMessage(TypeTypingStart, nil)
|
||||||
|
if err := c.broadcastToSession(chatID, startMsg); err != nil {
|
||||||
|
return func() {}, err
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
stopMsg := newMessage(TypeTypingStop, nil)
|
||||||
|
c.broadcastToSession(chatID, stopMsg)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPlaceholder implements channels.PlaceholderCapable.
|
||||||
|
// It sends a placeholder message via the Pico Protocol that will later be
|
||||||
|
// edited to the actual response via EditMessage (channels.MessageEditor).
|
||||||
|
func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
||||||
|
if !c.config.Placeholder.Enabled {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
text := c.config.Placeholder.Text
|
||||||
|
if text == "" {
|
||||||
|
text = "Thinking... 💭"
|
||||||
|
}
|
||||||
|
|
||||||
|
msgID := uuid.New().String()
|
||||||
|
outMsg := newMessage(TypeMessageCreate, map[string]any{
|
||||||
|
"content": text,
|
||||||
|
"message_id": msgID,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := c.broadcastToSession(chatID, outMsg); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcastToSession sends a message to all connections with a matching session.
|
||||||
|
func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error {
|
||||||
|
// chatID format: "pico:<sessionID>"
|
||||||
|
sessionID := strings.TrimPrefix(chatID, "pico:")
|
||||||
|
msg.SessionID = sessionID
|
||||||
|
|
||||||
|
var sent bool
|
||||||
|
c.connections.Range(func(key, value any) bool {
|
||||||
|
pc, ok := value.(*picoConn)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if pc.sessionID == sessionID {
|
||||||
|
if err := pc.writeJSON(msg); err != nil {
|
||||||
|
logger.DebugCF("pico", "Write to connection failed", map[string]any{
|
||||||
|
"conn_id": pc.id,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
sent = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if !sent {
|
||||||
|
return fmt.Errorf("no active connections for session %s: %w", sessionID, channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWebSocket upgrades the HTTP connection and manages the WebSocket lifecycle.
|
||||||
|
func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
http.Error(w, "channel not running", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate
|
||||||
|
if !c.authenticate(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check connection limit
|
||||||
|
maxConns := c.config.MaxConnections
|
||||||
|
if maxConns <= 0 {
|
||||||
|
maxConns = 100
|
||||||
|
}
|
||||||
|
if int(c.connCount.Load()) >= maxConns {
|
||||||
|
http.Error(w, "too many connections", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := c.upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("pico", "WebSocket upgrade failed", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine session ID from query param or generate one
|
||||||
|
sessionID := r.URL.Query().Get("session_id")
|
||||||
|
if sessionID == "" {
|
||||||
|
sessionID = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
pc := &picoConn{
|
||||||
|
id: uuid.New().String(),
|
||||||
|
conn: conn,
|
||||||
|
sessionID: sessionID,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.connections.Store(pc.id, pc)
|
||||||
|
c.connCount.Add(1)
|
||||||
|
|
||||||
|
logger.InfoCF("pico", "WebSocket client connected", map[string]any{
|
||||||
|
"conn_id": pc.id,
|
||||||
|
"session_id": sessionID,
|
||||||
|
})
|
||||||
|
|
||||||
|
go c.readLoop(pc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticate checks the Bearer token from the Authorization header.
|
||||||
|
// Query parameter authentication is only allowed when AllowTokenQuery is explicitly enabled.
|
||||||
|
func (c *PicoChannel) authenticate(r *http.Request) bool {
|
||||||
|
token := c.config.Token
|
||||||
|
if token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Authorization header
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
|
||||||
|
if after == token {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check query parameter only when explicitly allowed
|
||||||
|
if c.config.AllowTokenQuery {
|
||||||
|
if r.URL.Query().Get("token") == token {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLoop reads messages from a WebSocket connection.
|
||||||
|
func (c *PicoChannel) readLoop(pc *picoConn) {
|
||||||
|
defer func() {
|
||||||
|
pc.close()
|
||||||
|
c.connections.Delete(pc.id)
|
||||||
|
c.connCount.Add(-1)
|
||||||
|
logger.InfoCF("pico", "WebSocket client disconnected", map[string]any{
|
||||||
|
"conn_id": pc.id,
|
||||||
|
"session_id": pc.sessionID,
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
readTimeout := time.Duration(c.config.ReadTimeout) * time.Second
|
||||||
|
if readTimeout <= 0 {
|
||||||
|
readTimeout = 60 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
|
||||||
|
pc.conn.SetPongHandler(func(appData string) error {
|
||||||
|
_ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start ping ticker
|
||||||
|
pingInterval := time.Duration(c.config.PingInterval) * time.Second
|
||||||
|
if pingInterval <= 0 {
|
||||||
|
pingInterval = 30 * time.Second
|
||||||
|
}
|
||||||
|
go c.pingLoop(pc, pingInterval)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
_, rawMsg, err := pc.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||||
|
logger.DebugCF("pico", "WebSocket read error", map[string]any{
|
||||||
|
"conn_id": pc.id,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
|
||||||
|
|
||||||
|
var msg PicoMessage
|
||||||
|
if err := json.Unmarshal(rawMsg, &msg); err != nil {
|
||||||
|
errMsg := newError("invalid_message", "failed to parse message")
|
||||||
|
pc.writeJSON(errMsg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
c.handleMessage(pc, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pingLoop sends periodic ping frames to keep the connection alive.
|
||||||
|
func (c *PicoChannel) pingLoop(pc *picoConn, interval time.Duration) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if pc.closed.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pc.writeMu.Lock()
|
||||||
|
err := pc.conn.WriteMessage(websocket.PingMessage, nil)
|
||||||
|
pc.writeMu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMessage processes an inbound Pico Protocol message.
|
||||||
|
func (c *PicoChannel) handleMessage(pc *picoConn, msg PicoMessage) {
|
||||||
|
switch msg.Type {
|
||||||
|
case TypePing:
|
||||||
|
pong := newMessage(TypePong, nil)
|
||||||
|
pong.ID = msg.ID
|
||||||
|
pc.writeJSON(pong)
|
||||||
|
|
||||||
|
case TypeMessageSend:
|
||||||
|
c.handleMessageSend(pc, msg)
|
||||||
|
|
||||||
|
default:
|
||||||
|
errMsg := newError("unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type))
|
||||||
|
pc.writeJSON(errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMessageSend processes an inbound message.send from a client.
|
||||||
|
func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
|
||||||
|
content, _ := msg.Payload["content"].(string)
|
||||||
|
if strings.TrimSpace(content) == "" {
|
||||||
|
errMsg := newError("empty_content", "message content is empty")
|
||||||
|
pc.writeJSON(errMsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID := msg.SessionID
|
||||||
|
if sessionID == "" {
|
||||||
|
sessionID = pc.sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
chatID := "pico:" + sessionID
|
||||||
|
senderID := "pico-user"
|
||||||
|
|
||||||
|
peer := bus.Peer{Kind: "direct", ID: "pico:" + sessionID}
|
||||||
|
|
||||||
|
metadata := map[string]string{
|
||||||
|
"platform": "pico",
|
||||||
|
"session_id": sessionID,
|
||||||
|
"conn_id": pc.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("pico", "Received message", map[string]any{
|
||||||
|
"session_id": sessionID,
|
||||||
|
"preview": truncate(content, 50),
|
||||||
|
})
|
||||||
|
|
||||||
|
sender := bus.SenderInfo{
|
||||||
|
Platform: "pico",
|
||||||
|
PlatformID: senderID,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("pico", senderID),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.IsAllowedSender(sender) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, metadata, sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate truncates a string to maxLen runes.
|
||||||
|
func truncate(s string, maxLen int) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:maxLen]) + "..."
|
||||||
|
}
|
||||||
46
pkg/channels/pico/protocol.go
Normal file
46
pkg/channels/pico/protocol.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
package pico
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Protocol message types.
|
||||||
|
const (
|
||||||
|
// TypeMessageSend is sent from client to server.
|
||||||
|
TypeMessageSend = "message.send"
|
||||||
|
TypeMediaSend = "media.send"
|
||||||
|
TypePing = "ping"
|
||||||
|
|
||||||
|
// TypeMessageCreate is sent from server to client.
|
||||||
|
TypeMessageCreate = "message.create"
|
||||||
|
TypeMessageUpdate = "message.update"
|
||||||
|
TypeMediaCreate = "media.create"
|
||||||
|
TypeTypingStart = "typing.start"
|
||||||
|
TypeTypingStop = "typing.stop"
|
||||||
|
TypeError = "error"
|
||||||
|
TypePong = "pong"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PicoMessage is the wire format for all Pico Protocol messages.
|
||||||
|
type PicoMessage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
Timestamp int64 `json:"timestamp,omitempty"`
|
||||||
|
Payload map[string]any `json:"payload,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMessage creates a PicoMessage with the given type and payload.
|
||||||
|
func newMessage(msgType string, payload map[string]any) PicoMessage {
|
||||||
|
return PicoMessage{
|
||||||
|
Type: msgType,
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
Payload: payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newError creates an error PicoMessage.
|
||||||
|
func newError(code, message string) PicoMessage {
|
||||||
|
return newMessage(TypeError, map[string]any{
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
}
|
||||||
13
pkg/channels/qq/init.go
Normal file
13
pkg/channels/qq/init.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package qq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("qq", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewQQChannel(cfg.Channels.QQ, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package channels
|
package qq
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -14,12 +14,14 @@ import (
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
type QQChannel struct {
|
type QQChannel struct {
|
||||||
*BaseChannel
|
*channels.BaseChannel
|
||||||
config config.QQConfig
|
config config.QQConfig
|
||||||
api openapi.OpenAPI
|
api openapi.OpenAPI
|
||||||
tokenSource oauth2.TokenSource
|
tokenSource oauth2.TokenSource
|
||||||
|
|
@ -31,7 +33,10 @@ type QQChannel struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
|
func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
|
||||||
base := NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom)
|
base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom,
|
||||||
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||||
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
|
)
|
||||||
|
|
||||||
return &QQChannel{
|
return &QQChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
|
|
@ -90,11 +95,11 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
||||||
logger.ErrorCF("qq", "WebSocket session error", map[string]any{
|
logger.ErrorCF("qq", "WebSocket session error", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
c.setRunning(false)
|
c.SetRunning(false)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
c.setRunning(true)
|
c.SetRunning(true)
|
||||||
logger.InfoC("qq", "QQ bot started successfully")
|
logger.InfoC("qq", "QQ bot started successfully")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -102,7 +107,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
||||||
|
|
||||||
func (c *QQChannel) Stop(ctx context.Context) error {
|
func (c *QQChannel) Stop(ctx context.Context) error {
|
||||||
logger.InfoC("qq", "Stopping QQ bot")
|
logger.InfoC("qq", "Stopping QQ bot")
|
||||||
c.setRunning(false)
|
c.SetRunning(false)
|
||||||
|
|
||||||
if c.cancel != nil {
|
if c.cancel != nil {
|
||||||
c.cancel()
|
c.cancel()
|
||||||
|
|
@ -113,7 +118,7 @@ func (c *QQChannel) Stop(ctx context.Context) error {
|
||||||
|
|
||||||
func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return fmt.Errorf("QQ bot not running")
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
// construct message
|
// construct message
|
||||||
|
|
@ -127,7 +132,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{
|
logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return err
|
return fmt.Errorf("qq send: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -162,20 +167,35 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||||
"length": len(content),
|
"length": len(content),
|
||||||
})
|
})
|
||||||
|
|
||||||
// forward to message bus
|
// 转发到消息总线
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{}
|
||||||
"message_id": data.ID,
|
|
||||||
"peer_kind": "direct",
|
sender := bus.SenderInfo{
|
||||||
"peer_id": senderID,
|
Platform: "qq",
|
||||||
|
PlatformID: data.Author.ID,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HandleMessage(senderID, senderID, content, []string{}, metadata)
|
if !c.IsAllowedSender(sender) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HandleMessage(c.ctx,
|
||||||
|
bus.Peer{Kind: "direct", ID: senderID},
|
||||||
|
data.ID,
|
||||||
|
senderID,
|
||||||
|
senderID,
|
||||||
|
content,
|
||||||
|
[]string{},
|
||||||
|
metadata,
|
||||||
|
sender,
|
||||||
|
)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGroupATMessage handles group @messages
|
// handleGroupATMessage handles QQ group @ messages
|
||||||
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||||
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
|
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
|
||||||
// deduplication check
|
// deduplication check
|
||||||
|
|
@ -192,34 +212,57 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// extract message content (remove @bot part)
|
// extract message content (remove @ bot part)
|
||||||
content := data.Content
|
content := data.Content
|
||||||
if content == "" {
|
if content == "" {
|
||||||
logger.DebugC("qq", "Received empty group message, ignoring")
|
logger.DebugC("qq", "Received empty group message, ignoring")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GroupAT event means bot is always mentioned; apply group trigger filtering
|
||||||
|
respond, cleaned := c.ShouldRespondInGroup(true, content)
|
||||||
|
if !respond {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
content = cleaned
|
||||||
|
|
||||||
logger.InfoCF("qq", "Received group AT message", map[string]any{
|
logger.InfoCF("qq", "Received group AT message", map[string]any{
|
||||||
"sender": senderID,
|
"sender": senderID,
|
||||||
"group": data.GroupID,
|
"group": data.GroupID,
|
||||||
"length": len(content),
|
"length": len(content),
|
||||||
})
|
})
|
||||||
|
|
||||||
// forward to message bus (use GroupID as ChatID)
|
// 转发到消息总线(使用 GroupID 作为 ChatID)
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"message_id": data.ID,
|
|
||||||
"group_id": data.GroupID,
|
"group_id": data.GroupID,
|
||||||
"peer_kind": "group",
|
|
||||||
"peer_id": data.GroupID,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HandleMessage(senderID, data.GroupID, content, []string{}, metadata)
|
sender := bus.SenderInfo{
|
||||||
|
Platform: "qq",
|
||||||
|
PlatformID: data.Author.ID,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.IsAllowedSender(sender) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HandleMessage(c.ctx,
|
||||||
|
bus.Peer{Kind: "group", ID: data.GroupID},
|
||||||
|
data.ID,
|
||||||
|
senderID,
|
||||||
|
data.GroupID,
|
||||||
|
content,
|
||||||
|
[]string{},
|
||||||
|
metadata,
|
||||||
|
sender,
|
||||||
|
)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isDuplicate checks if message is duplicate
|
// isDuplicate 检查消息是否重复
|
||||||
func (c *QQChannel) isDuplicate(messageID string) bool {
|
func (c *QQChannel) isDuplicate(messageID string) bool {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
@ -230,9 +273,9 @@ func (c *QQChannel) isDuplicate(messageID string) bool {
|
||||||
|
|
||||||
c.processedIDs[messageID] = true
|
c.processedIDs[messageID] = true
|
||||||
|
|
||||||
// simple cleanup: limit map size
|
// 简单清理:限制 map 大小
|
||||||
if len(c.processedIDs) > 10000 {
|
if len(c.processedIDs) > 10000 {
|
||||||
// clear half
|
// 清空一半
|
||||||
count := 0
|
count := 0
|
||||||
for id := range c.processedIDs {
|
for id := range c.processedIDs {
|
||||||
if count >= 5000 {
|
if count >= 5000 {
|
||||||
32
pkg/channels/registry.go
Normal file
32
pkg/channels/registry.go
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChannelFactory is a constructor function that creates a Channel from config and message bus.
|
||||||
|
// Each channel subpackage registers one or more factories via init().
|
||||||
|
type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error)
|
||||||
|
|
||||||
|
var (
|
||||||
|
factoriesMu sync.RWMutex
|
||||||
|
factories = map[string]ChannelFactory{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterFactory registers a named channel factory. Called from subpackage init() functions.
|
||||||
|
func RegisterFactory(name string, f ChannelFactory) {
|
||||||
|
factoriesMu.Lock()
|
||||||
|
defer factoriesMu.Unlock()
|
||||||
|
factories[name] = f
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFactory looks up a channel factory by name.
|
||||||
|
func getFactory(name string) (ChannelFactory, bool) {
|
||||||
|
factoriesMu.RLock()
|
||||||
|
defer factoriesMu.RUnlock()
|
||||||
|
f, ok := factories[name]
|
||||||
|
return f, ok
|
||||||
|
}
|
||||||
13
pkg/channels/slack/init.go
Normal file
13
pkg/channels/slack/init.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package slack
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("slack", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewSlackChannel(cfg.Channels.Slack, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,32 +1,31 @@
|
||||||
package channels
|
package slack
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/slack-go/slack"
|
"github.com/slack-go/slack"
|
||||||
"github.com/slack-go/slack/slackevents"
|
"github.com/slack-go/slack/slackevents"
|
||||||
"github.com/slack-go/slack/socketmode"
|
"github.com/slack-go/slack/socketmode"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
"github.com/sipeed/picoclaw/pkg/voice"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type SlackChannel struct {
|
type SlackChannel struct {
|
||||||
*BaseChannel
|
*channels.BaseChannel
|
||||||
config config.SlackConfig
|
config config.SlackConfig
|
||||||
api *slack.Client
|
api *slack.Client
|
||||||
socketClient *socketmode.Client
|
socketClient *socketmode.Client
|
||||||
botUserID string
|
botUserID string
|
||||||
teamID string
|
teamID string
|
||||||
transcriber *voice.GroqTranscriber
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
pendingAcks sync.Map
|
pendingAcks sync.Map
|
||||||
|
|
@ -49,7 +48,11 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack
|
||||||
|
|
||||||
socketClient := socketmode.New(api)
|
socketClient := socketmode.New(api)
|
||||||
|
|
||||||
base := NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom)
|
base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom,
|
||||||
|
channels.WithMaxMessageLength(40000),
|
||||||
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||||
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
|
)
|
||||||
|
|
||||||
return &SlackChannel{
|
return &SlackChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
|
|
@ -59,10 +62,6 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SlackChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
|
|
||||||
c.transcriber = transcriber
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *SlackChannel) Start(ctx context.Context) error {
|
func (c *SlackChannel) Start(ctx context.Context) error {
|
||||||
logger.InfoC("slack", "Starting Slack channel (Socket Mode)")
|
logger.InfoC("slack", "Starting Slack channel (Socket Mode)")
|
||||||
|
|
||||||
|
|
@ -92,7 +91,7 @@ func (c *SlackChannel) Start(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
c.setRunning(true)
|
c.SetRunning(true)
|
||||||
logger.InfoC("slack", "Slack channel started (Socket Mode)")
|
logger.InfoC("slack", "Slack channel started (Socket Mode)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -104,14 +103,14 @@ func (c *SlackChannel) Stop(ctx context.Context) error {
|
||||||
c.cancel()
|
c.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
c.setRunning(false)
|
c.SetRunning(false)
|
||||||
logger.InfoC("slack", "Slack channel stopped")
|
logger.InfoC("slack", "Slack channel stopped")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return fmt.Errorf("slack channel not running")
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
channelID, threadTS := parseSlackChatID(msg.ChatID)
|
channelID, threadTS := parseSlackChatID(msg.ChatID)
|
||||||
|
|
@ -129,7 +128,7 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
|
|
||||||
_, _, err := c.api.PostMessageContext(ctx, channelID, opts...)
|
_, _, err := c.api.PostMessageContext(ctx, channelID, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send slack message: %w", err)
|
return fmt.Errorf("slack send: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok {
|
if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok {
|
||||||
|
|
@ -148,6 +147,82 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendMedia implements the channels.MediaSender interface.
|
||||||
|
func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
channelID, _ := parseSlackChatID(msg.ChatID)
|
||||||
|
if channelID == "" {
|
||||||
|
return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := c.GetMediaStore()
|
||||||
|
if store == nil {
|
||||||
|
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, part := range msg.Parts {
|
||||||
|
localPath, err := store.Resolve(part.Ref)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("slack", "Failed to resolve media ref", map[string]any{
|
||||||
|
"ref": part.Ref,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := part.Filename
|
||||||
|
if filename == "" {
|
||||||
|
filename = "file"
|
||||||
|
}
|
||||||
|
|
||||||
|
title := part.Caption
|
||||||
|
if title == "" {
|
||||||
|
title = filename
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{
|
||||||
|
Channel: channelID,
|
||||||
|
File: localPath,
|
||||||
|
Filename: filename,
|
||||||
|
Title: title,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("slack", "Failed to upload media", map[string]any{
|
||||||
|
"filename": filename,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return fmt.Errorf("slack send media: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReactToMessage implements channels.ReactionCapable.
|
||||||
|
// It adds an "eyes" (👀) reaction to the inbound message and returns an undo function
|
||||||
|
// that removes the reaction.
|
||||||
|
func (c *SlackChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
|
||||||
|
channelID, _ := parseSlackChatID(chatID)
|
||||||
|
if channelID == "" {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.api.AddReaction("eyes", slack.ItemRef{
|
||||||
|
Channel: channelID,
|
||||||
|
Timestamp: messageID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
c.api.RemoveReaction("eyes", slack.ItemRef{
|
||||||
|
Channel: channelID,
|
||||||
|
Timestamp: messageID,
|
||||||
|
})
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *SlackChannel) eventLoop() {
|
func (c *SlackChannel) eventLoop() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
|
@ -201,7 +276,12 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// check allowlist to avoid downloading attachments for rejected users
|
// check allowlist to avoid downloading attachments for rejected users
|
||||||
if !c.IsAllowed(ev.User) {
|
sender := bus.SenderInfo{
|
||||||
|
Platform: "slack",
|
||||||
|
PlatformID: ev.User,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("slack", ev.User),
|
||||||
|
}
|
||||||
|
if !c.IsAllowedSender(sender) {
|
||||||
logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{
|
logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{
|
||||||
"user_id": ev.User,
|
"user_id": ev.User,
|
||||||
})
|
})
|
||||||
|
|
@ -218,11 +298,6 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
chatID = channelID + "/" + threadTS
|
chatID = channelID + "/" + threadTS
|
||||||
}
|
}
|
||||||
|
|
||||||
c.api.AddReaction("eyes", slack.ItemRef{
|
|
||||||
Channel: channelID,
|
|
||||||
Timestamp: messageTS,
|
|
||||||
})
|
|
||||||
|
|
||||||
c.pendingAcks.Store(chatID, slackMessageRef{
|
c.pendingAcks.Store(chatID, slackMessageRef{
|
||||||
ChannelID: channelID,
|
ChannelID: channelID,
|
||||||
Timestamp: messageTS,
|
Timestamp: messageTS,
|
||||||
|
|
@ -231,20 +306,32 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
content := ev.Text
|
content := ev.Text
|
||||||
content = c.stripBotMention(content)
|
content = c.stripBotMention(content)
|
||||||
|
|
||||||
var mediaPaths []string
|
// In non-DM channels, apply group trigger filtering
|
||||||
localFiles := []string{} // track local files that need cleanup
|
if !strings.HasPrefix(channelID, "D") {
|
||||||
|
respond, cleaned := c.ShouldRespondInGroup(false, content)
|
||||||
|
if !respond {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content = cleaned
|
||||||
|
}
|
||||||
|
|
||||||
// ensure temp files are cleaned up when function returns
|
var mediaPaths []string
|
||||||
defer func() {
|
|
||||||
for _, file := range localFiles {
|
scope := channels.BuildMediaScope("slack", chatID, messageTS)
|
||||||
if err := os.Remove(file); err != nil {
|
|
||||||
logger.DebugCF("slack", "Failed to cleanup temp file", map[string]any{
|
// Helper to register a local file with the media store
|
||||||
"file": file,
|
storeMedia := func(localPath, filename string) string {
|
||||||
"error": err.Error(),
|
if store := c.GetMediaStore(); store != nil {
|
||||||
})
|
ref, err := store.Store(localPath, media.MediaMeta{
|
||||||
|
Filename: filename,
|
||||||
|
Source: "slack",
|
||||||
|
}, scope)
|
||||||
|
if err == nil {
|
||||||
|
return ref
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
return localPath // fallback
|
||||||
|
}
|
||||||
|
|
||||||
if ev.Message != nil && len(ev.Message.Files) > 0 {
|
if ev.Message != nil && len(ev.Message.Files) > 0 {
|
||||||
for _, file := range ev.Message.Files {
|
for _, file := range ev.Message.Files {
|
||||||
|
|
@ -252,25 +339,10 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
if localPath == "" {
|
if localPath == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
localFiles = append(localFiles, localPath)
|
mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name))
|
||||||
mediaPaths = append(mediaPaths, localPath)
|
|
||||||
|
|
||||||
if utils.IsAudioFile(file.Name, file.Mimetype) && c.transcriber != nil && c.transcriber.IsAvailable() {
|
|
||||||
ctx, cancel := context.WithTimeout(c.ctx, 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
result, err := c.transcriber.Transcribe(ctx, localPath)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("slack", "Voice transcription failed", map[string]any{"error": err.Error()})
|
|
||||||
content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name)
|
|
||||||
} else {
|
|
||||||
content += fmt.Sprintf("\n[voice transcription: %s]", result.Text)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
content += fmt.Sprintf("\n[file: %s]", file.Name)
|
content += fmt.Sprintf("\n[file: %s]", file.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(content) == "" {
|
if strings.TrimSpace(content) == "" {
|
||||||
return
|
return
|
||||||
|
|
@ -283,13 +355,13 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
peerID = senderID
|
peerID = senderID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
peer := bus.Peer{Kind: peerKind, ID: peerID}
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"message_ts": messageTS,
|
"message_ts": messageTS,
|
||||||
"channel_id": channelID,
|
"channel_id": channelID,
|
||||||
"thread_ts": threadTS,
|
"thread_ts": threadTS,
|
||||||
"platform": "slack",
|
"platform": "slack",
|
||||||
"peer_kind": peerKind,
|
|
||||||
"peer_id": peerID,
|
|
||||||
"team_id": c.teamID,
|
"team_id": c.teamID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,7 +372,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
"has_thread": threadTS != "",
|
"has_thread": threadTS != "",
|
||||||
})
|
})
|
||||||
|
|
||||||
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
|
c.HandleMessage(c.ctx, peer, messageTS, senderID, chatID, content, mediaPaths, metadata, sender)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
|
func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
|
||||||
|
|
@ -308,7 +380,11 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !c.IsAllowed(ev.User) {
|
if !c.IsAllowedSender(bus.SenderInfo{
|
||||||
|
Platform: "slack",
|
||||||
|
PlatformID: ev.User,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("slack", ev.User),
|
||||||
|
}) {
|
||||||
logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{
|
logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{
|
||||||
"user_id": ev.User,
|
"user_id": ev.User,
|
||||||
})
|
})
|
||||||
|
|
@ -316,6 +392,11 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
senderID := ev.User
|
senderID := ev.User
|
||||||
|
mentionSender := bus.SenderInfo{
|
||||||
|
Platform: "slack",
|
||||||
|
PlatformID: senderID,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("slack", senderID),
|
||||||
|
}
|
||||||
channelID := ev.Channel
|
channelID := ev.Channel
|
||||||
threadTS := ev.ThreadTimeStamp
|
threadTS := ev.ThreadTimeStamp
|
||||||
messageTS := ev.TimeStamp
|
messageTS := ev.TimeStamp
|
||||||
|
|
@ -327,11 +408,6 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
|
||||||
chatID = channelID + "/" + messageTS
|
chatID = channelID + "/" + messageTS
|
||||||
}
|
}
|
||||||
|
|
||||||
c.api.AddReaction("eyes", slack.ItemRef{
|
|
||||||
Channel: channelID,
|
|
||||||
Timestamp: messageTS,
|
|
||||||
})
|
|
||||||
|
|
||||||
c.pendingAcks.Store(chatID, slackMessageRef{
|
c.pendingAcks.Store(chatID, slackMessageRef{
|
||||||
ChannelID: channelID,
|
ChannelID: channelID,
|
||||||
Timestamp: messageTS,
|
Timestamp: messageTS,
|
||||||
|
|
@ -350,18 +426,18 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
|
||||||
mentionPeerID = senderID
|
mentionPeerID = senderID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mentionPeer := bus.Peer{Kind: mentionPeerKind, ID: mentionPeerID}
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"message_ts": messageTS,
|
"message_ts": messageTS,
|
||||||
"channel_id": channelID,
|
"channel_id": channelID,
|
||||||
"thread_ts": threadTS,
|
"thread_ts": threadTS,
|
||||||
"platform": "slack",
|
"platform": "slack",
|
||||||
"is_mention": "true",
|
"is_mention": "true",
|
||||||
"peer_kind": mentionPeerKind,
|
|
||||||
"peer_id": mentionPeerID,
|
|
||||||
"team_id": c.teamID,
|
"team_id": c.teamID,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
c.HandleMessage(c.ctx, mentionPeer, messageTS, senderID, chatID, content, nil, metadata, mentionSender)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
|
func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
|
||||||
|
|
@ -374,7 +450,12 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
|
||||||
c.socketClient.Ack(*event.Request)
|
c.socketClient.Ack(*event.Request)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !c.IsAllowed(cmd.UserID) {
|
cmdSender := bus.SenderInfo{
|
||||||
|
Platform: "slack",
|
||||||
|
PlatformID: cmd.UserID,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("slack", cmd.UserID),
|
||||||
|
}
|
||||||
|
if !c.IsAllowedSender(cmdSender) {
|
||||||
logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{
|
logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{
|
||||||
"user_id": cmd.UserID,
|
"user_id": cmd.UserID,
|
||||||
})
|
})
|
||||||
|
|
@ -395,8 +476,6 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
|
||||||
"platform": "slack",
|
"platform": "slack",
|
||||||
"is_command": "true",
|
"is_command": "true",
|
||||||
"trigger_id": cmd.TriggerID,
|
"trigger_id": cmd.TriggerID,
|
||||||
"peer_kind": "channel",
|
|
||||||
"peer_id": channelID,
|
|
||||||
"team_id": c.teamID,
|
"team_id": c.teamID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -406,7 +485,17 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
|
||||||
"text": utils.Truncate(content, 50),
|
"text": utils.Truncate(content, 50),
|
||||||
})
|
})
|
||||||
|
|
||||||
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
c.HandleMessage(
|
||||||
|
c.ctx,
|
||||||
|
bus.Peer{Kind: "channel", ID: channelID},
|
||||||
|
"",
|
||||||
|
senderID,
|
||||||
|
chatID,
|
||||||
|
content,
|
||||||
|
nil,
|
||||||
|
metadata,
|
||||||
|
cmdSender,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SlackChannel) downloadSlackFile(file slack.File) string {
|
func (c *SlackChannel) downloadSlackFile(file slack.File) string {
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package channels
|
package slack
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
208
pkg/channels/split.go
Normal file
208
pkg/channels/split.go
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SplitMessage splits long messages into chunks, preserving code block integrity.
|
||||||
|
// The maxLen parameter is measured in runes (Unicode characters), not bytes.
|
||||||
|
// The function reserves a buffer (10% of maxLen, min 50) to leave room for closing code blocks,
|
||||||
|
// but may extend to maxLen when needed.
|
||||||
|
// Call SplitMessage with the full text content and the maximum allowed length of a single message;
|
||||||
|
// it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks.
|
||||||
|
func SplitMessage(content string, maxLen int) []string {
|
||||||
|
if maxLen <= 0 {
|
||||||
|
if content == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{content}
|
||||||
|
}
|
||||||
|
|
||||||
|
runes := []rune(content)
|
||||||
|
totalLen := len(runes)
|
||||||
|
var messages []string
|
||||||
|
|
||||||
|
// Dynamic buffer: 10% of maxLen, but at least 50 chars if possible
|
||||||
|
codeBlockBuffer := max(maxLen/10, 50)
|
||||||
|
if codeBlockBuffer > maxLen/2 {
|
||||||
|
codeBlockBuffer = maxLen / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
start := 0
|
||||||
|
for start < totalLen {
|
||||||
|
remaining := totalLen - start
|
||||||
|
if remaining <= maxLen {
|
||||||
|
messages = append(messages, string(runes[start:totalLen]))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effective split point: maxLen minus buffer, to leave room for code blocks
|
||||||
|
effectiveLimit := max(maxLen-codeBlockBuffer, maxLen/2)
|
||||||
|
|
||||||
|
end := start + effectiveLimit
|
||||||
|
|
||||||
|
// Find natural split point within the effective limit
|
||||||
|
msgEnd := findLastNewlineInRange(runes, start, end, 200)
|
||||||
|
if msgEnd <= start {
|
||||||
|
msgEnd = findLastSpaceInRange(runes, start, end, 100)
|
||||||
|
}
|
||||||
|
if msgEnd <= start {
|
||||||
|
msgEnd = end
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this would end with an incomplete code block
|
||||||
|
unclosedIdx := findLastUnclosedCodeBlockInRange(runes, start, msgEnd)
|
||||||
|
|
||||||
|
if unclosedIdx >= 0 {
|
||||||
|
// Message would end with incomplete code block
|
||||||
|
// Try to extend up to maxLen to include the closing ```
|
||||||
|
if totalLen > msgEnd {
|
||||||
|
closingIdx := findNextClosingCodeBlockInRange(runes, msgEnd, totalLen)
|
||||||
|
if closingIdx > 0 && closingIdx-start <= maxLen {
|
||||||
|
// Extend to include the closing ```
|
||||||
|
msgEnd = closingIdx
|
||||||
|
} else {
|
||||||
|
// Code block is too long to fit in one chunk or missing closing fence.
|
||||||
|
// Try to split inside by injecting closing and reopening fences.
|
||||||
|
headerEnd := findNewlineFrom(runes, unclosedIdx)
|
||||||
|
var header string
|
||||||
|
if headerEnd == -1 {
|
||||||
|
header = strings.TrimSpace(string(runes[unclosedIdx : unclosedIdx+3]))
|
||||||
|
} else {
|
||||||
|
header = strings.TrimSpace(string(runes[unclosedIdx:headerEnd]))
|
||||||
|
}
|
||||||
|
headerEndIdx := unclosedIdx + len([]rune(header))
|
||||||
|
if headerEnd != -1 {
|
||||||
|
headerEndIdx = headerEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a reasonable amount of content after the header, split inside
|
||||||
|
if msgEnd > headerEndIdx+20 {
|
||||||
|
// Find a better split point closer to maxLen
|
||||||
|
innerLimit := min(
|
||||||
|
// Leave room for "\n```"
|
||||||
|
start+maxLen-5, totalLen)
|
||||||
|
betterEnd := findLastNewlineInRange(runes, start, innerLimit, 200)
|
||||||
|
if betterEnd > headerEndIdx {
|
||||||
|
msgEnd = betterEnd
|
||||||
|
} else {
|
||||||
|
msgEnd = innerLimit
|
||||||
|
}
|
||||||
|
chunk := strings.TrimRight(string(runes[start:msgEnd]), " \t\n\r") + "\n```"
|
||||||
|
messages = append(messages, chunk)
|
||||||
|
remaining := strings.TrimSpace(header + "\n" + string(runes[msgEnd:totalLen]))
|
||||||
|
// Replace the tail of runes with the reconstructed remaining
|
||||||
|
runes = []rune(remaining)
|
||||||
|
totalLen = len(runes)
|
||||||
|
start = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, try to split before the code block starts
|
||||||
|
newEnd := findLastNewlineInRange(runes, start, unclosedIdx, 200)
|
||||||
|
if newEnd <= start {
|
||||||
|
newEnd = findLastSpaceInRange(runes, start, unclosedIdx, 100)
|
||||||
|
}
|
||||||
|
if newEnd > start {
|
||||||
|
msgEnd = newEnd
|
||||||
|
} else {
|
||||||
|
// If we can't split before, we MUST split inside (last resort)
|
||||||
|
if unclosedIdx-start > 20 {
|
||||||
|
msgEnd = unclosedIdx
|
||||||
|
} else {
|
||||||
|
splitAt := min(start+maxLen-5, totalLen)
|
||||||
|
chunk := strings.TrimRight(string(runes[start:splitAt]), " \t\n\r") + "\n```"
|
||||||
|
messages = append(messages, chunk)
|
||||||
|
remaining := strings.TrimSpace(header + "\n" + string(runes[splitAt:totalLen]))
|
||||||
|
runes = []rune(remaining)
|
||||||
|
totalLen = len(runes)
|
||||||
|
start = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if msgEnd <= start {
|
||||||
|
msgEnd = start + effectiveLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = append(messages, string(runes[start:msgEnd]))
|
||||||
|
// Advance start, skipping leading whitespace of next chunk
|
||||||
|
start = msgEnd
|
||||||
|
for start < totalLen && (runes[start] == ' ' || runes[start] == '\t' || runes[start] == '\n' || runes[start] == '\r') {
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLastUnclosedCodeBlockInRange finds the last opening ``` that doesn't have a closing ```
|
||||||
|
// within runes[start:end]. Returns the absolute rune index or -1.
|
||||||
|
func findLastUnclosedCodeBlockInRange(runes []rune, start, end int) int {
|
||||||
|
inCodeBlock := false
|
||||||
|
lastOpenIdx := -1
|
||||||
|
|
||||||
|
for i := start; i < end; i++ {
|
||||||
|
if i+2 < end && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' {
|
||||||
|
if !inCodeBlock {
|
||||||
|
lastOpenIdx = i
|
||||||
|
}
|
||||||
|
inCodeBlock = !inCodeBlock
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if inCodeBlock {
|
||||||
|
return lastOpenIdx
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// findNextClosingCodeBlockInRange finds the next closing ``` starting from startIdx
|
||||||
|
// within runes[startIdx:end]. Returns the absolute index after the closing ``` or -1.
|
||||||
|
func findNextClosingCodeBlockInRange(runes []rune, startIdx, end int) int {
|
||||||
|
for i := startIdx; i < end; i++ {
|
||||||
|
if i+2 < end && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' {
|
||||||
|
return i + 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// findNewlineFrom finds the first newline character starting from the given index.
|
||||||
|
// Returns the absolute index or -1 if not found.
|
||||||
|
func findNewlineFrom(runes []rune, from int) int {
|
||||||
|
for i := from; i < len(runes); i++ {
|
||||||
|
if runes[i] == '\n' {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLastNewlineInRange finds the last newline within the last searchWindow runes
|
||||||
|
// of the range runes[start:end]. Returns the absolute index or start-1 (indicating not found).
|
||||||
|
func findLastNewlineInRange(runes []rune, start, end, searchWindow int) int {
|
||||||
|
searchStart := max(end-searchWindow, start)
|
||||||
|
for i := end - 1; i >= searchStart; i-- {
|
||||||
|
if runes[i] == '\n' {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return start - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLastSpaceInRange finds the last space/tab within the last searchWindow runes
|
||||||
|
// of the range runes[start:end]. Returns the absolute index or start-1 (indicating not found).
|
||||||
|
func findLastSpaceInRange(runes []rune, start, end, searchWindow int) int {
|
||||||
|
searchStart := max(end-searchWindow, start)
|
||||||
|
for i := end - 1; i >= searchStart; i-- {
|
||||||
|
if runes[i] == ' ' || runes[i] == '\t' {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return start - 1
|
||||||
|
}
|
||||||
362
pkg/channels/split_test.go
Normal file
362
pkg/channels/split_test.go
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSplitMessage(t *testing.T) {
|
||||||
|
longText := strings.Repeat("a", 2500)
|
||||||
|
longCode := "```go\n" + strings.Repeat("fmt.Println(\"hello\")\n", 100) + "```" // ~2100 chars
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
maxLen int
|
||||||
|
expectChunks int // Check number of chunks
|
||||||
|
checkContent func(t *testing.T, chunks []string) // Custom validation
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Empty message",
|
||||||
|
content: "",
|
||||||
|
maxLen: 2000,
|
||||||
|
expectChunks: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Short message fits in one chunk",
|
||||||
|
content: "Hello world",
|
||||||
|
maxLen: 2000,
|
||||||
|
expectChunks: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Simple split regular text",
|
||||||
|
content: longText,
|
||||||
|
maxLen: 2000,
|
||||||
|
expectChunks: 2,
|
||||||
|
checkContent: func(t *testing.T, chunks []string) {
|
||||||
|
if len([]rune(chunks[0])) > 2000 {
|
||||||
|
t.Errorf("Chunk 0 too large: %d runes", len([]rune(chunks[0])))
|
||||||
|
}
|
||||||
|
if len([]rune(chunks[0]))+len([]rune(chunks[1])) != len([]rune(longText)) {
|
||||||
|
t.Errorf(
|
||||||
|
"Total rune length mismatch. Got %d, want %d",
|
||||||
|
len([]rune(chunks[0]))+len([]rune(chunks[1])),
|
||||||
|
len([]rune(longText)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Split at newline",
|
||||||
|
// 1750 chars then newline, then more chars.
|
||||||
|
// Dynamic buffer: 2000 / 10 = 200.
|
||||||
|
// Effective limit: 2000 - 200 = 1800.
|
||||||
|
// Split should happen at newline because it's at 1750 (< 1800).
|
||||||
|
// Total length must > 2000 to trigger split. 1750 + 1 + 300 = 2051.
|
||||||
|
content: strings.Repeat("a", 1750) + "\n" + strings.Repeat("b", 300),
|
||||||
|
maxLen: 2000,
|
||||||
|
expectChunks: 2,
|
||||||
|
checkContent: func(t *testing.T, chunks []string) {
|
||||||
|
if len([]rune(chunks[0])) != 1750 {
|
||||||
|
t.Errorf("Expected chunk 0 to be 1750 runes (split at newline), got %d", len([]rune(chunks[0])))
|
||||||
|
}
|
||||||
|
if chunks[1] != strings.Repeat("b", 300) {
|
||||||
|
t.Errorf("Chunk 1 content mismatch. Len: %d", len([]rune(chunks[1])))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Long code block split",
|
||||||
|
content: "Prefix\n" + longCode,
|
||||||
|
maxLen: 2000,
|
||||||
|
expectChunks: 2,
|
||||||
|
checkContent: func(t *testing.T, chunks []string) {
|
||||||
|
// Check that first chunk ends with closing fence
|
||||||
|
if !strings.HasSuffix(chunks[0], "\n```") {
|
||||||
|
t.Error("First chunk should end with injected closing fence")
|
||||||
|
}
|
||||||
|
// Check that second chunk starts with execution header
|
||||||
|
if !strings.HasPrefix(chunks[1], "```go") {
|
||||||
|
t.Error("Second chunk should start with injected code block header")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Preserve Unicode characters (rune-aware)",
|
||||||
|
content: strings.Repeat("\u4e16", 2500), // 2500 runes, 7500 bytes
|
||||||
|
maxLen: 2000,
|
||||||
|
expectChunks: 2,
|
||||||
|
checkContent: func(t *testing.T, chunks []string) {
|
||||||
|
// Verify chunks contain valid unicode and don't split mid-rune
|
||||||
|
for i, chunk := range chunks {
|
||||||
|
runeCount := len([]rune(chunk))
|
||||||
|
if runeCount > 2000 {
|
||||||
|
t.Errorf("Chunk %d has %d runes, exceeds maxLen 2000", i, runeCount)
|
||||||
|
}
|
||||||
|
if !strings.Contains(chunk, "\u4e16") {
|
||||||
|
t.Errorf("Chunk %d should contain unicode characters", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Verify total rune count is preserved
|
||||||
|
totalRunes := 0
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
totalRunes += len([]rune(chunk))
|
||||||
|
}
|
||||||
|
if totalRunes != 2500 {
|
||||||
|
t.Errorf("Total rune count mismatch. Got %d, want 2500", totalRunes)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Zero maxLen returns single chunk",
|
||||||
|
content: "Hello world",
|
||||||
|
maxLen: 0,
|
||||||
|
expectChunks: 1,
|
||||||
|
checkContent: func(t *testing.T, chunks []string) {
|
||||||
|
if chunks[0] != "Hello world" {
|
||||||
|
t.Errorf("Expected original content, got %q", chunks[0])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := SplitMessage(tc.content, tc.maxLen)
|
||||||
|
|
||||||
|
if tc.expectChunks == 0 {
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("Expected 0 chunks, got %d", len(got))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != tc.expectChunks {
|
||||||
|
t.Errorf("Expected %d chunks, got %d", tc.expectChunks, len(got))
|
||||||
|
// Log sizes for debugging
|
||||||
|
for i, c := range got {
|
||||||
|
t.Logf("Chunk %d length: %d", i, len(c))
|
||||||
|
}
|
||||||
|
return // Stop further checks if count assumes specific split
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.checkContent != nil {
|
||||||
|
tc.checkContent(t, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper function tests for index-based rune operations ---
|
||||||
|
|
||||||
|
func TestFindLastNewlineInRange(t *testing.T) {
|
||||||
|
runes := []rune("aaa\nbbb\nccc")
|
||||||
|
// Indices: 0123 4567 89 10
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
start, end int
|
||||||
|
searchWindow int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"finds last newline in full range", 0, 11, 200, 7},
|
||||||
|
{"finds newline within search window", 0, 11, 4, 7},
|
||||||
|
{"narrow window misses newline outside window", 4, 11, 3, 3}, // returns start-1 (not found)
|
||||||
|
{"no newline in range", 0, 3, 200, -1}, // start-1 = -1
|
||||||
|
{"range limited to first segment", 0, 4, 200, 3},
|
||||||
|
{"search window of 1 at newline", 0, 8, 1, 7},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := findLastNewlineInRange(runes, tc.start, tc.end, tc.searchWindow)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("findLastNewlineInRange(runes, %d, %d, %d) = %d, want %d",
|
||||||
|
tc.start, tc.end, tc.searchWindow, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindLastSpaceInRange(t *testing.T) {
|
||||||
|
runes := []rune("abc def\tghi")
|
||||||
|
// Indices: 0123 4567 89 10
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
start, end int
|
||||||
|
searchWindow int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"finds tab as last space/tab", 0, 11, 200, 7},
|
||||||
|
{"finds space when tab out of window", 0, 7, 200, 3},
|
||||||
|
{"no space in range", 0, 3, 200, -1},
|
||||||
|
{"narrow window finds tab", 5, 11, 4, 7},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := findLastSpaceInRange(runes, tc.start, tc.end, tc.searchWindow)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("findLastSpaceInRange(runes, %d, %d, %d) = %d, want %d",
|
||||||
|
tc.start, tc.end, tc.searchWindow, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindNewlineFrom(t *testing.T) {
|
||||||
|
runes := []rune("hello\nworld\n")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
from int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"from start", 0, 5},
|
||||||
|
{"from after first newline", 6, 11},
|
||||||
|
{"from past all newlines", 12, -1},
|
||||||
|
{"from newline itself", 5, 5},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := findNewlineFrom(runes, tc.from)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("findNewlineFrom(runes, %d) = %d, want %d", tc.from, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindLastUnclosedCodeBlockInRange(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
start, end int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no code blocks",
|
||||||
|
content: "hello world",
|
||||||
|
start: 0, end: 11,
|
||||||
|
want: -1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complete code block",
|
||||||
|
content: "```go\ncode\n```",
|
||||||
|
start: 0, end: 14,
|
||||||
|
want: -1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unclosed code block",
|
||||||
|
content: "text\n```go\ncode here",
|
||||||
|
start: 0, end: 20,
|
||||||
|
want: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "closed then unclosed",
|
||||||
|
content: "```a\n```\n```b\ncode",
|
||||||
|
start: 0, end: 17,
|
||||||
|
want: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "search within subrange",
|
||||||
|
content: "```a\n```\n```b\ncode",
|
||||||
|
start: 9, end: 17,
|
||||||
|
want: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subrange with no code blocks",
|
||||||
|
content: "```a\n```\nhello",
|
||||||
|
start: 9, end: 14,
|
||||||
|
want: -1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
runes := []rune(tc.content)
|
||||||
|
got := findLastUnclosedCodeBlockInRange(runes, tc.start, tc.end)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("findLastUnclosedCodeBlockInRange(%q, %d, %d) = %d, want %d",
|
||||||
|
tc.content, tc.start, tc.end, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindNextClosingCodeBlockInRange(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
startIdx int
|
||||||
|
end int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "finds closing fence",
|
||||||
|
content: "code\n```\nmore",
|
||||||
|
startIdx: 0, end: 13,
|
||||||
|
want: 8, // position after ```
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no closing fence",
|
||||||
|
content: "just code here",
|
||||||
|
startIdx: 0, end: 14,
|
||||||
|
want: -1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fence at start of search",
|
||||||
|
content: "```end",
|
||||||
|
startIdx: 0, end: 6,
|
||||||
|
want: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fence outside range",
|
||||||
|
content: "code\n```",
|
||||||
|
startIdx: 0, end: 4,
|
||||||
|
want: -1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
runes := []rune(tc.content)
|
||||||
|
got := findNextClosingCodeBlockInRange(runes, tc.startIdx, tc.end)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("findNextClosingCodeBlockInRange(%q, %d, %d) = %d, want %d",
|
||||||
|
tc.content, tc.startIdx, tc.end, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitMessage_CodeBlockIntegrity(t *testing.T) {
|
||||||
|
// Focused test for the core requirement: splitting inside a code block preserves syntax highlighting
|
||||||
|
|
||||||
|
// 60 chars total approximately
|
||||||
|
content := "```go\npackage main\n\nfunc main() {\n\tprintln(\"Hello\")\n}\n```"
|
||||||
|
maxLen := 40
|
||||||
|
|
||||||
|
chunks := SplitMessage(content, maxLen)
|
||||||
|
|
||||||
|
if len(chunks) != 2 {
|
||||||
|
t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First chunk must end with "\n```"
|
||||||
|
if !strings.HasSuffix(chunks[0], "\n```") {
|
||||||
|
t.Errorf("First chunk should end with closing fence. Got: %q", chunks[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second chunk must start with the header "```go"
|
||||||
|
if !strings.HasPrefix(chunks[1], "```go") {
|
||||||
|
t.Errorf("Second chunk should start with code block header. Got: %q", chunks[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// First chunk should contain meaningful content
|
||||||
|
if len([]rune(chunks[0])) > 40 {
|
||||||
|
t.Errorf("First chunk exceeded maxLen: length %d runes", len([]rune(chunks[0])))
|
||||||
|
}
|
||||||
|
}
|
||||||
13
pkg/channels/telegram/init.go
Normal file
13
pkg/channels/telegram/init.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewTelegramChannel(cfg, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package channels
|
package telegram
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -7,8 +7,8 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
"github.com/mymmrac/telego"
|
||||||
|
|
@ -17,10 +17,12 @@ import (
|
||||||
tu "github.com/mymmrac/telego/telegoutil"
|
tu "github.com/mymmrac/telego/telegoutil"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
"github.com/sipeed/picoclaw/pkg/voice"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -37,24 +39,14 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
type TelegramChannel struct {
|
type TelegramChannel struct {
|
||||||
*BaseChannel
|
*channels.BaseChannel
|
||||||
bot *telego.Bot
|
bot *telego.Bot
|
||||||
|
bh *telegohandler.BotHandler
|
||||||
commands TelegramCommander
|
commands TelegramCommander
|
||||||
config *config.Config
|
config *config.Config
|
||||||
chatIDs map[string]int64
|
chatIDs map[string]int64
|
||||||
transcriber *voice.GroqTranscriber
|
ctx context.Context
|
||||||
placeholders sync.Map // chatID -> messageID
|
cancel context.CancelFunc
|
||||||
stopThinking sync.Map // chatID -> thinkingCancel
|
|
||||||
}
|
|
||||||
|
|
||||||
type thinkingCancel struct {
|
|
||||||
fn context.CancelFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *thinkingCancel) Cancel() {
|
|
||||||
if c != nil && c.fn != nil {
|
|
||||||
c.fn()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
|
func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
|
||||||
|
|
@ -85,7 +77,15 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
|
||||||
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
|
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
base := NewBaseChannel("telegram", telegramCfg, bus, telegramCfg.AllowFrom)
|
base := channels.NewBaseChannel(
|
||||||
|
"telegram",
|
||||||
|
telegramCfg,
|
||||||
|
bus,
|
||||||
|
telegramCfg.AllowFrom,
|
||||||
|
channels.WithMaxMessageLength(4096),
|
||||||
|
channels.WithGroupTrigger(telegramCfg.GroupTrigger),
|
||||||
|
channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID),
|
||||||
|
)
|
||||||
|
|
||||||
return &TelegramChannel{
|
return &TelegramChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
|
|
@ -93,30 +93,28 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
|
||||||
bot: bot,
|
bot: bot,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
chatIDs: make(map[string]int64),
|
chatIDs: make(map[string]int64),
|
||||||
transcriber: nil,
|
|
||||||
placeholders: sync.Map{},
|
|
||||||
stopThinking: sync.Map{},
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
|
|
||||||
c.transcriber = transcriber
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TelegramChannel) Start(ctx context.Context) error {
|
func (c *TelegramChannel) Start(ctx context.Context) error {
|
||||||
logger.InfoC("telegram", "Starting Telegram bot (polling mode)...")
|
logger.InfoC("telegram", "Starting Telegram bot (polling mode)...")
|
||||||
|
|
||||||
updates, err := c.bot.UpdatesViaLongPolling(ctx, &telego.GetUpdatesParams{
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
|
updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{
|
||||||
Timeout: 30,
|
Timeout: 30,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
c.cancel()
|
||||||
return fmt.Errorf("failed to start long polling: %w", err)
|
return fmt.Errorf("failed to start long polling: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
bh, err := telegohandler.NewBotHandler(c.bot, updates)
|
bh, err := telegohandler.NewBotHandler(c.bot, updates)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
c.cancel()
|
||||||
return fmt.Errorf("failed to create bot handler: %w", err)
|
return fmt.Errorf("failed to create bot handler: %w", err)
|
||||||
}
|
}
|
||||||
|
c.bh = bh
|
||||||
|
|
||||||
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
||||||
c.commands.Help(ctx, message)
|
c.commands.Help(ctx, message)
|
||||||
|
|
@ -138,59 +136,46 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
||||||
return c.handleMessage(ctx, &message)
|
return c.handleMessage(ctx, &message)
|
||||||
}, th.AnyMessage())
|
}, th.AnyMessage())
|
||||||
|
|
||||||
c.setRunning(true)
|
c.SetRunning(true)
|
||||||
logger.InfoCF("telegram", "Telegram bot connected", map[string]any{
|
logger.InfoCF("telegram", "Telegram bot connected", map[string]any{
|
||||||
"username": c.bot.Username(),
|
"username": c.bot.Username(),
|
||||||
})
|
})
|
||||||
|
|
||||||
go bh.Start()
|
go bh.Start()
|
||||||
|
|
||||||
go func() {
|
|
||||||
<-ctx.Done()
|
|
||||||
bh.Stop()
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TelegramChannel) Stop(ctx context.Context) error {
|
func (c *TelegramChannel) Stop(ctx context.Context) error {
|
||||||
logger.InfoC("telegram", "Stopping Telegram bot...")
|
logger.InfoC("telegram", "Stopping Telegram bot...")
|
||||||
c.setRunning(false)
|
c.SetRunning(false)
|
||||||
|
|
||||||
|
// Stop the bot handler
|
||||||
|
if c.bh != nil {
|
||||||
|
c.bh.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel our context (stops long polling)
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return fmt.Errorf("telegram bot not running")
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
chatID, err := parseChatID(msg.ChatID)
|
chatID, err := parseChatID(msg.ChatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid chat ID: %w", err)
|
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
||||||
}
|
|
||||||
|
|
||||||
// Stop thinking animation
|
|
||||||
if stop, ok := c.stopThinking.Load(msg.ChatID); ok {
|
|
||||||
if cf, ok := stop.(*thinkingCancel); ok && cf != nil {
|
|
||||||
cf.Cancel()
|
|
||||||
}
|
|
||||||
c.stopThinking.Delete(msg.ChatID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
htmlContent := markdownToTelegramHTML(msg.Content)
|
htmlContent := markdownToTelegramHTML(msg.Content)
|
||||||
|
|
||||||
// Try to edit placeholder
|
// Typing/placeholder handled by Manager.preSend — just send the message
|
||||||
if pID, ok := c.placeholders.Load(msg.ChatID); ok {
|
|
||||||
c.placeholders.Delete(msg.ChatID)
|
|
||||||
editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent)
|
|
||||||
editMsg.ParseMode = telego.ModeHTML
|
|
||||||
|
|
||||||
if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Fallback to new message if edit fails
|
|
||||||
}
|
|
||||||
|
|
||||||
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
||||||
tgMsg.ParseMode = telego.ModeHTML
|
tgMsg.ParseMode = telego.ModeHTML
|
||||||
|
|
||||||
|
|
@ -199,9 +184,164 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
tgMsg.ParseMode = ""
|
tgMsg.ParseMode = ""
|
||||||
_, err = c.bot.SendMessage(ctx, tgMsg)
|
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
|
||||||
|
return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartTyping implements channels.TypingCapable.
|
||||||
|
// It sends ChatAction(typing) immediately and then repeats every 4 seconds
|
||||||
|
// (Telegram's typing indicator expires after ~5s) in a background goroutine.
|
||||||
|
// The returned stop function is idempotent and cancels the goroutine.
|
||||||
|
func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
||||||
|
cid, err := parseChatID(chatID)
|
||||||
|
if err != nil {
|
||||||
|
return func() {}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the first typing action immediately
|
||||||
|
_ = c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping))
|
||||||
|
|
||||||
|
typingCtx, cancel := context.WithCancel(ctx)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(4 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-typingCtx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
_ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return cancel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditMessage implements channels.MessageEditor.
|
||||||
|
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
||||||
|
cid, err := parseChatID(chatID)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
mid, err := strconv.Atoi(messageID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
htmlContent := markdownToTelegramHTML(content)
|
||||||
|
editMsg := tu.EditMessageText(tu.ID(cid), mid, htmlContent)
|
||||||
|
editMsg.ParseMode = telego.ModeHTML
|
||||||
|
_, err = c.bot.EditMessageText(ctx, editMsg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPlaceholder implements channels.PlaceholderCapable.
|
||||||
|
// It sends a placeholder message (e.g. "Thinking... 💭") that will later be
|
||||||
|
// edited to the actual response via EditMessage (channels.MessageEditor).
|
||||||
|
func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
||||||
|
phCfg := c.config.Channels.Telegram.Placeholder
|
||||||
|
if !phCfg.Enabled {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
text := phCfg.Text
|
||||||
|
if text == "" {
|
||||||
|
text = "Thinking... 💭"
|
||||||
|
}
|
||||||
|
|
||||||
|
cid, err := parseChatID(chatID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(cid), text))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%d", pMsg.MessageID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMedia implements the channels.MediaSender interface.
|
||||||
|
func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
chatID, err := parseChatID(msg.ChatID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := c.GetMediaStore()
|
||||||
|
if store == nil {
|
||||||
|
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, part := range msg.Parts {
|
||||||
|
localPath, err := store.Resolve(part.Ref)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("telegram", "Failed to resolve media ref", map[string]any{
|
||||||
|
"ref": part.Ref,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(localPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("telegram", "Failed to open media file", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch part.Type {
|
||||||
|
case "image":
|
||||||
|
params := &telego.SendPhotoParams{
|
||||||
|
ChatID: tu.ID(chatID),
|
||||||
|
Photo: telego.InputFile{File: file},
|
||||||
|
Caption: part.Caption,
|
||||||
|
}
|
||||||
|
_, err = c.bot.SendPhoto(ctx, params)
|
||||||
|
case "audio":
|
||||||
|
params := &telego.SendAudioParams{
|
||||||
|
ChatID: tu.ID(chatID),
|
||||||
|
Audio: telego.InputFile{File: file},
|
||||||
|
Caption: part.Caption,
|
||||||
|
}
|
||||||
|
_, err = c.bot.SendAudio(ctx, params)
|
||||||
|
case "video":
|
||||||
|
params := &telego.SendVideoParams{
|
||||||
|
ChatID: tu.ID(chatID),
|
||||||
|
Video: telego.InputFile{File: file},
|
||||||
|
Caption: part.Caption,
|
||||||
|
}
|
||||||
|
_, err = c.bot.SendVideo(ctx, params)
|
||||||
|
default: // "file" or unknown types
|
||||||
|
params := &telego.SendDocumentParams{
|
||||||
|
ChatID: tu.ID(chatID),
|
||||||
|
Document: telego.InputFile{File: file},
|
||||||
|
Caption: part.Caption,
|
||||||
|
}
|
||||||
|
_, err = c.bot.SendDocument(ctx, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("telegram", "Failed to send media", map[string]any{
|
||||||
|
"type": part.Type,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return fmt.Errorf("telegram send media: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -216,37 +356,46 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
return fmt.Errorf("message sender (user) is nil")
|
return fmt.Errorf("message sender (user) is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
senderID := fmt.Sprintf("%d", user.ID)
|
platformID := fmt.Sprintf("%d", user.ID)
|
||||||
if user.Username != "" {
|
sender := bus.SenderInfo{
|
||||||
senderID = fmt.Sprintf("%d|%s", user.ID, user.Username)
|
Platform: "telegram",
|
||||||
|
PlatformID: platformID,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("telegram", platformID),
|
||||||
|
Username: user.Username,
|
||||||
|
DisplayName: user.FirstName,
|
||||||
}
|
}
|
||||||
|
|
||||||
// check allowlist to avoid downloading attachments for rejected users
|
// check allowlist to avoid downloading attachments for rejected users
|
||||||
if !c.IsAllowed(senderID) {
|
if !c.IsAllowedSender(sender) {
|
||||||
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
|
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
|
||||||
"user_id": senderID,
|
"user_id": platformID,
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
chatID := message.Chat.ID
|
chatID := message.Chat.ID
|
||||||
c.chatIDs[senderID] = chatID
|
c.chatIDs[platformID] = chatID
|
||||||
|
|
||||||
content := ""
|
content := ""
|
||||||
mediaPaths := []string{}
|
mediaPaths := []string{}
|
||||||
localFiles := []string{} // track local files that need cleanup
|
|
||||||
|
|
||||||
// ensure temp files are cleaned up when function returns
|
chatIDStr := fmt.Sprintf("%d", chatID)
|
||||||
defer func() {
|
messageIDStr := fmt.Sprintf("%d", message.MessageID)
|
||||||
for _, file := range localFiles {
|
scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr)
|
||||||
if err := os.Remove(file); err != nil {
|
|
||||||
logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]any{
|
// Helper to register a local file with the media store
|
||||||
"file": file,
|
storeMedia := func(localPath, filename string) string {
|
||||||
"error": err.Error(),
|
if store := c.GetMediaStore(); store != nil {
|
||||||
})
|
ref, err := store.Store(localPath, media.MediaMeta{
|
||||||
|
Filename: filename,
|
||||||
|
Source: "telegram",
|
||||||
|
}, scope)
|
||||||
|
if err == nil {
|
||||||
|
return ref
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
return localPath // fallback: use raw path
|
||||||
|
}
|
||||||
|
|
||||||
if message.Text != "" {
|
if message.Text != "" {
|
||||||
content += message.Text
|
content += message.Text
|
||||||
|
|
@ -263,8 +412,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
photo := message.Photo[len(message.Photo)-1]
|
photo := message.Photo[len(message.Photo)-1]
|
||||||
photoPath := c.downloadPhoto(ctx, photo.FileID)
|
photoPath := c.downloadPhoto(ctx, photo.FileID)
|
||||||
if photoPath != "" {
|
if photoPath != "" {
|
||||||
localFiles = append(localFiles, photoPath)
|
mediaPaths = append(mediaPaths, storeMedia(photoPath, "photo.jpg"))
|
||||||
mediaPaths = append(mediaPaths, photoPath)
|
|
||||||
if content != "" {
|
if content != "" {
|
||||||
content += "\n"
|
content += "\n"
|
||||||
}
|
}
|
||||||
|
|
@ -275,43 +423,19 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
if message.Voice != nil {
|
if message.Voice != nil {
|
||||||
voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg")
|
voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg")
|
||||||
if voicePath != "" {
|
if voicePath != "" {
|
||||||
localFiles = append(localFiles, voicePath)
|
mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg"))
|
||||||
mediaPaths = append(mediaPaths, voicePath)
|
|
||||||
|
|
||||||
var transcribedText string
|
|
||||||
if c.transcriber != nil && c.transcriber.IsAvailable() {
|
|
||||||
transcriberCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
result, err := c.transcriber.Transcribe(transcriberCtx, voicePath)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("telegram", "Voice transcription failed", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
"path": voicePath,
|
|
||||||
})
|
|
||||||
transcribedText = "[voice (transcription failed)]"
|
|
||||||
} else {
|
|
||||||
transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text)
|
|
||||||
logger.InfoCF("telegram", "Voice transcribed successfully", map[string]any{
|
|
||||||
"text": result.Text,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
transcribedText = "[voice]"
|
|
||||||
}
|
|
||||||
|
|
||||||
if content != "" {
|
if content != "" {
|
||||||
content += "\n"
|
content += "\n"
|
||||||
}
|
}
|
||||||
content += transcribedText
|
content += "[voice]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if message.Audio != nil {
|
if message.Audio != nil {
|
||||||
audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3")
|
audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3")
|
||||||
if audioPath != "" {
|
if audioPath != "" {
|
||||||
localFiles = append(localFiles, audioPath)
|
mediaPaths = append(mediaPaths, storeMedia(audioPath, "audio.mp3"))
|
||||||
mediaPaths = append(mediaPaths, audioPath)
|
|
||||||
if content != "" {
|
if content != "" {
|
||||||
content += "\n"
|
content += "\n"
|
||||||
}
|
}
|
||||||
|
|
@ -322,8 +446,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
if message.Document != nil {
|
if message.Document != nil {
|
||||||
docPath := c.downloadFile(ctx, message.Document.FileID, "")
|
docPath := c.downloadFile(ctx, message.Document.FileID, "")
|
||||||
if docPath != "" {
|
if docPath != "" {
|
||||||
localFiles = append(localFiles, docPath)
|
mediaPaths = append(mediaPaths, storeMedia(docPath, "document"))
|
||||||
mediaPaths = append(mediaPaths, docPath)
|
|
||||||
if content != "" {
|
if content != "" {
|
||||||
content += "\n"
|
content += "\n"
|
||||||
}
|
}
|
||||||
|
|
@ -335,37 +458,26 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
content = "[empty message]"
|
content = "[empty message]"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In group chats, apply unified group trigger filtering
|
||||||
|
if message.Chat.Type != "private" {
|
||||||
|
isMentioned := c.isBotMentioned(message)
|
||||||
|
if isMentioned {
|
||||||
|
content = c.stripBotMention(content)
|
||||||
|
}
|
||||||
|
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
||||||
|
if !respond {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
content = cleaned
|
||||||
|
}
|
||||||
|
|
||||||
logger.DebugCF("telegram", "Received message", map[string]any{
|
logger.DebugCF("telegram", "Received message", map[string]any{
|
||||||
"sender_id": senderID,
|
"sender_id": sender.CanonicalID,
|
||||||
"chat_id": fmt.Sprintf("%d", chatID),
|
"chat_id": fmt.Sprintf("%d", chatID),
|
||||||
"preview": utils.Truncate(content, 50),
|
"preview": utils.Truncate(content, 50),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Thinking indicator
|
// Placeholder is now auto-triggered by BaseChannel.HandleMessage via PlaceholderCapable
|
||||||
err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("telegram", "Failed to send chat action", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop any previous thinking animation
|
|
||||||
chatIDStr := fmt.Sprintf("%d", chatID)
|
|
||||||
if prevStop, ok := c.stopThinking.Load(chatIDStr); ok {
|
|
||||||
if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil {
|
|
||||||
cf.Cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create cancel function for thinking state
|
|
||||||
_, thinkCancel := context.WithTimeout(ctx, 5*time.Minute)
|
|
||||||
c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel})
|
|
||||||
|
|
||||||
pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭"))
|
|
||||||
if err == nil {
|
|
||||||
pID := pMsg.MessageID
|
|
||||||
c.placeholders.Store(chatIDStr, pID)
|
|
||||||
}
|
|
||||||
|
|
||||||
peerKind := "direct"
|
peerKind := "direct"
|
||||||
peerID := fmt.Sprintf("%d", user.ID)
|
peerID := fmt.Sprintf("%d", user.ID)
|
||||||
|
|
@ -374,17 +486,26 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
peerID = fmt.Sprintf("%d", chatID)
|
peerID = fmt.Sprintf("%d", chatID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
peer := bus.Peer{Kind: peerKind, ID: peerID}
|
||||||
|
messageID := fmt.Sprintf("%d", message.MessageID)
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"message_id": fmt.Sprintf("%d", message.MessageID),
|
|
||||||
"user_id": fmt.Sprintf("%d", user.ID),
|
"user_id": fmt.Sprintf("%d", user.ID),
|
||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
"first_name": user.FirstName,
|
"first_name": user.FirstName,
|
||||||
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
||||||
"peer_kind": peerKind,
|
|
||||||
"peer_id": peerID,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
|
c.HandleMessage(c.ctx,
|
||||||
|
peer,
|
||||||
|
messageID,
|
||||||
|
platformID,
|
||||||
|
fmt.Sprintf("%d", chatID),
|
||||||
|
content,
|
||||||
|
mediaPaths,
|
||||||
|
metadata,
|
||||||
|
sender,
|
||||||
|
)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -537,3 +658,52 @@ func escapeHTML(text string) string {
|
||||||
text = strings.ReplaceAll(text, ">", ">")
|
text = strings.ReplaceAll(text, ">", ">")
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isBotMentioned checks if the bot is mentioned in the message via entities.
|
||||||
|
func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
|
||||||
|
botUsername := c.bot.Username()
|
||||||
|
if botUsername == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
entities := message.Entities
|
||||||
|
if entities == nil {
|
||||||
|
entities = message.CaptionEntities
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entity := range entities {
|
||||||
|
if entity.Type == "mention" {
|
||||||
|
// Extract the mention text from the message
|
||||||
|
text := message.Text
|
||||||
|
if text == "" {
|
||||||
|
text = message.Caption
|
||||||
|
}
|
||||||
|
runes := []rune(text)
|
||||||
|
end := entity.Offset + entity.Length
|
||||||
|
if end <= len(runes) {
|
||||||
|
mention := string(runes[entity.Offset:end])
|
||||||
|
if strings.EqualFold(mention, "@"+botUsername) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entity.Type == "text_mention" && entity.User != nil {
|
||||||
|
if entity.User.Username == botUsername {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripBotMention removes the @bot mention from the content.
|
||||||
|
func (c *TelegramChannel) stripBotMention(content string) string {
|
||||||
|
botUsername := c.bot.Username()
|
||||||
|
if botUsername == "" {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
// Case-insensitive replacement
|
||||||
|
re := regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(botUsername))
|
||||||
|
content = re.ReplaceAllString(content, "")
|
||||||
|
return strings.TrimSpace(content)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package channels
|
package telegram
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -119,7 +119,7 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error {
|
||||||
if provider == "" {
|
if provider == "" {
|
||||||
provider = "configured default"
|
provider = "configured default"
|
||||||
}
|
}
|
||||||
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml",
|
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.json",
|
||||||
c.config.Agents.Defaults.GetModelName(), provider)
|
c.config.Agents.Defaults.GetModelName(), provider)
|
||||||
|
|
||||||
case "channels":
|
case "channels":
|
||||||
20
pkg/channels/webhook.go
Normal file
20
pkg/channels/webhook.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// WebhookHandler is an optional interface for channels that receive messages
|
||||||
|
// via HTTP webhooks. Manager discovers channels implementing this interface
|
||||||
|
// and registers them on the shared HTTP server.
|
||||||
|
type WebhookHandler interface {
|
||||||
|
// WebhookPath returns the path to mount this handler on the shared server.
|
||||||
|
// Examples: "/webhook/line", "/webhook/wecom"
|
||||||
|
WebhookPath() string
|
||||||
|
http.Handler // ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthChecker is an optional interface for channels that expose
|
||||||
|
// a health check endpoint on the shared HTTP server.
|
||||||
|
type HealthChecker interface {
|
||||||
|
HealthPath() string
|
||||||
|
HealthHandler(w http.ResponseWriter, r *http.Request)
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,4 @@
|
||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
package wecom
|
||||||
// WeCom App (企业微信自建应用) channel implementation
|
|
||||||
// Supports receiving messages via webhook callback and sending messages proactively
|
|
||||||
|
|
||||||
package channels
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|
@ -11,14 +7,19 @@ import (
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
@ -29,9 +30,8 @@ const (
|
||||||
|
|
||||||
// WeComAppChannel implements the Channel interface for WeCom App (企业微信自建应用)
|
// WeComAppChannel implements the Channel interface for WeCom App (企业微信自建应用)
|
||||||
type WeComAppChannel struct {
|
type WeComAppChannel struct {
|
||||||
*BaseChannel
|
*channels.BaseChannel
|
||||||
config config.WeComAppConfig
|
config config.WeComAppConfig
|
||||||
server *http.Server
|
|
||||||
accessToken string
|
accessToken string
|
||||||
tokenExpiry time.Time
|
tokenExpiry time.Time
|
||||||
tokenMu sync.RWMutex
|
tokenMu sync.RWMutex
|
||||||
|
|
@ -123,7 +123,11 @@ func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (
|
||||||
return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required")
|
return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
base := NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom)
|
base := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom,
|
||||||
|
channels.WithMaxMessageLength(2048),
|
||||||
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||||
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
|
)
|
||||||
|
|
||||||
return &WeComAppChannel{
|
return &WeComAppChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
|
|
@ -137,7 +141,7 @@ func (c *WeComAppChannel) Name() string {
|
||||||
return "wecom_app"
|
return "wecom_app"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start initializes the WeCom App channel with HTTP webhook server
|
// Start initializes the WeCom App channel
|
||||||
func (c *WeComAppChannel) Start(ctx context.Context) error {
|
func (c *WeComAppChannel) Start(ctx context.Context) error {
|
||||||
logger.InfoC("wecom_app", "Starting WeCom App channel...")
|
logger.InfoC("wecom_app", "Starting WeCom App channel...")
|
||||||
|
|
||||||
|
|
@ -153,37 +157,8 @@ func (c *WeComAppChannel) Start(ctx context.Context) error {
|
||||||
// Start token refresh goroutine
|
// Start token refresh goroutine
|
||||||
go c.tokenRefreshLoop()
|
go c.tokenRefreshLoop()
|
||||||
|
|
||||||
// Setup HTTP server for webhook
|
c.SetRunning(true)
|
||||||
mux := http.NewServeMux()
|
logger.InfoC("wecom_app", "WeCom App channel started")
|
||||||
webhookPath := c.config.WebhookPath
|
|
||||||
if webhookPath == "" {
|
|
||||||
webhookPath = "/webhook/wecom-app"
|
|
||||||
}
|
|
||||||
mux.HandleFunc(webhookPath, c.handleWebhook)
|
|
||||||
|
|
||||||
// Health check endpoint
|
|
||||||
mux.HandleFunc("/health/wecom-app", c.handleHealth)
|
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
|
|
||||||
c.server = &http.Server{
|
|
||||||
Addr: addr,
|
|
||||||
Handler: mux,
|
|
||||||
}
|
|
||||||
|
|
||||||
c.setRunning(true)
|
|
||||||
logger.InfoCF("wecom_app", "WeCom App channel started", map[string]any{
|
|
||||||
"address": addr,
|
|
||||||
"path": webhookPath,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Start server in goroutine
|
|
||||||
go func() {
|
|
||||||
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
logger.ErrorCF("wecom_app", "HTTP server error", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -196,13 +171,7 @@ func (c *WeComAppChannel) Stop(ctx context.Context) error {
|
||||||
c.cancel()
|
c.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.server != nil {
|
c.SetRunning(false)
|
||||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
c.server.Shutdown(shutdownCtx)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.setRunning(false)
|
|
||||||
logger.InfoC("wecom_app", "WeCom App channel stopped")
|
logger.InfoC("wecom_app", "WeCom App channel stopped")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -210,7 +179,7 @@ func (c *WeComAppChannel) Stop(ctx context.Context) error {
|
||||||
// Send sends a message to WeCom user proactively using access token
|
// Send sends a message to WeCom user proactively using access token
|
||||||
func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return fmt.Errorf("wecom_app channel not running")
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken := c.getAccessToken()
|
accessToken := c.getAccessToken()
|
||||||
|
|
@ -226,6 +195,220 @@ func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content)
|
return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendMedia implements the channels.MediaSender interface.
|
||||||
|
func (c *WeComAppChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := c.getAccessToken()
|
||||||
|
if accessToken == "" {
|
||||||
|
return fmt.Errorf("no valid access token available: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := c.GetMediaStore()
|
||||||
|
if store == nil {
|
||||||
|
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, part := range msg.Parts {
|
||||||
|
localPath, err := store.Resolve(part.Ref)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("wecom_app", "Failed to resolve media ref", map[string]any{
|
||||||
|
"ref": part.Ref,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map part type to WeCom media type
|
||||||
|
var mediaType string
|
||||||
|
switch part.Type {
|
||||||
|
case "image":
|
||||||
|
mediaType = "image"
|
||||||
|
case "audio":
|
||||||
|
mediaType = "voice"
|
||||||
|
case "video":
|
||||||
|
mediaType = "video"
|
||||||
|
default:
|
||||||
|
mediaType = "file"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload media to get media_id
|
||||||
|
mediaID, err := c.uploadMedia(ctx, accessToken, mediaType, localPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("wecom_app", "Failed to upload media", map[string]any{
|
||||||
|
"type": mediaType,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
// Fallback: send caption as text
|
||||||
|
if part.Caption != "" {
|
||||||
|
_ = c.sendTextMessage(ctx, accessToken, msg.ChatID, part.Caption)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send media message using the media_id
|
||||||
|
if mediaType == "image" {
|
||||||
|
err = c.sendImageMessage(ctx, accessToken, msg.ChatID, mediaID)
|
||||||
|
} else {
|
||||||
|
// For non-image types, send as text fallback with caption
|
||||||
|
caption := part.Caption
|
||||||
|
if caption == "" {
|
||||||
|
caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename)
|
||||||
|
}
|
||||||
|
err = c.sendTextMessage(ctx, accessToken, msg.ChatID, caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadMedia uploads a local file to WeCom temporary media storage.
|
||||||
|
func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaType, localPath string) (string, error) {
|
||||||
|
apiURL := fmt.Sprintf("%s/cgi-bin/media/upload?access_token=%s&type=%s",
|
||||||
|
wecomAPIBase, url.QueryEscape(accessToken), url.QueryEscape(mediaType))
|
||||||
|
|
||||||
|
file, err := os.Open(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to open file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
|
||||||
|
filename := filepath.Base(localPath)
|
||||||
|
formFile, err := writer.CreateFormFile("media", filename)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create form file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = io.Copy(formFile, file); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to copy file content: %w", err)
|
||||||
|
}
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", channels.ClassifyNetError(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom upload error: %s", string(respBody)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
ErrCode int `json:"errcode"`
|
||||||
|
ErrMsg string `json:"errmsg"`
|
||||||
|
MediaID string `json:"media_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse upload response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ErrCode != 0 {
|
||||||
|
return "", fmt.Errorf("upload API error: %s (code: %d)", result.ErrMsg, result.ErrCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.MediaID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendImageMessage sends an image message using a media_id.
|
||||||
|
func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, userID, mediaID string) error {
|
||||||
|
apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken)
|
||||||
|
|
||||||
|
msg := WeComImageMessage{
|
||||||
|
ToUser: userID,
|
||||||
|
MsgType: "image",
|
||||||
|
AgentID: c.config.AgentID,
|
||||||
|
}
|
||||||
|
msg.Image.MediaID = mediaID
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := c.config.ReplyTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return channels.ClassifyNetError(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(respBody)))
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sendResp WeComSendMessageResponse
|
||||||
|
if err := json.Unmarshal(respBody, &sendResp); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sendResp.ErrCode != 0 {
|
||||||
|
return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookPath returns the path for registering on the shared HTTP server.
|
||||||
|
func (c *WeComAppChannel) WebhookPath() string {
|
||||||
|
if c.config.WebhookPath != "" {
|
||||||
|
return c.config.WebhookPath
|
||||||
|
}
|
||||||
|
return "/webhook/wecom-app"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP implements http.Handler for the shared HTTP server.
|
||||||
|
func (c *WeComAppChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c.handleWebhook(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthPath returns the health check endpoint path.
|
||||||
|
func (c *WeComAppChannel) HealthPath() string {
|
||||||
|
return "/health/wecom-app"
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthHandler handles health check requests.
|
||||||
|
func (c *WeComAppChannel) HealthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c.handleHealth(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
// handleWebhook handles incoming webhook requests from WeCom
|
// handleWebhook handles incoming webhook requests from WeCom
|
||||||
func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
@ -279,7 +462,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify signature
|
// Verify signature
|
||||||
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
|
if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
|
||||||
logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{
|
logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{
|
||||||
"token": c.config.Token,
|
"token": c.config.Token,
|
||||||
"msg_signature": msgSignature,
|
"msg_signature": msgSignature,
|
||||||
|
|
@ -298,7 +481,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
|
||||||
"encoding_aes_key": c.config.EncodingAESKey,
|
"encoding_aes_key": c.config.EncodingAESKey,
|
||||||
"corp_id": c.config.CorpID,
|
"corp_id": c.config.CorpID,
|
||||||
})
|
})
|
||||||
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID)
|
decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{
|
logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -357,7 +540,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify signature
|
// Verify signature
|
||||||
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
|
if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
|
||||||
logger.WarnC("wecom_app", "Message signature verification failed")
|
logger.WarnC("wecom_app", "Message signature verification failed")
|
||||||
http.Error(w, "Invalid signature", http.StatusForbidden)
|
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
|
|
@ -365,7 +548,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
||||||
|
|
||||||
// Decrypt message with CorpID verification
|
// Decrypt message with CorpID verification
|
||||||
// For WeCom App (自建应用), receiveid should be corp_id
|
// For WeCom App (自建应用), receiveid should be corp_id
|
||||||
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID)
|
decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{
|
logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -428,6 +611,9 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
|
||||||
|
|
||||||
// Build metadata
|
// Build metadata
|
||||||
// WeCom App only supports direct messages (private chat)
|
// WeCom App only supports direct messages (private chat)
|
||||||
|
peer := bus.Peer{Kind: "direct", ID: senderID}
|
||||||
|
messageID := fmt.Sprintf("%d", msg.MsgId)
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"msg_type": msg.MsgType,
|
"msg_type": msg.MsgType,
|
||||||
"msg_id": fmt.Sprintf("%d", msg.MsgId),
|
"msg_id": fmt.Sprintf("%d", msg.MsgId),
|
||||||
|
|
@ -435,8 +621,6 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
|
||||||
"platform": "wecom_app",
|
"platform": "wecom_app",
|
||||||
"media_id": msg.MediaId,
|
"media_id": msg.MediaId,
|
||||||
"create_time": fmt.Sprintf("%d", msg.CreateTime),
|
"create_time": fmt.Sprintf("%d", msg.CreateTime),
|
||||||
"peer_kind": "direct",
|
|
||||||
"peer_id": senderID,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
content := msg.Content
|
content := msg.Content
|
||||||
|
|
@ -447,8 +631,15 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
|
||||||
"preview": utils.Truncate(content, 50),
|
"preview": utils.Truncate(content, 50),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Build sender info
|
||||||
|
appSender := bus.SenderInfo{
|
||||||
|
Platform: "wecom",
|
||||||
|
PlatformID: senderID,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("wecom", senderID),
|
||||||
|
}
|
||||||
|
|
||||||
// Handle the message through the base channel
|
// Handle the message through the base channel
|
||||||
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, appSender)
|
||||||
}
|
}
|
||||||
|
|
||||||
// tokenRefreshLoop periodically refreshes the access token
|
// tokenRefreshLoop periodically refreshes the access token
|
||||||
|
|
@ -550,10 +741,15 @@ func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, user
|
||||||
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send message: %w", err)
|
return channels.ClassifyNetError(err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read response: %w", err)
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
package wecom
|
||||||
// WeCom App (企业微信自建应用) channel tests
|
|
||||||
|
|
||||||
package channels
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|
@ -197,7 +194,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
|
||||||
msgEncrypt := "test_message"
|
msgEncrypt := "test_message"
|
||||||
expectedSig := generateSignatureApp("test_token", timestamp, nonce, msgEncrypt)
|
expectedSig := generateSignatureApp("test_token", timestamp, nonce, msgEncrypt)
|
||||||
|
|
||||||
if !WeComVerifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
|
if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
|
||||||
t.Error("valid signature should pass verification")
|
t.Error("valid signature should pass verification")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -207,7 +204,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
|
||||||
nonce := "test_nonce"
|
nonce := "test_nonce"
|
||||||
msgEncrypt := "test_message"
|
msgEncrypt := "test_message"
|
||||||
|
|
||||||
if WeComVerifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
|
if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
|
||||||
t.Error("invalid signature should fail verification")
|
t.Error("invalid signature should fail verification")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -221,7 +218,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
|
||||||
}
|
}
|
||||||
chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
|
chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
|
||||||
|
|
||||||
if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
|
if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
|
||||||
t.Error("empty token should skip verification and return true")
|
t.Error("empty token should skip verification and return true")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -243,7 +240,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
|
||||||
plainText := "hello world"
|
plainText := "hello world"
|
||||||
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
|
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
|
||||||
|
|
||||||
result, err := WeComDecryptMessage(encoded, ch.config.EncodingAESKey)
|
result, err := decryptMessage(encoded, ch.config.EncodingAESKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -268,7 +265,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
|
||||||
t.Fatalf("failed to encrypt test message: %v", err)
|
t.Fatalf("failed to encrypt test message: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := WeComDecryptMessage(encrypted, ch.config.EncodingAESKey)
|
result, err := decryptMessage(encrypted, ch.config.EncodingAESKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -286,7 +283,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
|
||||||
}
|
}
|
||||||
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
||||||
|
|
||||||
_, err := WeComDecryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
|
_, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for invalid base64, got nil")
|
t.Error("expected error for invalid base64, got nil")
|
||||||
}
|
}
|
||||||
|
|
@ -301,7 +298,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
|
||||||
}
|
}
|
||||||
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
ch, _ := NewWeComAppChannel(cfg, msgBus)
|
||||||
|
|
||||||
_, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
|
_, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for invalid AES key, got nil")
|
t.Error("expected error for invalid AES key, got nil")
|
||||||
}
|
}
|
||||||
|
|
@ -319,7 +316,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
|
||||||
|
|
||||||
// Encrypt a very short message that results in ciphertext less than block size
|
// Encrypt a very short message that results in ciphertext less than block size
|
||||||
shortData := make([]byte, 8)
|
shortData := make([]byte, 8)
|
||||||
_, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey)
|
_, err := decryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for short ciphertext, got nil")
|
t.Error("expected error for short ciphertext, got nil")
|
||||||
}
|
}
|
||||||
|
|
@ -361,7 +358,7 @@ func TestWeComAppPKCS7Unpad(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result, err := pkcs7UnpadWeCom(tt.input)
|
result, err := pkcs7Unpad(tt.input)
|
||||||
if tt.expected == nil {
|
if tt.expected == nil {
|
||||||
// This case should return an error
|
// This case should return an error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -852,6 +849,28 @@ func TestWeComAppMessageStructures(t *testing.T) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("WeComImageMessage structure", func(t *testing.T) {
|
||||||
|
msg := WeComImageMessage{
|
||||||
|
ToUser: "user123",
|
||||||
|
MsgType: "image",
|
||||||
|
AgentID: 1000002,
|
||||||
|
}
|
||||||
|
msg.Image.MediaID = "media_123456"
|
||||||
|
|
||||||
|
if msg.Image.MediaID != "media_123456" {
|
||||||
|
t.Errorf("Image.MediaID = %q, want %q", msg.Image.MediaID, "media_123456")
|
||||||
|
}
|
||||||
|
if msg.ToUser != "user123" {
|
||||||
|
t.Errorf("ToUser = %q, want %q", msg.ToUser, "user123")
|
||||||
|
}
|
||||||
|
if msg.MsgType != "image" {
|
||||||
|
t.Errorf("MsgType = %q, want %q", msg.MsgType, "image")
|
||||||
|
}
|
||||||
|
if msg.AgentID != 1000002 {
|
||||||
|
t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("WeComAccessTokenResponse structure", func(t *testing.T) {
|
t.Run("WeComAccessTokenResponse structure", func(t *testing.T) {
|
||||||
jsonData := `{
|
jsonData := `{
|
||||||
"errcode": 0,
|
"errcode": 0,
|
||||||
|
|
@ -1,29 +1,21 @@
|
||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
package wecom
|
||||||
// WeCom Bot (企业微信智能机器人) channel implementation
|
|
||||||
// Uses webhook callback mode for receiving messages and webhook API for sending replies
|
|
||||||
|
|
||||||
package channels
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/sha1"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
@ -31,9 +23,8 @@ import (
|
||||||
// WeComBotChannel implements the Channel interface for WeCom Bot (企业微信智能机器人)
|
// WeComBotChannel implements the Channel interface for WeCom Bot (企业微信智能机器人)
|
||||||
// Uses webhook callback mode - simpler than WeCom App but only supports passive replies
|
// Uses webhook callback mode - simpler than WeCom App but only supports passive replies
|
||||||
type WeComBotChannel struct {
|
type WeComBotChannel struct {
|
||||||
*BaseChannel
|
*channels.BaseChannel
|
||||||
config config.WeComConfig
|
config config.WeComConfig
|
||||||
server *http.Server
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
processedMsgs map[string]bool // Message deduplication: msg_id -> processed
|
processedMsgs map[string]bool // Message deduplication: msg_id -> processed
|
||||||
|
|
@ -96,7 +87,11 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We
|
||||||
return nil, fmt.Errorf("wecom token and webhook_url are required")
|
return nil, fmt.Errorf("wecom token and webhook_url are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
base := NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom)
|
base := channels.NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom,
|
||||||
|
channels.WithMaxMessageLength(2048),
|
||||||
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||||
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
|
)
|
||||||
|
|
||||||
return &WeComBotChannel{
|
return &WeComBotChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
|
|
@ -110,43 +105,14 @@ func (c *WeComBotChannel) Name() string {
|
||||||
return "wecom"
|
return "wecom"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start initializes the WeCom Bot channel with HTTP webhook server
|
// Start initializes the WeCom Bot channel
|
||||||
func (c *WeComBotChannel) Start(ctx context.Context) error {
|
func (c *WeComBotChannel) Start(ctx context.Context) error {
|
||||||
logger.InfoC("wecom", "Starting WeCom Bot channel...")
|
logger.InfoC("wecom", "Starting WeCom Bot channel...")
|
||||||
|
|
||||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
// Setup HTTP server for webhook
|
c.SetRunning(true)
|
||||||
mux := http.NewServeMux()
|
logger.InfoC("wecom", "WeCom Bot channel started")
|
||||||
webhookPath := c.config.WebhookPath
|
|
||||||
if webhookPath == "" {
|
|
||||||
webhookPath = "/webhook/wecom"
|
|
||||||
}
|
|
||||||
mux.HandleFunc(webhookPath, c.handleWebhook)
|
|
||||||
|
|
||||||
// Health check endpoint
|
|
||||||
mux.HandleFunc("/health/wecom", c.handleHealth)
|
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
|
|
||||||
c.server = &http.Server{
|
|
||||||
Addr: addr,
|
|
||||||
Handler: mux,
|
|
||||||
}
|
|
||||||
|
|
||||||
c.setRunning(true)
|
|
||||||
logger.InfoCF("wecom", "WeCom Bot channel started", map[string]any{
|
|
||||||
"address": addr,
|
|
||||||
"path": webhookPath,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Start server in goroutine
|
|
||||||
go func() {
|
|
||||||
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
logger.ErrorCF("wecom", "HTTP server error", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -159,13 +125,7 @@ func (c *WeComBotChannel) Stop(ctx context.Context) error {
|
||||||
c.cancel()
|
c.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.server != nil {
|
c.SetRunning(false)
|
||||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
c.server.Shutdown(shutdownCtx)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.setRunning(false)
|
|
||||||
logger.InfoC("wecom", "WeCom Bot channel stopped")
|
logger.InfoC("wecom", "WeCom Bot channel stopped")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -175,7 +135,7 @@ func (c *WeComBotChannel) Stop(ctx context.Context) error {
|
||||||
// For delayed responses, we use the webhook URL
|
// For delayed responses, we use the webhook URL
|
||||||
func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return fmt.Errorf("wecom channel not running")
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("wecom", "Sending message via webhook", map[string]any{
|
logger.DebugCF("wecom", "Sending message via webhook", map[string]any{
|
||||||
|
|
@ -186,6 +146,29 @@ func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
return c.sendWebhookReply(ctx, msg.ChatID, msg.Content)
|
return c.sendWebhookReply(ctx, msg.ChatID, msg.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebhookPath returns the path for registering on the shared HTTP server.
|
||||||
|
func (c *WeComBotChannel) WebhookPath() string {
|
||||||
|
if c.config.WebhookPath != "" {
|
||||||
|
return c.config.WebhookPath
|
||||||
|
}
|
||||||
|
return "/webhook/wecom"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP implements http.Handler for the shared HTTP server.
|
||||||
|
func (c *WeComBotChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c.handleWebhook(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthPath returns the health check endpoint path.
|
||||||
|
func (c *WeComBotChannel) HealthPath() string {
|
||||||
|
return "/health/wecom"
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthHandler handles health check requests.
|
||||||
|
func (c *WeComBotChannel) HealthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c.handleHealth(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
// handleWebhook handles incoming webhook requests from WeCom
|
// handleWebhook handles incoming webhook requests from WeCom
|
||||||
func (c *WeComBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
func (c *WeComBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
@ -219,7 +202,7 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify signature
|
// Verify signature
|
||||||
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
|
if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
|
||||||
logger.WarnC("wecom", "Signature verification failed")
|
logger.WarnC("wecom", "Signature verification failed")
|
||||||
http.Error(w, "Invalid signature", http.StatusForbidden)
|
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
|
|
@ -228,7 +211,7 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons
|
||||||
// Decrypt echostr
|
// Decrypt echostr
|
||||||
// For AIBOT (智能机器人), receiveid should be empty string ""
|
// For AIBOT (智能机器人), receiveid should be empty string ""
|
||||||
// Reference: https://developer.work.weixin.qq.com/document/path/101033
|
// Reference: https://developer.work.weixin.qq.com/document/path/101033
|
||||||
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
|
decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{
|
logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -281,7 +264,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify signature
|
// Verify signature
|
||||||
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
|
if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
|
||||||
logger.WarnC("wecom", "Message signature verification failed")
|
logger.WarnC("wecom", "Message signature verification failed")
|
||||||
http.Error(w, "Invalid signature", http.StatusForbidden)
|
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
|
|
@ -290,7 +273,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
||||||
// Decrypt message
|
// Decrypt message
|
||||||
// For AIBOT (智能机器人), receiveid should be empty string ""
|
// For AIBOT (智能机器人), receiveid should be empty string ""
|
||||||
// Reference: https://developer.work.weixin.qq.com/document/path/101033
|
// Reference: https://developer.work.weixin.qq.com/document/path/101033
|
||||||
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
|
decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{
|
logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -387,12 +370,21 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build metadata
|
// Build metadata
|
||||||
|
peer := bus.Peer{Kind: peerKind, ID: peerID}
|
||||||
|
|
||||||
|
// In group chats, apply unified group trigger filtering
|
||||||
|
if isGroupChat {
|
||||||
|
respond, cleaned := c.ShouldRespondInGroup(false, content)
|
||||||
|
if !respond {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content = cleaned
|
||||||
|
}
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"msg_type": msg.MsgType,
|
"msg_type": msg.MsgType,
|
||||||
"msg_id": msg.MsgID,
|
"msg_id": msg.MsgID,
|
||||||
"platform": "wecom",
|
"platform": "wecom",
|
||||||
"peer_kind": peerKind,
|
|
||||||
"peer_id": peerID,
|
|
||||||
"response_url": msg.ResponseURL,
|
"response_url": msg.ResponseURL,
|
||||||
}
|
}
|
||||||
if isGroupChat {
|
if isGroupChat {
|
||||||
|
|
@ -408,8 +400,19 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
|
||||||
"preview": utils.Truncate(content, 50),
|
"preview": utils.Truncate(content, 50),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Build sender info
|
||||||
|
sender := bus.SenderInfo{
|
||||||
|
Platform: "wecom",
|
||||||
|
PlatformID: senderID,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("wecom", senderID),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.IsAllowedSender(sender) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Handle the message through the base channel
|
// Handle the message through the base channel
|
||||||
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
c.HandleMessage(ctx, peer, msg.MsgID, senderID, chatID, content, nil, metadata, sender)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendWebhookReply sends a reply using the webhook URL
|
// sendWebhookReply sends a reply using the webhook URL
|
||||||
|
|
@ -442,10 +445,15 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content
|
||||||
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send webhook reply: %w", err)
|
return channels.ClassifyNetError(err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("webhook API error: %s", string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read response: %w", err)
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
|
|
@ -477,129 +485,3 @@ func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(status)
|
json.NewEncoder(w).Encode(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WeCom common utilities for both WeCom Bot and WeCom App
|
|
||||||
// The following functions were moved from wecom_common.go
|
|
||||||
|
|
||||||
// WeComVerifySignature verifies the message signature for WeCom
|
|
||||||
// This is a common function used by both WeCom Bot and WeCom App
|
|
||||||
func WeComVerifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
|
|
||||||
if token == "" {
|
|
||||||
return true // Skip verification if token is not set
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort parameters
|
|
||||||
params := []string{token, timestamp, nonce, msgEncrypt}
|
|
||||||
sort.Strings(params)
|
|
||||||
|
|
||||||
// Concatenate
|
|
||||||
str := strings.Join(params, "")
|
|
||||||
|
|
||||||
// SHA1 hash
|
|
||||||
hash := sha1.Sum([]byte(str))
|
|
||||||
expectedSignature := fmt.Sprintf("%x", hash)
|
|
||||||
|
|
||||||
return expectedSignature == msgSignature
|
|
||||||
}
|
|
||||||
|
|
||||||
// WeComDecryptMessage decrypts the encrypted message using AES
|
|
||||||
// This is a common function used by both WeCom Bot and WeCom App
|
|
||||||
// For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id
|
|
||||||
func WeComDecryptMessage(encryptedMsg, encodingAESKey string) (string, error) {
|
|
||||||
return WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// WeComDecryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid
|
|
||||||
// receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification.
|
|
||||||
func WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) {
|
|
||||||
if encodingAESKey == "" {
|
|
||||||
// No encryption, return as is (base64 decode)
|
|
||||||
decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(decoded), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode AES key (base64)
|
|
||||||
aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to decode AES key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode encrypted message
|
|
||||||
cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to decode message: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AES decrypt
|
|
||||||
block, err := aes.NewCipher(aesKey)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create cipher: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cipherText) < aes.BlockSize {
|
|
||||||
return "", fmt.Errorf("ciphertext too short")
|
|
||||||
}
|
|
||||||
|
|
||||||
// IV is the first 16 bytes of AESKey
|
|
||||||
iv := aesKey[:aes.BlockSize]
|
|
||||||
mode := cipher.NewCBCDecrypter(block, iv)
|
|
||||||
plainText := make([]byte, len(cipherText))
|
|
||||||
mode.CryptBlocks(plainText, cipherText)
|
|
||||||
|
|
||||||
// Remove PKCS7 padding
|
|
||||||
plainText, err = pkcs7UnpadWeCom(plainText)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to unpad: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse message structure
|
|
||||||
// Format: random(16) + msg_len(4) + msg + receiveid
|
|
||||||
if len(plainText) < 20 {
|
|
||||||
return "", fmt.Errorf("decrypted message too short")
|
|
||||||
}
|
|
||||||
|
|
||||||
msgLen := binary.BigEndian.Uint32(plainText[16:20])
|
|
||||||
if int(msgLen) > len(plainText)-20 {
|
|
||||||
return "", fmt.Errorf("invalid message length")
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := plainText[20 : 20+msgLen]
|
|
||||||
|
|
||||||
// Verify receiveid if provided
|
|
||||||
if receiveid != "" && len(plainText) > 20+int(msgLen) {
|
|
||||||
actualReceiveID := string(plainText[20+msgLen:])
|
|
||||||
if actualReceiveID != receiveid {
|
|
||||||
return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(msg), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pkcs7UnpadWeCom removes PKCS7 padding with validation
|
|
||||||
// WeCom uses block size of 32 (not standard AES block size of 16)
|
|
||||||
const wecomBlockSize = 32
|
|
||||||
|
|
||||||
func pkcs7UnpadWeCom(data []byte) ([]byte, error) {
|
|
||||||
if len(data) == 0 {
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
padding := int(data[len(data)-1])
|
|
||||||
// WeCom uses 32-byte block size for PKCS7 padding
|
|
||||||
if padding == 0 || padding > wecomBlockSize {
|
|
||||||
return nil, fmt.Errorf("invalid padding size: %d", padding)
|
|
||||||
}
|
|
||||||
if padding > len(data) {
|
|
||||||
return nil, fmt.Errorf("padding size larger than data")
|
|
||||||
}
|
|
||||||
// Verify all padding bytes
|
|
||||||
for i := range padding {
|
|
||||||
if data[len(data)-1-i] != byte(padding) {
|
|
||||||
return nil, fmt.Errorf("invalid padding byte at position %d", i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return data[:len(data)-padding], nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
package wecom
|
||||||
// WeCom Bot (企业微信智能机器人) channel tests
|
|
||||||
|
|
||||||
package channels
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|
@ -177,7 +174,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
|
||||||
msgEncrypt := "test_message"
|
msgEncrypt := "test_message"
|
||||||
expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt)
|
expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt)
|
||||||
|
|
||||||
if !WeComVerifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
|
if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
|
||||||
t.Error("valid signature should pass verification")
|
t.Error("valid signature should pass verification")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -187,7 +184,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
|
||||||
nonce := "test_nonce"
|
nonce := "test_nonce"
|
||||||
msgEncrypt := "test_message"
|
msgEncrypt := "test_message"
|
||||||
|
|
||||||
if WeComVerifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
|
if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
|
||||||
t.Error("invalid signature should fail verification")
|
t.Error("invalid signature should fail verification")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -202,7 +199,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
|
||||||
config: cfgEmpty,
|
config: cfgEmpty,
|
||||||
}
|
}
|
||||||
|
|
||||||
if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
|
if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
|
||||||
t.Error("empty token should skip verification and return true")
|
t.Error("empty token should skip verification and return true")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -223,7 +220,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
|
||||||
plainText := "hello world"
|
plainText := "hello world"
|
||||||
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
|
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
|
||||||
|
|
||||||
result, err := WeComDecryptMessage(encoded, ch.config.EncodingAESKey)
|
result, err := decryptMessage(encoded, ch.config.EncodingAESKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -247,7 +244,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
|
||||||
t.Fatalf("failed to encrypt test message: %v", err)
|
t.Fatalf("failed to encrypt test message: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := WeComDecryptMessage(encrypted, ch.config.EncodingAESKey)
|
result, err := decryptMessage(encrypted, ch.config.EncodingAESKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -264,7 +261,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
|
||||||
}
|
}
|
||||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
_, err := WeComDecryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
|
_, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for invalid base64, got nil")
|
t.Error("expected error for invalid base64, got nil")
|
||||||
}
|
}
|
||||||
|
|
@ -278,7 +275,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
|
||||||
}
|
}
|
||||||
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
_, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
|
_, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for invalid AES key, got nil")
|
t.Error("expected error for invalid AES key, got nil")
|
||||||
}
|
}
|
||||||
|
|
@ -320,20 +317,20 @@ func TestWeComBotPKCS7Unpad(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result, err := pkcs7UnpadWeCom(tt.input)
|
result, err := pkcs7Unpad(tt.input)
|
||||||
if tt.expected == nil {
|
if tt.expected == nil {
|
||||||
// This case should return an error
|
// This case should return an error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("pkcs7UnpadWeCom() expected error for invalid padding, got result: %v", result)
|
t.Errorf("pkcs7Unpad() expected error for invalid padding, got result: %v", result)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("pkcs7UnpadWeCom() unexpected error: %v", err)
|
t.Errorf("pkcs7Unpad() unexpected error: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !bytes.Equal(result, tt.expected) {
|
if !bytes.Equal(result, tt.expected) {
|
||||||
t.Errorf("pkcs7UnpadWeCom() = %v, want %v", result, tt.expected)
|
t.Errorf("pkcs7Unpad() = %v, want %v", result, tt.expected)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
134
pkg/channels/wecom/common.go
Normal file
134
pkg/channels/wecom/common.go
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
package wecom
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// blockSize is the PKCS7 block size used by WeCom (32)
|
||||||
|
const blockSize = 32
|
||||||
|
|
||||||
|
// verifySignature verifies the message signature for WeCom
|
||||||
|
// This is a common function used by both WeCom Bot and WeCom App
|
||||||
|
func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
|
||||||
|
if token == "" {
|
||||||
|
return true // Skip verification if token is not set
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort parameters
|
||||||
|
params := []string{token, timestamp, nonce, msgEncrypt}
|
||||||
|
sort.Strings(params)
|
||||||
|
|
||||||
|
// Concatenate
|
||||||
|
str := strings.Join(params, "")
|
||||||
|
|
||||||
|
// SHA1 hash
|
||||||
|
hash := sha1.Sum([]byte(str))
|
||||||
|
expectedSignature := fmt.Sprintf("%x", hash)
|
||||||
|
|
||||||
|
return expectedSignature == msgSignature
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptMessage decrypts the encrypted message using AES
|
||||||
|
// For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id
|
||||||
|
func decryptMessage(encryptedMsg, encodingAESKey string) (string, error) {
|
||||||
|
return decryptMessageWithVerify(encryptedMsg, encodingAESKey, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid
|
||||||
|
// receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification.
|
||||||
|
func decryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) {
|
||||||
|
if encodingAESKey == "" {
|
||||||
|
// No encryption, return as is (base64 decode)
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(decoded), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode AES key (base64)
|
||||||
|
aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode AES key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode encrypted message
|
||||||
|
cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AES decrypt
|
||||||
|
block, err := aes.NewCipher(aesKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cipherText) < aes.BlockSize {
|
||||||
|
return "", fmt.Errorf("ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IV is the first 16 bytes of AESKey
|
||||||
|
iv := aesKey[:aes.BlockSize]
|
||||||
|
mode := cipher.NewCBCDecrypter(block, iv)
|
||||||
|
plainText := make([]byte, len(cipherText))
|
||||||
|
mode.CryptBlocks(plainText, cipherText)
|
||||||
|
|
||||||
|
// Remove PKCS7 padding
|
||||||
|
plainText, err = pkcs7Unpad(plainText)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to unpad: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse message structure
|
||||||
|
// Format: random(16) + msg_len(4) + msg + receiveid
|
||||||
|
if len(plainText) < 20 {
|
||||||
|
return "", fmt.Errorf("decrypted message too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
msgLen := binary.BigEndian.Uint32(plainText[16:20])
|
||||||
|
if int(msgLen) > len(plainText)-20 {
|
||||||
|
return "", fmt.Errorf("invalid message length")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := plainText[20 : 20+msgLen]
|
||||||
|
|
||||||
|
// Verify receiveid if provided
|
||||||
|
if receiveid != "" && len(plainText) > 20+int(msgLen) {
|
||||||
|
actualReceiveID := string(plainText[20+msgLen:])
|
||||||
|
if actualReceiveID != receiveid {
|
||||||
|
return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(msg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pkcs7Unpad removes PKCS7 padding with validation
|
||||||
|
func pkcs7Unpad(data []byte) ([]byte, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
padding := int(data[len(data)-1])
|
||||||
|
// WeCom uses 32-byte block size for PKCS7 padding
|
||||||
|
if padding == 0 || padding > blockSize {
|
||||||
|
return nil, fmt.Errorf("invalid padding size: %d", padding)
|
||||||
|
}
|
||||||
|
if padding > len(data) {
|
||||||
|
return nil, fmt.Errorf("padding size larger than data")
|
||||||
|
}
|
||||||
|
// Verify all padding bytes
|
||||||
|
for i := range padding {
|
||||||
|
if data[len(data)-1-i] != byte(padding) {
|
||||||
|
return nil, fmt.Errorf("invalid padding byte at position %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data[:len(data)-padding], nil
|
||||||
|
}
|
||||||
16
pkg/channels/wecom/init.go
Normal file
16
pkg/channels/wecom/init.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package wecom
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("wecom", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewWeComBotChannel(cfg.Channels.WeCom, b)
|
||||||
|
})
|
||||||
|
channels.RegisterFactory("wecom_app", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewWeComAppChannel(cfg.Channels.WeComApp, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
13
pkg/channels/whatsapp/init.go
Normal file
13
pkg/channels/whatsapp/init.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package whatsapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("whatsapp", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewWhatsAppChannel(cfg.Channels.WhatsApp, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,31 +1,42 @@
|
||||||
package channels
|
package whatsapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WhatsAppChannel struct {
|
type WhatsAppChannel struct {
|
||||||
*BaseChannel
|
*channels.BaseChannel
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
config config.WhatsAppConfig
|
config config.WhatsAppConfig
|
||||||
url string
|
url string
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
connected bool
|
connected bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) {
|
func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) {
|
||||||
base := NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom)
|
base := channels.NewBaseChannel(
|
||||||
|
"whatsapp",
|
||||||
|
cfg,
|
||||||
|
bus,
|
||||||
|
cfg.AllowFrom,
|
||||||
|
channels.WithMaxMessageLength(65536),
|
||||||
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
|
)
|
||||||
|
|
||||||
return &WhatsAppChannel{
|
return &WhatsAppChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
|
|
@ -36,7 +47,11 @@ func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsA
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WhatsAppChannel) Start(ctx context.Context) error {
|
func (c *WhatsAppChannel) Start(ctx context.Context) error {
|
||||||
log.Printf("Starting WhatsApp channel connecting to %s...", c.url)
|
logger.InfoCF("whatsapp", "Starting WhatsApp channel", map[string]any{
|
||||||
|
"bridge_url": c.url,
|
||||||
|
})
|
||||||
|
|
||||||
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
dialer := websocket.DefaultDialer
|
dialer := websocket.DefaultDialer
|
||||||
dialer.HandshakeTimeout = 10 * time.Second
|
dialer.HandshakeTimeout = 10 * time.Second
|
||||||
|
|
@ -46,6 +61,7 @@ func (c *WhatsAppChannel) Start(ctx context.Context) error {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
c.cancel()
|
||||||
return fmt.Errorf("failed to connect to WhatsApp bridge: %w", err)
|
return fmt.Errorf("failed to connect to WhatsApp bridge: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,39 +70,57 @@ func (c *WhatsAppChannel) Start(ctx context.Context) error {
|
||||||
c.connected = true
|
c.connected = true
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
c.setRunning(true)
|
c.SetRunning(true)
|
||||||
log.Println("WhatsApp channel connected")
|
logger.InfoC("whatsapp", "WhatsApp channel connected")
|
||||||
|
|
||||||
go c.listen(ctx)
|
go c.listen()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WhatsAppChannel) Stop(ctx context.Context) error {
|
func (c *WhatsAppChannel) Stop(ctx context.Context) error {
|
||||||
log.Println("Stopping WhatsApp channel...")
|
logger.InfoC("whatsapp", "Stopping WhatsApp channel...")
|
||||||
|
|
||||||
|
// Cancel context first to signal listen goroutine to exit
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
if c.conn != nil {
|
if c.conn != nil {
|
||||||
if err := c.conn.Close(); err != nil {
|
if err := c.conn.Close(); err != nil {
|
||||||
log.Printf("Error closing WhatsApp connection: %v", err)
|
logger.ErrorCF("whatsapp", "Error closing WhatsApp connection", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
c.conn = nil
|
c.conn = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
c.connected = false
|
c.connected = false
|
||||||
c.setRunning(false)
|
c.SetRunning(false)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ctx before acquiring lock
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
if c.conn == nil {
|
if c.conn == nil {
|
||||||
return fmt.Errorf("whatsapp connection not established")
|
return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
|
|
@ -100,17 +134,20 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
return fmt.Errorf("failed to marshal message: %w", err)
|
return fmt.Errorf("failed to marshal message: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||||
return fmt.Errorf("failed to send message: %w", err)
|
_ = c.conn.SetWriteDeadline(time.Time{})
|
||||||
|
return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Time{})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WhatsAppChannel) listen(ctx context.Context) {
|
func (c *WhatsAppChannel) listen() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-c.ctx.Done():
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
|
@ -124,14 +161,18 @@ func (c *WhatsAppChannel) listen(ctx context.Context) {
|
||||||
|
|
||||||
_, message, err := conn.ReadMessage()
|
_, message, err := conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("WhatsApp read error: %v", err)
|
logger.ErrorCF("whatsapp", "WhatsApp read error", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var msg map[string]any
|
var msg map[string]any
|
||||||
if err := json.Unmarshal(message, &msg); err != nil {
|
if err := json.Unmarshal(message, &msg); err != nil {
|
||||||
log.Printf("Failed to unmarshal WhatsApp message: %v", err)
|
logger.ErrorCF("whatsapp", "Failed to unmarshal WhatsApp message", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,22 +215,38 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) {
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata := make(map[string]string)
|
metadata := make(map[string]string)
|
||||||
if messageID, ok := msg["id"].(string); ok {
|
var messageID string
|
||||||
metadata["message_id"] = messageID
|
if mid, ok := msg["id"].(string); ok {
|
||||||
|
messageID = mid
|
||||||
}
|
}
|
||||||
if userName, ok := msg["from_name"].(string); ok {
|
if userName, ok := msg["from_name"].(string); ok {
|
||||||
metadata["user_name"] = userName
|
metadata["user_name"] = userName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var peer bus.Peer
|
||||||
if chatID == senderID {
|
if chatID == senderID {
|
||||||
metadata["peer_kind"] = "direct"
|
peer = bus.Peer{Kind: "direct", ID: senderID}
|
||||||
metadata["peer_id"] = senderID
|
|
||||||
} else {
|
} else {
|
||||||
metadata["peer_kind"] = "group"
|
peer = bus.Peer{Kind: "group", ID: chatID}
|
||||||
metadata["peer_id"] = chatID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("WhatsApp message from %s: %s...", senderID, utils.Truncate(content, 50))
|
logger.InfoCF("whatsapp", "WhatsApp message received", map[string]any{
|
||||||
|
"sender": senderID,
|
||||||
|
"preview": utils.Truncate(content, 50),
|
||||||
|
})
|
||||||
|
|
||||||
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
|
sender := bus.SenderInfo{
|
||||||
|
Platform: "whatsapp",
|
||||||
|
PlatformID: senderID,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("whatsapp", senderID),
|
||||||
|
}
|
||||||
|
if display, ok := metadata["user_name"]; ok {
|
||||||
|
sender.DisplayName = display
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.IsAllowedSender(sender) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
|
||||||
}
|
}
|
||||||
20
pkg/channels/whatsapp_native/init.go
Normal file
20
pkg/channels/whatsapp_native/init.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package whatsapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("whatsapp_native", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
waCfg := cfg.Channels.WhatsApp
|
||||||
|
storePath := waCfg.SessionStorePath
|
||||||
|
if storePath == "" {
|
||||||
|
storePath = filepath.Join(cfg.WorkspacePath(), "whatsapp")
|
||||||
|
}
|
||||||
|
return NewWhatsAppNativeChannel(waCfg, b, storePath)
|
||||||
|
})
|
||||||
|
}
|
||||||
341
pkg/channels/whatsapp_native/whatsapp_native.go
Normal file
341
pkg/channels/whatsapp_native/whatsapp_native.go
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
//go:build whatsapp_native
|
||||||
|
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package whatsapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mdp/qrterminal/v3"
|
||||||
|
"go.mau.fi/whatsmeow"
|
||||||
|
"go.mau.fi/whatsmeow/proto/waE2E"
|
||||||
|
"go.mau.fi/whatsmeow/store/sqlstore"
|
||||||
|
"go.mau.fi/whatsmeow/types"
|
||||||
|
"go.mau.fi/whatsmeow/types/events"
|
||||||
|
waLog "go.mau.fi/whatsmeow/util/log"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sqliteDriver = "sqlite"
|
||||||
|
whatsappDBName = "store.db"
|
||||||
|
|
||||||
|
reconnectInitial = 5 * time.Second
|
||||||
|
reconnectMax = 5 * time.Minute
|
||||||
|
reconnectMultiplier = 2.0
|
||||||
|
)
|
||||||
|
|
||||||
|
// WhatsAppNativeChannel implements the WhatsApp channel using whatsmeow (in-process, no external bridge).
|
||||||
|
type WhatsAppNativeChannel struct {
|
||||||
|
*channels.BaseChannel
|
||||||
|
config config.WhatsAppConfig
|
||||||
|
storePath string
|
||||||
|
client *whatsmeow.Client
|
||||||
|
container *sqlstore.Container
|
||||||
|
mu sync.Mutex
|
||||||
|
runCtx context.Context
|
||||||
|
runCancel context.CancelFunc
|
||||||
|
reconnectMu sync.Mutex
|
||||||
|
reconnecting bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection.
|
||||||
|
// storePath is the directory for the SQLite session store (e.g. workspace/whatsapp).
|
||||||
|
func NewWhatsAppNativeChannel(
|
||||||
|
cfg config.WhatsAppConfig,
|
||||||
|
bus *bus.MessageBus,
|
||||||
|
storePath string,
|
||||||
|
) (channels.Channel, error) {
|
||||||
|
base := channels.NewBaseChannel("whatsapp_native", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(65536))
|
||||||
|
if storePath == "" {
|
||||||
|
storePath = "whatsapp"
|
||||||
|
}
|
||||||
|
c := &WhatsAppNativeChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
config: cfg,
|
||||||
|
storePath: storePath,
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
|
||||||
|
logger.InfoCF("whatsapp", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath})
|
||||||
|
|
||||||
|
if err := os.MkdirAll(c.storePath, 0o700); err != nil {
|
||||||
|
return fmt.Errorf("create session store dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := filepath.Join(c.storePath, whatsappDBName)
|
||||||
|
connStr := "file:" + dbPath + "?_foreign_keys=on"
|
||||||
|
|
||||||
|
db, err := sql.Open(sqliteDriver, connStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open whatsapp store: %w", err)
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
db.SetMaxIdleConns(1)
|
||||||
|
if _, err = db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return fmt.Errorf("enable foreign keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
waLogger := waLog.Stdout("WhatsApp", "WARN", true)
|
||||||
|
container := sqlstore.NewWithDB(db, sqliteDriver, waLogger)
|
||||||
|
if err = container.Upgrade(ctx); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return fmt.Errorf("open whatsapp store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceStore, err := container.GetFirstDevice(ctx)
|
||||||
|
if err != nil {
|
||||||
|
_ = container.Close()
|
||||||
|
return fmt.Errorf("get device store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := whatsmeow.NewClient(deviceStore, waLogger)
|
||||||
|
client.AddEventHandler(c.eventHandler)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.container = container
|
||||||
|
c.client = client
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if client.Store.ID == nil {
|
||||||
|
qrChan, err := client.GetQRChannel(ctx)
|
||||||
|
if err != nil {
|
||||||
|
_ = container.Close()
|
||||||
|
return fmt.Errorf("get QR channel: %w", err)
|
||||||
|
}
|
||||||
|
if err := client.Connect(); err != nil {
|
||||||
|
_ = container.Close()
|
||||||
|
return fmt.Errorf("connect: %w", err)
|
||||||
|
}
|
||||||
|
for evt := range qrChan {
|
||||||
|
if evt.Event == "code" {
|
||||||
|
logger.InfoCF("whatsapp", "Scan this QR code with WhatsApp (Linked Devices):", nil)
|
||||||
|
qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{
|
||||||
|
Level: qrterminal.L,
|
||||||
|
Writer: os.Stdout,
|
||||||
|
HalfBlocks: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.InfoCF("whatsapp", "WhatsApp login event", map[string]any{"event": evt.Event})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := client.Connect(); err != nil {
|
||||||
|
_ = container.Close()
|
||||||
|
return fmt.Errorf("connect: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.runCtx, c.runCancel = context.WithCancel(ctx)
|
||||||
|
c.SetRunning(true)
|
||||||
|
logger.InfoC("whatsapp", "WhatsApp native channel connected")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error {
|
||||||
|
logger.InfoC("whatsapp", "Stopping WhatsApp native channel")
|
||||||
|
if c.runCancel != nil {
|
||||||
|
c.runCancel()
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
client := c.client
|
||||||
|
container := c.container
|
||||||
|
c.client = nil
|
||||||
|
c.container = nil
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if client != nil {
|
||||||
|
client.Disconnect()
|
||||||
|
}
|
||||||
|
if container != nil {
|
||||||
|
_ = container.Close()
|
||||||
|
}
|
||||||
|
c.SetRunning(false)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WhatsAppNativeChannel) eventHandler(evt any) {
|
||||||
|
switch evt.(type) {
|
||||||
|
case *events.Message:
|
||||||
|
c.handleIncoming(evt.(*events.Message))
|
||||||
|
case *events.Disconnected:
|
||||||
|
logger.InfoCF("whatsapp", "WhatsApp disconnected, will attempt reconnection", nil)
|
||||||
|
c.reconnectMu.Lock()
|
||||||
|
if c.reconnecting {
|
||||||
|
c.reconnectMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.reconnecting = true
|
||||||
|
c.reconnectMu.Unlock()
|
||||||
|
go c.reconnectWithBackoff()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WhatsAppNativeChannel) reconnectWithBackoff() {
|
||||||
|
defer func() {
|
||||||
|
c.reconnectMu.Lock()
|
||||||
|
c.reconnecting = false
|
||||||
|
c.reconnectMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
backoff := reconnectInitial
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.runCtx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
client := c.client
|
||||||
|
c.mu.Unlock()
|
||||||
|
if client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("whatsapp", "WhatsApp reconnecting", map[string]any{"backoff": backoff.String()})
|
||||||
|
err := client.Connect()
|
||||||
|
if err == nil {
|
||||||
|
logger.InfoC("whatsapp", "WhatsApp reconnected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.WarnCF("whatsapp", "WhatsApp reconnect failed", map[string]any{"error": err.Error()})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-c.runCtx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(backoff):
|
||||||
|
if backoff < reconnectMax {
|
||||||
|
next := time.Duration(float64(backoff) * reconnectMultiplier)
|
||||||
|
if next > reconnectMax {
|
||||||
|
next = reconnectMax
|
||||||
|
}
|
||||||
|
backoff = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
|
||||||
|
if evt.Message == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
senderID := evt.Info.Sender.String()
|
||||||
|
chatID := evt.Info.Chat.String()
|
||||||
|
content := evt.Message.GetConversation()
|
||||||
|
if content == "" && evt.Message.ExtendedTextMessage != nil {
|
||||||
|
content = evt.Message.ExtendedTextMessage.GetText()
|
||||||
|
}
|
||||||
|
content = utils.SanitizeMessageContent(content)
|
||||||
|
|
||||||
|
if content == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var mediaPaths []string
|
||||||
|
|
||||||
|
metadata := make(map[string]string)
|
||||||
|
metadata["message_id"] = evt.Info.ID
|
||||||
|
if evt.Info.PushName != "" {
|
||||||
|
metadata["user_name"] = evt.Info.PushName
|
||||||
|
}
|
||||||
|
if evt.Info.Chat.Server == types.GroupServer {
|
||||||
|
metadata["peer_kind"] = "group"
|
||||||
|
metadata["peer_id"] = chatID
|
||||||
|
} else {
|
||||||
|
metadata["peer_kind"] = "direct"
|
||||||
|
metadata["peer_id"] = senderID
|
||||||
|
}
|
||||||
|
|
||||||
|
peerKind := "direct"
|
||||||
|
if evt.Info.Chat.Server == types.GroupServer {
|
||||||
|
peerKind = "group"
|
||||||
|
}
|
||||||
|
peer := bus.Peer{Kind: peerKind, ID: chatID}
|
||||||
|
messageID := evt.Info.ID
|
||||||
|
sender := bus.SenderInfo{
|
||||||
|
Platform: "whatsapp",
|
||||||
|
PlatformID: senderID,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("whatsapp", senderID),
|
||||||
|
DisplayName: evt.Info.PushName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.IsAllowedSender(sender) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF(
|
||||||
|
"whatsapp",
|
||||||
|
"WhatsApp message received",
|
||||||
|
map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)},
|
||||||
|
)
|
||||||
|
c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
client := c.client
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if client == nil || !client.IsConnected() {
|
||||||
|
return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
|
||||||
|
to, err := parseJID(msg.ChatID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
waMsg := &waE2E.Message{
|
||||||
|
Conversation: proto.String(msg.Content),
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = client.SendMessage(ctx, to, waMsg); err != nil {
|
||||||
|
return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseJID converts a chat ID (phone number or JID string) to types.JID.
|
||||||
|
func parseJID(s string) (types.JID, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return types.JID{}, fmt.Errorf("empty chat id")
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "@") {
|
||||||
|
return types.ParseJID(s)
|
||||||
|
}
|
||||||
|
return types.NewJID(s, types.DefaultUserServer), nil
|
||||||
|
}
|
||||||
21
pkg/channels/whatsapp_native/whatsapp_native_stub.go
Normal file
21
pkg/channels/whatsapp_native/whatsapp_native_stub.go
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
//go:build !whatsapp_native
|
||||||
|
|
||||||
|
package whatsapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewWhatsAppNativeChannel returns an error when the binary was not built with -tags whatsapp_native.
|
||||||
|
// Build with: go build -tags whatsapp_native ./cmd/...
|
||||||
|
func NewWhatsAppNativeChannel(
|
||||||
|
cfg config.WhatsAppConfig,
|
||||||
|
bus *bus.MessageBus,
|
||||||
|
storePath string,
|
||||||
|
) (channels.Channel, error) {
|
||||||
|
return nil, fmt.Errorf("whatsapp native not compiled in; build with -tags whatsapp_native")
|
||||||
|
}
|
||||||
|
|
@ -172,7 +172,7 @@ type AgentDefaults struct {
|
||||||
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||||
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
||||||
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
||||||
Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
||||||
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
||||||
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
||||||
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
||||||
|
|
@ -203,12 +203,33 @@ type ChannelsConfig struct {
|
||||||
OneBot OneBotConfig `json:"onebot"`
|
OneBot OneBotConfig `json:"onebot"`
|
||||||
WeCom WeComConfig `json:"wecom"`
|
WeCom WeComConfig `json:"wecom"`
|
||||||
WeComApp WeComAppConfig `json:"wecom_app"`
|
WeComApp WeComAppConfig `json:"wecom_app"`
|
||||||
|
Pico PicoConfig `json:"pico"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupTriggerConfig controls when the bot responds in group chats.
|
||||||
|
type GroupTriggerConfig struct {
|
||||||
|
MentionOnly bool `json:"mention_only,omitempty"`
|
||||||
|
Prefixes []string `json:"prefixes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TypingConfig controls typing indicator behavior (Phase 10).
|
||||||
|
type TypingConfig struct {
|
||||||
|
Enabled bool `json:"enabled,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaceholderConfig controls placeholder message behavior (Phase 10).
|
||||||
|
type PlaceholderConfig struct {
|
||||||
|
Enabled bool `json:"enabled,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WhatsAppConfig struct {
|
type WhatsAppConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
|
||||||
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
|
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
|
||||||
|
UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"`
|
||||||
|
SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TelegramConfig struct {
|
type TelegramConfig struct {
|
||||||
|
|
@ -216,6 +237,10 @@ type TelegramConfig struct {
|
||||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||||
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
Typing TypingConfig `json:"typing,omitempty"`
|
||||||
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FeishuConfig struct {
|
type FeishuConfig struct {
|
||||||
|
|
@ -225,6 +250,8 @@ type FeishuConfig struct {
|
||||||
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
|
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
|
||||||
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
|
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiscordConfig struct {
|
type DiscordConfig struct {
|
||||||
|
|
@ -232,6 +259,10 @@ type DiscordConfig struct {
|
||||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
|
||||||
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
|
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
Typing TypingConfig `json:"typing,omitempty"`
|
||||||
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MaixCamConfig struct {
|
type MaixCamConfig struct {
|
||||||
|
|
@ -239,6 +270,7 @@ type MaixCamConfig struct {
|
||||||
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
|
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
|
||||||
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
|
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MAIXCAM_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type QQConfig struct {
|
type QQConfig struct {
|
||||||
|
|
@ -246,6 +278,8 @@ type QQConfig struct {
|
||||||
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
|
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
|
||||||
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
|
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DingTalkConfig struct {
|
type DingTalkConfig struct {
|
||||||
|
|
@ -253,6 +287,8 @@ type DingTalkConfig struct {
|
||||||
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
|
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
|
||||||
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
|
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SlackConfig struct {
|
type SlackConfig struct {
|
||||||
|
|
@ -260,6 +296,10 @@ type SlackConfig struct {
|
||||||
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
|
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
|
||||||
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
|
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
Typing TypingConfig `json:"typing,omitempty"`
|
||||||
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LINEConfig struct {
|
type LINEConfig struct {
|
||||||
|
|
@ -270,6 +310,10 @@ type LINEConfig struct {
|
||||||
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
|
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
|
||||||
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
|
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
Typing TypingConfig `json:"typing,omitempty"`
|
||||||
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OneBotConfig struct {
|
type OneBotConfig struct {
|
||||||
|
|
@ -279,6 +323,10 @@ type OneBotConfig struct {
|
||||||
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
|
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
|
||||||
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
|
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
Typing TypingConfig `json:"typing,omitempty"`
|
||||||
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WeComConfig struct {
|
type WeComConfig struct {
|
||||||
|
|
@ -291,6 +339,8 @@ type WeComConfig struct {
|
||||||
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
|
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
|
||||||
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
|
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WeComAppConfig struct {
|
type WeComAppConfig struct {
|
||||||
|
|
@ -305,6 +355,21 @@ type WeComAppConfig struct {
|
||||||
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
|
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
|
||||||
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
|
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PicoConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
|
||||||
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
|
||||||
|
AllowTokenQuery bool `json:"allow_token_query,omitempty"`
|
||||||
|
AllowOrigins []string `json:"allow_origins,omitempty"`
|
||||||
|
PingInterval int `json:"ping_interval,omitempty"`
|
||||||
|
ReadTimeout int `json:"read_timeout,omitempty"`
|
||||||
|
WriteTimeout int `json:"write_timeout,omitempty"`
|
||||||
|
MaxConnections int `json:"max_connections,omitempty"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"`
|
||||||
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HeartbeatConfig struct {
|
type HeartbeatConfig struct {
|
||||||
|
|
@ -470,11 +535,18 @@ type ExecConfig struct {
|
||||||
CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
|
CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MediaCleanupConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_MEDIA_CLEANUP_ENABLED"`
|
||||||
|
MaxAge int `json:"max_age_minutes" env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE"`
|
||||||
|
Interval int `json:"interval_minutes" env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL"`
|
||||||
|
}
|
||||||
|
|
||||||
type ToolsConfig struct {
|
type ToolsConfig struct {
|
||||||
Web WebToolsConfig `json:"web"`
|
Web WebToolsConfig `json:"web"`
|
||||||
Cron CronToolsConfig `json:"cron"`
|
Cron CronToolsConfig `json:"cron"`
|
||||||
Exec ExecConfig `json:"exec"`
|
Exec ExecConfig `json:"exec"`
|
||||||
Skills SkillsToolsConfig `json:"skills"`
|
Skills SkillsToolsConfig `json:"skills"`
|
||||||
|
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SkillsToolsConfig struct {
|
type SkillsToolsConfig struct {
|
||||||
|
|
@ -537,6 +609,9 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migrate legacy channel config fields to new unified structures
|
||||||
|
cfg.migrateChannelConfigs()
|
||||||
|
|
||||||
// Auto-migrate: if only legacy providers config exists, convert to model_list
|
// Auto-migrate: if only legacy providers config exists, convert to model_list
|
||||||
if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
|
if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
|
||||||
cfg.ModelList = ConvertProvidersToModelList(cfg)
|
cfg.ModelList = ConvertProvidersToModelList(cfg)
|
||||||
|
|
@ -550,6 +625,18 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Config) migrateChannelConfigs() {
|
||||||
|
// Discord: mention_only -> group_trigger.mention_only
|
||||||
|
if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {
|
||||||
|
c.Channels.Discord.GroupTrigger.MentionOnly = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// OneBot: group_trigger_prefix -> group_trigger.prefixes
|
||||||
|
if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
|
||||||
|
c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func SaveConfig(path string, cfg *Config) error {
|
func SaveConfig(path string, cfg *Config) error {
|
||||||
data, err := json.MarshalIndent(cfg, "", " ")
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -210,8 +211,8 @@ func TestDefaultConfig_WorkspacePath(t *testing.T) {
|
||||||
func TestDefaultConfig_Model(t *testing.T) {
|
func TestDefaultConfig_Model(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
if cfg.Agents.Defaults.Model == "" {
|
if cfg.Agents.Defaults.Model != "" {
|
||||||
t.Error("Model should not be empty")
|
t.Error("Model should be empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -324,6 +325,25 @@ func TestSaveConfig_FilePermissions(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
path := filepath.Join(tmpDir, "config.json")
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if err := SaveConfig(path, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(data), `"model": ""`) {
|
||||||
|
t.Fatalf("saved config should include empty legacy model field, got: %s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestConfig_Complete verifies all config fields are set
|
// TestConfig_Complete verifies all config fields are set
|
||||||
func TestConfig_Complete(t *testing.T) {
|
func TestConfig_Complete(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
@ -331,8 +351,8 @@ func TestConfig_Complete(t *testing.T) {
|
||||||
if cfg.Agents.Defaults.Workspace == "" {
|
if cfg.Agents.Defaults.Workspace == "" {
|
||||||
t.Error("Workspace should not be empty")
|
t.Error("Workspace should not be empty")
|
||||||
}
|
}
|
||||||
if cfg.Agents.Defaults.Model == "" {
|
if cfg.Agents.Defaults.Model != "" {
|
||||||
t.Error("Model should not be empty")
|
t.Error("Model should be empty")
|
||||||
}
|
}
|
||||||
if cfg.Agents.Defaults.Temperature != nil {
|
if cfg.Agents.Defaults.Temperature != nil {
|
||||||
t.Error("Temperature should be nil when not provided")
|
t.Error("Temperature should be nil when not provided")
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,10 @@ func DefaultConfig() *Config {
|
||||||
Workspace: "~/.picoclaw/workspace",
|
Workspace: "~/.picoclaw/workspace",
|
||||||
RestrictToWorkspace: true,
|
RestrictToWorkspace: true,
|
||||||
Provider: "",
|
Provider: "",
|
||||||
Model: "glm-4.7",
|
Model: "",
|
||||||
MaxTokens: 8192,
|
MaxTokens: 32768,
|
||||||
Temperature: nil, // nil means use provider default
|
Temperature: nil, // nil means use provider default
|
||||||
MaxToolIterations: 20,
|
MaxToolIterations: 50,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Bindings: []AgentBinding{},
|
Bindings: []AgentBinding{},
|
||||||
|
|
@ -27,12 +27,19 @@ func DefaultConfig() *Config {
|
||||||
WhatsApp: WhatsAppConfig{
|
WhatsApp: WhatsAppConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
BridgeURL: "ws://localhost:3001",
|
BridgeURL: "ws://localhost:3001",
|
||||||
|
UseNative: false,
|
||||||
|
SessionStorePath: "",
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
Telegram: TelegramConfig{
|
Telegram: TelegramConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Token: "",
|
Token: "",
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
Typing: TypingConfig{Enabled: true},
|
||||||
|
Placeholder: PlaceholderConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Text: "Thinking... 💭",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Feishu: FeishuConfig{
|
Feishu: FeishuConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
|
|
@ -80,6 +87,7 @@ func DefaultConfig() *Config {
|
||||||
WebhookPort: 18791,
|
WebhookPort: 18791,
|
||||||
WebhookPath: "/webhook/line",
|
WebhookPath: "/webhook/line",
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
GroupTrigger: GroupTriggerConfig{MentionOnly: true},
|
||||||
},
|
},
|
||||||
OneBot: OneBotConfig{
|
OneBot: OneBotConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
|
|
@ -113,6 +121,15 @@ func DefaultConfig() *Config {
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
ReplyTimeout: 5,
|
ReplyTimeout: 5,
|
||||||
},
|
},
|
||||||
|
Pico: PicoConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Token: "",
|
||||||
|
PingInterval: 30,
|
||||||
|
ReadTimeout: 60,
|
||||||
|
WriteTimeout: 10,
|
||||||
|
MaxConnections: 100,
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: ProvidersConfig{
|
||||||
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
||||||
|
|
@ -276,6 +293,11 @@ func DefaultConfig() *Config {
|
||||||
Port: 18790,
|
Port: 18790,
|
||||||
},
|
},
|
||||||
Tools: ToolsConfig{
|
Tools: ToolsConfig{
|
||||||
|
MediaCleanup: MediaCleanupConfig{
|
||||||
|
Enabled: true,
|
||||||
|
MaxAge: 30,
|
||||||
|
Interval: 5,
|
||||||
|
},
|
||||||
Web: WebToolsConfig{
|
Web: WebToolsConfig{
|
||||||
Proxy: "",
|
Proxy: "",
|
||||||
Brave: BraveConfig{
|
Brave: BraveConfig{
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/constants"
|
"github.com/sipeed/picoclaw/pkg/constants"
|
||||||
|
|
@ -127,7 +128,9 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := ev.FormatMessage()
|
msg := ev.FormatMessage()
|
||||||
msgBus.PublishOutbound(bus.OutboundMessage{
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
Channel: platform,
|
Channel: platform,
|
||||||
ChatID: userID,
|
ChatID: userID,
|
||||||
Content: msg,
|
Content: msg,
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,13 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterOnMux registers /health and /ready handlers onto the given mux.
|
||||||
|
// This allows the health endpoints to be served by a shared HTTP server.
|
||||||
|
func (s *Server) RegisterOnMux(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/health", s.healthHandler)
|
||||||
|
mux.HandleFunc("/ready", s.readyHandler)
|
||||||
|
}
|
||||||
|
|
||||||
func statusString(ok bool) string {
|
func statusString(ok bool) string {
|
||||||
if ok {
|
if ok {
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
package heartbeat
|
package heartbeat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -308,7 +309,9 @@ func (hs *HeartbeatService) sendResponse(response string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus.PublishOutbound(bus.OutboundMessage{
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
Channel: platform,
|
Channel: platform,
|
||||||
ChatID: userID,
|
ChatID: userID,
|
||||||
Content: response,
|
Content: response,
|
||||||
|
|
|
||||||
107
pkg/identity/identity.go
Normal file
107
pkg/identity/identity.go
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
// Package identity provides unified user identity utilities for PicoClaw.
|
||||||
|
// It introduces a canonical "platform:id" format and matching logic
|
||||||
|
// that is backward-compatible with all legacy allow-list formats.
|
||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildCanonicalID constructs a canonical "platform:id" identifier.
|
||||||
|
// Both platform and platformID are lowercased and trimmed.
|
||||||
|
func BuildCanonicalID(platform, platformID string) string {
|
||||||
|
p := strings.ToLower(strings.TrimSpace(platform))
|
||||||
|
id := strings.TrimSpace(platformID)
|
||||||
|
if p == "" || id == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p + ":" + id
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCanonicalID splits a canonical ID ("platform:id") into its parts.
|
||||||
|
// Returns ok=false if the input does not contain a colon separator.
|
||||||
|
func ParseCanonicalID(canonical string) (platform, id string, ok bool) {
|
||||||
|
canonical = strings.TrimSpace(canonical)
|
||||||
|
idx := strings.Index(canonical, ":")
|
||||||
|
if idx <= 0 || idx == len(canonical)-1 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return canonical[:idx], canonical[idx+1:], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchAllowed checks whether the given sender matches a single allow-list entry.
|
||||||
|
// It is backward-compatible with all legacy formats:
|
||||||
|
//
|
||||||
|
// - "123456" → matches sender.PlatformID
|
||||||
|
// - "@alice" → matches sender.Username
|
||||||
|
// - "123456|alice" → matches PlatformID or Username
|
||||||
|
// - "telegram:123456" → exact match on sender.CanonicalID
|
||||||
|
func MatchAllowed(sender bus.SenderInfo, allowed string) bool {
|
||||||
|
allowed = strings.TrimSpace(allowed)
|
||||||
|
if allowed == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try canonical match first: "platform:id" format
|
||||||
|
if platform, id, ok := ParseCanonicalID(allowed); ok {
|
||||||
|
// Only treat as canonical if the platform portion looks like a known platform name
|
||||||
|
// (not a pure-numeric string, which could be a compound ID)
|
||||||
|
if !isNumeric(platform) {
|
||||||
|
candidate := BuildCanonicalID(platform, id)
|
||||||
|
if candidate != "" && sender.CanonicalID != "" {
|
||||||
|
return strings.EqualFold(sender.CanonicalID, candidate)
|
||||||
|
}
|
||||||
|
// If sender has no canonical ID, try matching platform + platformID
|
||||||
|
return strings.EqualFold(platform, sender.Platform) &&
|
||||||
|
sender.PlatformID == id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip leading "@" for username matching
|
||||||
|
trimmed := strings.TrimPrefix(allowed, "@")
|
||||||
|
|
||||||
|
// Split compound "id|username" format
|
||||||
|
allowedID := trimmed
|
||||||
|
allowedUser := ""
|
||||||
|
if idx := strings.Index(trimmed, "|"); idx > 0 {
|
||||||
|
allowedID = trimmed[:idx]
|
||||||
|
allowedUser = trimmed[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match against PlatformID
|
||||||
|
if sender.PlatformID != "" && sender.PlatformID == allowedID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match against Username
|
||||||
|
if sender.Username != "" {
|
||||||
|
if sender.Username == trimmed || sender.Username == allowedUser {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match compound sender format against allowed parts
|
||||||
|
if allowedUser != "" && sender.PlatformID != "" && sender.PlatformID == allowedID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if allowedUser != "" && sender.Username != "" && sender.Username == allowedUser {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNumeric returns true if s consists entirely of digits.
|
||||||
|
func isNumeric(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
229
pkg/identity/identity_test.go
Normal file
229
pkg/identity/identity_test.go
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildCanonicalID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
platform string
|
||||||
|
platformID string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"telegram", "123456", "telegram:123456"},
|
||||||
|
{"Discord", "98765432", "discord:98765432"},
|
||||||
|
{"SLACK", "U123ABC", "slack:U123ABC"},
|
||||||
|
{"", "123", ""},
|
||||||
|
{"telegram", "", ""},
|
||||||
|
{" telegram ", " 123 ", "telegram:123"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := BuildCanonicalID(tt.platform, tt.platformID)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("BuildCanonicalID(%q, %q) = %q, want %q",
|
||||||
|
tt.platform, tt.platformID, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCanonicalID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
wantPlatform string
|
||||||
|
wantID string
|
||||||
|
wantOk bool
|
||||||
|
}{
|
||||||
|
{"telegram:123456", "telegram", "123456", true},
|
||||||
|
{"discord:98765432", "discord", "98765432", true},
|
||||||
|
{"slack:U123ABC", "slack", "U123ABC", true},
|
||||||
|
{"nocolon", "", "", false},
|
||||||
|
{"", "", "", false},
|
||||||
|
{":missing", "", "", false},
|
||||||
|
{"missing:", "", "", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
platform, id, ok := ParseCanonicalID(tt.input)
|
||||||
|
if ok != tt.wantOk || platform != tt.wantPlatform || id != tt.wantID {
|
||||||
|
t.Errorf("ParseCanonicalID(%q) = (%q, %q, %v), want (%q, %q, %v)",
|
||||||
|
tt.input, platform, id, ok,
|
||||||
|
tt.wantPlatform, tt.wantID, tt.wantOk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchAllowed(t *testing.T) {
|
||||||
|
telegramSender := bus.SenderInfo{
|
||||||
|
Platform: "telegram",
|
||||||
|
PlatformID: "123456",
|
||||||
|
CanonicalID: "telegram:123456",
|
||||||
|
Username: "alice",
|
||||||
|
DisplayName: "Alice Smith",
|
||||||
|
}
|
||||||
|
|
||||||
|
discordSender := bus.SenderInfo{
|
||||||
|
Platform: "discord",
|
||||||
|
PlatformID: "98765432",
|
||||||
|
CanonicalID: "discord:98765432",
|
||||||
|
Username: "bob",
|
||||||
|
DisplayName: "bob#1234",
|
||||||
|
}
|
||||||
|
|
||||||
|
noCanonicalSender := bus.SenderInfo{
|
||||||
|
Platform: "telegram",
|
||||||
|
PlatformID: "999",
|
||||||
|
Username: "carol",
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sender bus.SenderInfo
|
||||||
|
allowed string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// Pure numeric ID matching
|
||||||
|
{
|
||||||
|
name: "numeric ID matches PlatformID",
|
||||||
|
sender: telegramSender,
|
||||||
|
allowed: "123456",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "numeric ID does not match",
|
||||||
|
sender: telegramSender,
|
||||||
|
allowed: "654321",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
// Username matching
|
||||||
|
{
|
||||||
|
name: "@username matches Username",
|
||||||
|
sender: telegramSender,
|
||||||
|
allowed: "@alice",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "@username does not match",
|
||||||
|
sender: telegramSender,
|
||||||
|
allowed: "@bob",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
// Compound format "id|username"
|
||||||
|
{
|
||||||
|
name: "compound matches by ID",
|
||||||
|
sender: telegramSender,
|
||||||
|
allowed: "123456|alice",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "compound matches by username",
|
||||||
|
sender: telegramSender,
|
||||||
|
allowed: "999|alice",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "compound does not match",
|
||||||
|
sender: telegramSender,
|
||||||
|
allowed: "654321|bob",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
// Canonical format "platform:id"
|
||||||
|
{
|
||||||
|
name: "canonical matches exactly",
|
||||||
|
sender: telegramSender,
|
||||||
|
allowed: "telegram:123456",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "canonical case-insensitive platform",
|
||||||
|
sender: telegramSender,
|
||||||
|
allowed: "Telegram:123456",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "canonical wrong platform",
|
||||||
|
sender: telegramSender,
|
||||||
|
allowed: "discord:123456",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "canonical wrong ID",
|
||||||
|
sender: telegramSender,
|
||||||
|
allowed: "telegram:654321",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
// Cross-platform canonical
|
||||||
|
{
|
||||||
|
name: "discord canonical match",
|
||||||
|
sender: discordSender,
|
||||||
|
allowed: "discord:98765432",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "telegram canonical does not match discord sender",
|
||||||
|
sender: discordSender,
|
||||||
|
allowed: "telegram:98765432",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
// Sender without canonical ID
|
||||||
|
{
|
||||||
|
name: "canonical match falls back to platform+platformID",
|
||||||
|
sender: noCanonicalSender,
|
||||||
|
allowed: "telegram:999",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "platform mismatch on fallback",
|
||||||
|
sender: noCanonicalSender,
|
||||||
|
allowed: "discord:999",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
// Empty allowed string
|
||||||
|
{
|
||||||
|
name: "empty allowed never matches",
|
||||||
|
sender: telegramSender,
|
||||||
|
allowed: "",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
// Whitespace handling
|
||||||
|
{
|
||||||
|
name: "trimmed allowed matches",
|
||||||
|
sender: telegramSender,
|
||||||
|
allowed: " 123456 ",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := MatchAllowed(tt.sender, tt.allowed)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("MatchAllowed(%+v, %q) = %v, want %v",
|
||||||
|
tt.sender, tt.allowed, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsNumeric(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"123456", true},
|
||||||
|
{"0", true},
|
||||||
|
{"", false},
|
||||||
|
{"abc", false},
|
||||||
|
{"12a34", false},
|
||||||
|
{"telegram", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := isNumeric(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("isNumeric(%q) = %v, want %v", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
271
pkg/media/store.go
Normal file
271
pkg/media/store.go
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MediaMeta holds metadata about a stored media file.
|
||||||
|
type MediaMeta struct {
|
||||||
|
Filename string
|
||||||
|
ContentType string
|
||||||
|
Source string // "telegram", "discord", "tool:image-gen", etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaStore manages the lifecycle of media files associated with processing scopes.
|
||||||
|
type MediaStore interface {
|
||||||
|
// Store registers an existing local file under the given scope.
|
||||||
|
// Returns a ref identifier (e.g. "media://<id>").
|
||||||
|
// Store does not move or copy the file; it only records the mapping.
|
||||||
|
Store(localPath string, meta MediaMeta, scope string) (ref string, err error)
|
||||||
|
|
||||||
|
// Resolve returns the local file path for a given ref.
|
||||||
|
Resolve(ref string) (localPath string, err error)
|
||||||
|
|
||||||
|
// ResolveWithMeta returns the local file path and metadata for a given ref.
|
||||||
|
ResolveWithMeta(ref string) (localPath string, meta MediaMeta, err error)
|
||||||
|
|
||||||
|
// ReleaseAll deletes all files registered under the given scope
|
||||||
|
// and removes the mapping entries. File-not-exist errors are ignored.
|
||||||
|
ReleaseAll(scope string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaEntry holds the path and metadata for a stored media file.
|
||||||
|
type mediaEntry struct {
|
||||||
|
path string
|
||||||
|
meta MediaMeta
|
||||||
|
storedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaCleanerConfig configures the background TTL cleanup.
|
||||||
|
type MediaCleanerConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
MaxAge time.Duration
|
||||||
|
Interval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileMediaStore is a pure in-memory implementation of MediaStore.
|
||||||
|
// Files are expected to already exist on disk (e.g. in /tmp/picoclaw_media/).
|
||||||
|
type FileMediaStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
refs map[string]mediaEntry
|
||||||
|
scopeToRefs map[string]map[string]struct{}
|
||||||
|
refToScope map[string]string
|
||||||
|
|
||||||
|
cleanerCfg MediaCleanerConfig
|
||||||
|
stop chan struct{}
|
||||||
|
startOnce sync.Once
|
||||||
|
stopOnce sync.Once
|
||||||
|
nowFunc func() time.Time // for testing
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileMediaStore creates a new FileMediaStore without background cleanup.
|
||||||
|
func NewFileMediaStore() *FileMediaStore {
|
||||||
|
return &FileMediaStore{
|
||||||
|
refs: make(map[string]mediaEntry),
|
||||||
|
scopeToRefs: make(map[string]map[string]struct{}),
|
||||||
|
refToScope: make(map[string]string),
|
||||||
|
nowFunc: time.Now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileMediaStoreWithCleanup creates a FileMediaStore with TTL-based background cleanup.
|
||||||
|
func NewFileMediaStoreWithCleanup(cfg MediaCleanerConfig) *FileMediaStore {
|
||||||
|
return &FileMediaStore{
|
||||||
|
refs: make(map[string]mediaEntry),
|
||||||
|
scopeToRefs: make(map[string]map[string]struct{}),
|
||||||
|
refToScope: make(map[string]string),
|
||||||
|
cleanerCfg: cfg,
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
nowFunc: time.Now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store registers a local file under the given scope. The file must exist.
|
||||||
|
func (s *FileMediaStore) Store(localPath string, meta MediaMeta, scope string) (string, error) {
|
||||||
|
if _, err := os.Stat(localPath); err != nil {
|
||||||
|
return "", fmt.Errorf("media store: %s: %w", localPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := "media://" + uuid.New().String()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.refs[ref] = mediaEntry{path: localPath, meta: meta, storedAt: s.nowFunc()}
|
||||||
|
if s.scopeToRefs[scope] == nil {
|
||||||
|
s.scopeToRefs[scope] = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
s.scopeToRefs[scope][ref] = struct{}{}
|
||||||
|
s.refToScope[ref] = scope
|
||||||
|
|
||||||
|
return ref, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve returns the local path for the given ref.
|
||||||
|
func (s *FileMediaStore) Resolve(ref string) (string, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
entry, ok := s.refs[ref]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("media store: unknown ref: %s", ref)
|
||||||
|
}
|
||||||
|
return entry.path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveWithMeta returns the local path and metadata for the given ref.
|
||||||
|
func (s *FileMediaStore) ResolveWithMeta(ref string) (string, MediaMeta, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
entry, ok := s.refs[ref]
|
||||||
|
if !ok {
|
||||||
|
return "", MediaMeta{}, fmt.Errorf("media store: unknown ref: %s", ref)
|
||||||
|
}
|
||||||
|
return entry.path, entry.meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseAll removes all files under the given scope and cleans up mappings.
|
||||||
|
// Phase 1 (under lock): remove entries from maps.
|
||||||
|
// Phase 2 (no lock): delete files from disk.
|
||||||
|
func (s *FileMediaStore) ReleaseAll(scope string) error {
|
||||||
|
// Phase 1: collect paths and remove from maps under lock
|
||||||
|
var paths []string
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
refs, ok := s.scopeToRefs[scope]
|
||||||
|
if !ok {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for ref := range refs {
|
||||||
|
if entry, exists := s.refs[ref]; exists {
|
||||||
|
paths = append(paths, entry.path)
|
||||||
|
}
|
||||||
|
delete(s.refs, ref)
|
||||||
|
delete(s.refToScope, ref)
|
||||||
|
}
|
||||||
|
delete(s.scopeToRefs, scope)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Phase 2: delete files without holding the lock
|
||||||
|
for _, p := range paths {
|
||||||
|
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||||
|
logger.WarnCF("media", "release: failed to remove file", map[string]any{
|
||||||
|
"path": p,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanExpired removes all entries older than MaxAge.
|
||||||
|
// Phase 1 (under lock): identify expired entries and remove from maps.
|
||||||
|
// Phase 2 (no lock): delete files from disk to minimize lock contention.
|
||||||
|
func (s *FileMediaStore) CleanExpired() int {
|
||||||
|
if s.cleanerCfg.MaxAge <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: collect expired entries under lock
|
||||||
|
type expiredEntry struct {
|
||||||
|
ref string
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
cutoff := s.nowFunc().Add(-s.cleanerCfg.MaxAge)
|
||||||
|
var expired []expiredEntry
|
||||||
|
|
||||||
|
for ref, entry := range s.refs {
|
||||||
|
if entry.storedAt.Before(cutoff) {
|
||||||
|
expired = append(expired, expiredEntry{ref: ref, path: entry.path})
|
||||||
|
|
||||||
|
if scope, ok := s.refToScope[ref]; ok {
|
||||||
|
if scopeRefs, ok := s.scopeToRefs[scope]; ok {
|
||||||
|
delete(scopeRefs, ref)
|
||||||
|
if len(scopeRefs) == 0 {
|
||||||
|
delete(s.scopeToRefs, scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(s.refs, ref)
|
||||||
|
delete(s.refToScope, ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Phase 2: delete files without holding the lock
|
||||||
|
for _, e := range expired {
|
||||||
|
if err := os.Remove(e.path); err != nil && !os.IsNotExist(err) {
|
||||||
|
logger.WarnCF("media", "cleanup: failed to remove file", map[string]any{
|
||||||
|
"path": e.path,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(expired)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins the background cleanup goroutine if cleanup is enabled.
|
||||||
|
// Safe to call multiple times; only the first call starts the goroutine.
|
||||||
|
func (s *FileMediaStore) Start() {
|
||||||
|
if !s.cleanerCfg.Enabled || s.stop == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.cleanerCfg.Interval <= 0 || s.cleanerCfg.MaxAge <= 0 {
|
||||||
|
logger.WarnCF("media", "cleanup: skipped due to invalid config", map[string]any{
|
||||||
|
"interval": s.cleanerCfg.Interval.String(),
|
||||||
|
"max_age": s.cleanerCfg.MaxAge.String(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.startOnce.Do(func() {
|
||||||
|
logger.InfoCF("media", "cleanup enabled", map[string]any{
|
||||||
|
"interval": s.cleanerCfg.Interval.String(),
|
||||||
|
"max_age": s.cleanerCfg.MaxAge.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(s.cleanerCfg.Interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
if n := s.CleanExpired(); n > 0 {
|
||||||
|
logger.InfoCF("media", "cleanup: removed expired entries", map[string]any{
|
||||||
|
"count": n,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop terminates the background cleanup goroutine.
|
||||||
|
// Safe to call multiple times; only the first call closes the channel.
|
||||||
|
func (s *FileMediaStore) Stop() {
|
||||||
|
if s.stop == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.stopOnce.Do(func() {
|
||||||
|
close(s.stop)
|
||||||
|
})
|
||||||
|
}
|
||||||
530
pkg/media/store_test.go
Normal file
530
pkg/media/store_test.go
Normal file
|
|
@ -0,0 +1,530 @@
|
||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createTempFile(t *testing.T, dir, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(path, []byte("test content"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreAndResolve(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := NewFileMediaStore()
|
||||||
|
|
||||||
|
path := createTempFile(t, dir, "photo.jpg")
|
||||||
|
|
||||||
|
ref, err := store.Store(path, MediaMeta{Filename: "photo.jpg", Source: "telegram"}, "scope1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Store failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(ref, "media://") {
|
||||||
|
t.Errorf("ref should start with media://, got %q", ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := store.Resolve(ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve failed: %v", err)
|
||||||
|
}
|
||||||
|
if resolved != path {
|
||||||
|
t.Errorf("Resolve returned %q, want %q", resolved, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseAll(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := NewFileMediaStore()
|
||||||
|
|
||||||
|
paths := make([]string, 3)
|
||||||
|
refs := make([]string, 3)
|
||||||
|
for i := range 3 {
|
||||||
|
paths[i] = createTempFile(t, dir, strings.Repeat("a", i+1)+".jpg")
|
||||||
|
var err error
|
||||||
|
refs[i], err = store.Store(paths[i], MediaMeta{Source: "test"}, "scope1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Store failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.ReleaseAll("scope1"); err != nil {
|
||||||
|
t.Fatalf("ReleaseAll failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files should be deleted
|
||||||
|
for _, p := range paths {
|
||||||
|
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("file %q should have been deleted", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refs should be unresolvable
|
||||||
|
for _, ref := range refs {
|
||||||
|
if _, err := store.Resolve(ref); err == nil {
|
||||||
|
t.Errorf("Resolve(%q) should fail after ReleaseAll", ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiScopeIsolation(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := NewFileMediaStore()
|
||||||
|
|
||||||
|
pathA := createTempFile(t, dir, "fileA.jpg")
|
||||||
|
pathB := createTempFile(t, dir, "fileB.jpg")
|
||||||
|
|
||||||
|
refA, _ := store.Store(pathA, MediaMeta{Source: "test"}, "scopeA")
|
||||||
|
refB, _ := store.Store(pathB, MediaMeta{Source: "test"}, "scopeB")
|
||||||
|
|
||||||
|
// Release only scopeA
|
||||||
|
if err := store.ReleaseAll("scopeA"); err != nil {
|
||||||
|
t.Fatalf("ReleaseAll(scopeA) failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeA file should be gone
|
||||||
|
if _, err := os.Stat(pathA); !os.IsNotExist(err) {
|
||||||
|
t.Error("file A should have been deleted")
|
||||||
|
}
|
||||||
|
if _, err := store.Resolve(refA); err == nil {
|
||||||
|
t.Error("refA should be unresolvable after release")
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeB file should still exist
|
||||||
|
if _, err := os.Stat(pathB); err != nil {
|
||||||
|
t.Error("file B should still exist")
|
||||||
|
}
|
||||||
|
resolved, err := store.Resolve(refB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("refB should still resolve: %v", err)
|
||||||
|
}
|
||||||
|
if resolved != pathB {
|
||||||
|
t.Errorf("resolved %q, want %q", resolved, pathB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseAllIdempotent(t *testing.T) {
|
||||||
|
store := NewFileMediaStore()
|
||||||
|
|
||||||
|
// ReleaseAll on non-existent scope should not error
|
||||||
|
if err := store.ReleaseAll("nonexistent"); err != nil {
|
||||||
|
t.Fatalf("ReleaseAll on empty scope should not error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and release, then release again
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := createTempFile(t, dir, "file.jpg")
|
||||||
|
_, _ = store.Store(path, MediaMeta{Source: "test"}, "scope1")
|
||||||
|
|
||||||
|
if err := store.ReleaseAll("scope1"); err != nil {
|
||||||
|
t.Fatalf("first ReleaseAll failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.ReleaseAll("scope1"); err != nil {
|
||||||
|
t.Fatalf("second ReleaseAll should not error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseAllCleansMappingsIfRefsMissing(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := NewFileMediaStore()
|
||||||
|
|
||||||
|
path := createTempFile(t, dir, "file.jpg")
|
||||||
|
ref, err := store.Store(path, MediaMeta{Source: "test"}, "scope1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Store failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate internal inconsistency: scopeToRefs/refToScope contains ref but refs map doesn't.
|
||||||
|
store.mu.Lock()
|
||||||
|
delete(store.refs, ref)
|
||||||
|
store.mu.Unlock()
|
||||||
|
|
||||||
|
if err := store.ReleaseAll("scope1"); err != nil {
|
||||||
|
t.Fatalf("ReleaseAll failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseAll should still clean mappings (even if it can't delete the file without the path).
|
||||||
|
store.mu.RLock()
|
||||||
|
defer store.mu.RUnlock()
|
||||||
|
if _, ok := store.refToScope[ref]; ok {
|
||||||
|
t.Error("refToScope should not contain ref after ReleaseAll")
|
||||||
|
}
|
||||||
|
if _, ok := store.scopeToRefs["scope1"]; ok {
|
||||||
|
t.Error("scopeToRefs should not contain scope1 after ReleaseAll")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreNonexistentFile(t *testing.T) {
|
||||||
|
store := NewFileMediaStore()
|
||||||
|
|
||||||
|
_, err := store.Store("/nonexistent/path/file.jpg", MediaMeta{Source: "test"}, "scope1")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Store should fail for nonexistent file")
|
||||||
|
}
|
||||||
|
// Error message should include the underlying os error, not just "file does not exist"
|
||||||
|
if !strings.Contains(err.Error(), "no such file or directory") &&
|
||||||
|
!strings.Contains(err.Error(), "cannot find") {
|
||||||
|
t.Errorf("Error should contain OS error detail, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWithMeta(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := NewFileMediaStore()
|
||||||
|
|
||||||
|
path := createTempFile(t, dir, "image.png")
|
||||||
|
meta := MediaMeta{
|
||||||
|
Filename: "image.png",
|
||||||
|
ContentType: "image/png",
|
||||||
|
Source: "telegram",
|
||||||
|
}
|
||||||
|
|
||||||
|
ref, err := store.Store(path, meta, "scope1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Store failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedPath, resolvedMeta, err := store.ResolveWithMeta(ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveWithMeta failed: %v", err)
|
||||||
|
}
|
||||||
|
if resolvedPath != path {
|
||||||
|
t.Errorf("ResolveWithMeta path = %q, want %q", resolvedPath, path)
|
||||||
|
}
|
||||||
|
if resolvedMeta.Filename != meta.Filename {
|
||||||
|
t.Errorf("ResolveWithMeta Filename = %q, want %q", resolvedMeta.Filename, meta.Filename)
|
||||||
|
}
|
||||||
|
if resolvedMeta.ContentType != meta.ContentType {
|
||||||
|
t.Errorf("ResolveWithMeta ContentType = %q, want %q", resolvedMeta.ContentType, meta.ContentType)
|
||||||
|
}
|
||||||
|
if resolvedMeta.Source != meta.Source {
|
||||||
|
t.Errorf("ResolveWithMeta Source = %q, want %q", resolvedMeta.Source, meta.Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown ref should fail
|
||||||
|
_, _, err = store.ResolveWithMeta("media://nonexistent")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("ResolveWithMeta should fail for unknown ref")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentSafety(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := NewFileMediaStore()
|
||||||
|
|
||||||
|
const goroutines = 20
|
||||||
|
const filesPerGoroutine = 5
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(goroutines)
|
||||||
|
|
||||||
|
for g := range goroutines {
|
||||||
|
go func(gIdx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
scope := strings.Repeat("s", gIdx+1)
|
||||||
|
|
||||||
|
for i := range filesPerGoroutine {
|
||||||
|
path := createTempFile(t, dir, strings.Repeat("f", gIdx*filesPerGoroutine+i+1)+".tmp")
|
||||||
|
ref, err := store.Store(path, MediaMeta{Source: "test"}, scope)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Store failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.Resolve(ref); err != nil {
|
||||||
|
t.Errorf("Resolve failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.ReleaseAll(scope); err != nil {
|
||||||
|
t.Errorf("ReleaseAll failed: %v", err)
|
||||||
|
}
|
||||||
|
}(g)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TTL cleanup tests ---
|
||||||
|
|
||||||
|
func newTestStoreWithCleanup(maxAge time.Duration) *FileMediaStore {
|
||||||
|
s := NewFileMediaStoreWithCleanup(MediaCleanerConfig{
|
||||||
|
Enabled: true,
|
||||||
|
MaxAge: maxAge,
|
||||||
|
Interval: time.Hour, // won't tick in tests
|
||||||
|
})
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanExpiredRemovesOldEntries(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Now()
|
||||||
|
store := newTestStoreWithCleanup(10 * time.Minute)
|
||||||
|
store.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) }
|
||||||
|
|
||||||
|
path := createTempFile(t, dir, "old.jpg")
|
||||||
|
ref, err := store.Store(path, MediaMeta{Source: "test"}, "scope1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Store failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance clock to present
|
||||||
|
store.nowFunc = func() time.Time { return now }
|
||||||
|
removed := store.CleanExpired()
|
||||||
|
|
||||||
|
if removed != 1 {
|
||||||
|
t.Errorf("expected 1 removed, got %d", removed)
|
||||||
|
}
|
||||||
|
if _, err := store.Resolve(ref); err == nil {
|
||||||
|
t.Error("expired ref should be unresolvable")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Error("expired file should be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanExpiredKeepsNonExpired(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Now()
|
||||||
|
store := newTestStoreWithCleanup(10 * time.Minute)
|
||||||
|
store.nowFunc = func() time.Time { return now }
|
||||||
|
|
||||||
|
path := createTempFile(t, dir, "fresh.jpg")
|
||||||
|
ref, err := store.Store(path, MediaMeta{Source: "test"}, "scope1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Store failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
removed := store.CleanExpired()
|
||||||
|
if removed != 0 {
|
||||||
|
t.Errorf("expected 0 removed, got %d", removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.Resolve(ref); err != nil {
|
||||||
|
t.Errorf("fresh ref should still resolve: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Error("fresh file should still exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanExpiredMixedAges(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Now()
|
||||||
|
store := newTestStoreWithCleanup(10 * time.Minute)
|
||||||
|
|
||||||
|
// Store old entry
|
||||||
|
store.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) }
|
||||||
|
oldPath := createTempFile(t, dir, "old.jpg")
|
||||||
|
oldRef, _ := store.Store(oldPath, MediaMeta{Source: "test"}, "scope1")
|
||||||
|
|
||||||
|
// Store fresh entry
|
||||||
|
store.nowFunc = func() time.Time { return now }
|
||||||
|
freshPath := createTempFile(t, dir, "fresh.jpg")
|
||||||
|
freshRef, _ := store.Store(freshPath, MediaMeta{Source: "test"}, "scope1")
|
||||||
|
|
||||||
|
removed := store.CleanExpired()
|
||||||
|
if removed != 1 {
|
||||||
|
t.Errorf("expected 1 removed, got %d", removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.Resolve(oldRef); err == nil {
|
||||||
|
t.Error("old ref should be gone")
|
||||||
|
}
|
||||||
|
if _, err := store.Resolve(freshRef); err != nil {
|
||||||
|
t.Errorf("fresh ref should still resolve: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanExpiredCleansEmptyScopes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Now()
|
||||||
|
store := newTestStoreWithCleanup(10 * time.Minute)
|
||||||
|
|
||||||
|
// Store old entry as the only one in scope
|
||||||
|
store.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) }
|
||||||
|
path := createTempFile(t, dir, "only.jpg")
|
||||||
|
store.Store(path, MediaMeta{Source: "test"}, "lonely_scope")
|
||||||
|
|
||||||
|
store.nowFunc = func() time.Time { return now }
|
||||||
|
store.CleanExpired()
|
||||||
|
|
||||||
|
store.mu.RLock()
|
||||||
|
defer store.mu.RUnlock()
|
||||||
|
if _, ok := store.scopeToRefs["lonely_scope"]; ok {
|
||||||
|
t.Error("empty scope should be cleaned up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartStopLifecycle(t *testing.T) {
|
||||||
|
store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{
|
||||||
|
Enabled: true,
|
||||||
|
MaxAge: time.Minute,
|
||||||
|
Interval: 50 * time.Millisecond,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start and stop should not panic
|
||||||
|
store.Start()
|
||||||
|
// Double start should not spawn a second goroutine
|
||||||
|
store.Start()
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
store.Stop()
|
||||||
|
|
||||||
|
// Double stop should not panic
|
||||||
|
store.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanExpiredZeroMaxAge(t *testing.T) {
|
||||||
|
store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{
|
||||||
|
Enabled: true,
|
||||||
|
MaxAge: 0,
|
||||||
|
Interval: time.Hour,
|
||||||
|
})
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := createTempFile(t, dir, "file.jpg")
|
||||||
|
ref, _ := store.Store(path, MediaMeta{Source: "test"}, "scope1")
|
||||||
|
|
||||||
|
// Zero MaxAge should be a no-op
|
||||||
|
removed := store.CleanExpired()
|
||||||
|
if removed != 0 {
|
||||||
|
t.Errorf("expected 0 removed with zero MaxAge, got %d", removed)
|
||||||
|
}
|
||||||
|
if _, err := store.Resolve(ref); err != nil {
|
||||||
|
t.Errorf("ref should still resolve: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartDisabledIsNoop(t *testing.T) {
|
||||||
|
store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{
|
||||||
|
Enabled: false,
|
||||||
|
MaxAge: time.Minute,
|
||||||
|
Interval: time.Minute,
|
||||||
|
})
|
||||||
|
// Should not start any goroutine or panic
|
||||||
|
store.Start()
|
||||||
|
store.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartZeroIntervalNoPanic(t *testing.T) {
|
||||||
|
store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{
|
||||||
|
Enabled: true,
|
||||||
|
MaxAge: time.Minute,
|
||||||
|
Interval: 0,
|
||||||
|
})
|
||||||
|
// Zero interval should not panic (time.NewTicker panics on <= 0)
|
||||||
|
store.Start()
|
||||||
|
store.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartZeroMaxAgeNoPanic(t *testing.T) {
|
||||||
|
store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{
|
||||||
|
Enabled: true,
|
||||||
|
MaxAge: 0,
|
||||||
|
Interval: time.Minute,
|
||||||
|
})
|
||||||
|
store.Start()
|
||||||
|
store.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentCleanupSafety(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := newTestStoreWithCleanup(50 * time.Millisecond)
|
||||||
|
store.nowFunc = time.Now
|
||||||
|
|
||||||
|
const workers = 10
|
||||||
|
const ops = 20
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(workers * 4)
|
||||||
|
|
||||||
|
// Store workers
|
||||||
|
for w := range workers {
|
||||||
|
go func(wIdx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
scope := fmt.Sprintf("scope-%d", wIdx)
|
||||||
|
for i := range ops {
|
||||||
|
p := createTempFile(t, dir, fmt.Sprintf("w%d-f%d.tmp", wIdx, i))
|
||||||
|
store.Store(p, MediaMeta{Source: "test"}, scope)
|
||||||
|
}
|
||||||
|
}(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve workers
|
||||||
|
for range workers {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for range ops {
|
||||||
|
store.Resolve("media://nonexistent")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseAll workers
|
||||||
|
for w := range workers {
|
||||||
|
go func(wIdx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for range ops {
|
||||||
|
store.ReleaseAll(fmt.Sprintf("scope-%d", wIdx))
|
||||||
|
}
|
||||||
|
}(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanExpired workers
|
||||||
|
for range workers {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for range ops {
|
||||||
|
store.CleanExpired()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefToScopeConsistency(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := NewFileMediaStore()
|
||||||
|
|
||||||
|
// Store entries in two scopes
|
||||||
|
ref1, _ := store.Store(createTempFile(t, dir, "a.jpg"), MediaMeta{Source: "test"}, "s1")
|
||||||
|
ref2, _ := store.Store(createTempFile(t, dir, "b.jpg"), MediaMeta{Source: "test"}, "s1")
|
||||||
|
ref3, _ := store.Store(createTempFile(t, dir, "c.jpg"), MediaMeta{Source: "test"}, "s2")
|
||||||
|
|
||||||
|
store.mu.RLock()
|
||||||
|
checkRef := func(ref, expectedScope string) {
|
||||||
|
t.Helper()
|
||||||
|
if scope, ok := store.refToScope[ref]; !ok || scope != expectedScope {
|
||||||
|
t.Errorf("refToScope[%s] = %q, want %q", ref, scope, expectedScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkRef(ref1, "s1")
|
||||||
|
checkRef(ref2, "s1")
|
||||||
|
checkRef(ref3, "s2")
|
||||||
|
store.mu.RUnlock()
|
||||||
|
|
||||||
|
// Release s1 and verify refToScope is cleaned
|
||||||
|
store.ReleaseAll("s1")
|
||||||
|
|
||||||
|
store.mu.RLock()
|
||||||
|
defer store.mu.RUnlock()
|
||||||
|
if _, ok := store.refToScope[ref1]; ok {
|
||||||
|
t.Error("refToScope should not contain ref1 after ReleaseAll")
|
||||||
|
}
|
||||||
|
if _, ok := store.refToScope[ref2]; ok {
|
||||||
|
t.Error("refToScope should not contain ref2 after ReleaseAll")
|
||||||
|
}
|
||||||
|
if _, ok := store.refToScope[ref3]; !ok {
|
||||||
|
t.Error("refToScope should still contain ref3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -165,6 +165,12 @@ func ConvertConfig(data map[string]any) (*config.Config, []string, error) {
|
||||||
if v, ok := getString(cMap, "bridge_url"); ok {
|
if v, ok := getString(cMap, "bridge_url"); ok {
|
||||||
cfg.Channels.WhatsApp.BridgeURL = v
|
cfg.Channels.WhatsApp.BridgeURL = v
|
||||||
}
|
}
|
||||||
|
if v, ok := getBool(cMap, "use_native"); ok {
|
||||||
|
cfg.Channels.WhatsApp.UseNative = v
|
||||||
|
}
|
||||||
|
if v, ok := getString(cMap, "session_store_path"); ok {
|
||||||
|
cfg.Channels.WhatsApp.SessionStorePath = v
|
||||||
|
}
|
||||||
case "feishu":
|
case "feishu":
|
||||||
cfg.Channels.Feishu.Enabled = enabled
|
cfg.Channels.Feishu.Enabled = enabled
|
||||||
cfg.Channels.Feishu.AllowFrom = allowFrom
|
cfg.Channels.Feishu.AllowFrom = allowFrom
|
||||||
|
|
|
||||||
|
|
@ -296,8 +296,8 @@ func TestConvertConfig(t *testing.T) {
|
||||||
if len(warnings) != 0 {
|
if len(warnings) != 0 {
|
||||||
t.Errorf("expected no warnings, got %v", warnings)
|
t.Errorf("expected no warnings, got %v", warnings)
|
||||||
}
|
}
|
||||||
if cfg.Agents.Defaults.Model != "glm-4.7" {
|
if cfg.Agents.Defaults.Model != "" {
|
||||||
t.Errorf("default model should be glm-4.7, got %q", cfg.Agents.Defaults.Model)
|
t.Errorf("default model should be nil, got %q", cfg.Agents.Defaults.Model)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,26 @@ func NewFallbackChain(cooldown *CooldownTracker) *FallbackChain {
|
||||||
|
|
||||||
// ResolveCandidates parses model config into a deduplicated candidate list.
|
// ResolveCandidates parses model config into a deduplicated candidate list.
|
||||||
func ResolveCandidates(cfg ModelConfig, defaultProvider string) []FallbackCandidate {
|
func ResolveCandidates(cfg ModelConfig, defaultProvider string) []FallbackCandidate {
|
||||||
|
return ResolveCandidatesWithLookup(cfg, defaultProvider, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveCandidatesWithLookup(
|
||||||
|
cfg ModelConfig,
|
||||||
|
defaultProvider string,
|
||||||
|
lookup func(raw string) (resolved string, ok bool),
|
||||||
|
) []FallbackCandidate {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
var candidates []FallbackCandidate
|
var candidates []FallbackCandidate
|
||||||
|
|
||||||
addCandidate := func(raw string) {
|
addCandidate := func(raw string) {
|
||||||
ref := ParseModelRef(raw, defaultProvider)
|
candidateRaw := strings.TrimSpace(raw)
|
||||||
|
if lookup != nil {
|
||||||
|
if resolved, ok := lookup(candidateRaw); ok {
|
||||||
|
candidateRaw = resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := ParseModelRef(candidateRaw, defaultProvider)
|
||||||
if ref == nil {
|
if ref == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -453,6 +453,75 @@ func TestResolveCandidates_EmptyPrimary(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveCandidatesWithLookup_AliasResolvesToNestedModel(t *testing.T) {
|
||||||
|
cfg := ModelConfig{
|
||||||
|
Primary: "step-3.5-flash",
|
||||||
|
Fallbacks: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup := func(raw string) (string, bool) {
|
||||||
|
if raw == "step-3.5-flash" {
|
||||||
|
return "openrouter/stepfun/step-3.5-flash:free", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := ResolveCandidatesWithLookup(cfg, "", lookup)
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("candidates = %d, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
if candidates[0].Provider != "openrouter" {
|
||||||
|
t.Fatalf("provider = %q, want openrouter", candidates[0].Provider)
|
||||||
|
}
|
||||||
|
if candidates[0].Model != "stepfun/step-3.5-flash:free" {
|
||||||
|
t.Fatalf("model = %q, want stepfun/step-3.5-flash:free", candidates[0].Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCandidatesWithLookup_DeduplicateAfterLookup(t *testing.T) {
|
||||||
|
cfg := ModelConfig{
|
||||||
|
Primary: "step-3.5-flash",
|
||||||
|
Fallbacks: []string{"openrouter/stepfun/step-3.5-flash:free"},
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup := func(raw string) (string, bool) {
|
||||||
|
if raw == "step-3.5-flash" {
|
||||||
|
return "openrouter/stepfun/step-3.5-flash:free", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := ResolveCandidatesWithLookup(cfg, "", lookup)
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("candidates = %d, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCandidatesWithLookup_AliasWithoutProtocolUsesDefaultProvider(t *testing.T) {
|
||||||
|
cfg := ModelConfig{
|
||||||
|
Primary: "glm-5",
|
||||||
|
Fallbacks: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup := func(raw string) (string, bool) {
|
||||||
|
if raw == "glm-5" {
|
||||||
|
return "glm-5", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := ResolveCandidatesWithLookup(cfg, "openai", lookup)
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("candidates = %d, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
if candidates[0].Provider != "openai" {
|
||||||
|
t.Fatalf("provider = %q, want openai", candidates[0].Provider)
|
||||||
|
}
|
||||||
|
if candidates[0].Model != "glm-5" {
|
||||||
|
t.Fatalf("model = %q, want glm-5", candidates[0].Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFallbackExhaustedError_Message(t *testing.T) {
|
func TestFallbackExhaustedError_Message(t *testing.T) {
|
||||||
e := &FallbackExhaustedError{
|
e := &FallbackExhaustedError{
|
||||||
Attempts: []FallbackAttempt{
|
Attempts: []FallbackAttempt{
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ type (
|
||||||
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||||
ExtraContent = protocoltypes.ExtraContent
|
ExtraContent = protocoltypes.ExtraContent
|
||||||
GoogleExtra = protocoltypes.GoogleExtra
|
GoogleExtra = protocoltypes.GoogleExtra
|
||||||
|
ReasoningDetail = protocoltypes.ReasoningDetail
|
||||||
)
|
)
|
||||||
|
|
||||||
type Provider struct {
|
type Provider struct {
|
||||||
|
|
@ -200,6 +201,8 @@ func parseResponse(body []byte) (*LLMResponse, error) {
|
||||||
Message struct {
|
Message struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ReasoningContent string `json:"reasoning_content"`
|
ReasoningContent string `json:"reasoning_content"`
|
||||||
|
Reasoning string `json:"reasoning"`
|
||||||
|
ReasoningDetails []ReasoningDetail `json:"reasoning_details"`
|
||||||
ToolCalls []struct {
|
ToolCalls []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
|
@ -274,6 +277,8 @@ func parseResponse(body []byte) (*LLMResponse, error) {
|
||||||
return &LLMResponse{
|
return &LLMResponse{
|
||||||
Content: choice.Message.Content,
|
Content: choice.Message.Content,
|
||||||
ReasoningContent: choice.Message.ReasoningContent,
|
ReasoningContent: choice.Message.ReasoningContent,
|
||||||
|
Reasoning: choice.Message.Reasoning,
|
||||||
|
ReasoningDetails: choice.Message.ReasoningDetails,
|
||||||
ToolCalls: toolCalls,
|
ToolCalls: toolCalls,
|
||||||
FinishReason: choice.FinishReason,
|
FinishReason: choice.FinishReason,
|
||||||
Usage: apiResponse.Usage,
|
Usage: apiResponse.Usage,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,15 @@ type LLMResponse struct {
|
||||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
FinishReason string `json:"finish_reason"`
|
FinishReason string `json:"finish_reason"`
|
||||||
Usage *UsageInfo `json:"usage,omitempty"`
|
Usage *UsageInfo `json:"usage,omitempty"`
|
||||||
|
Reasoning string `json:"reasoning"`
|
||||||
|
ReasoningDetails []ReasoningDetail `json:"reasoning_details"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReasoningDetail struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
Index int `json:"index"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UsageInfo struct {
|
type UsageInfo struct {
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,15 @@ func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID stri
|
||||||
scopedCandidate := fmt.Sprintf("%s:%s", channel, strings.ToLower(peerID))
|
scopedCandidate := fmt.Sprintf("%s:%s", channel, strings.ToLower(peerID))
|
||||||
candidates[scopedCandidate] = true
|
candidates[scopedCandidate] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If peerID is already in canonical "platform:id" format, also add the
|
||||||
|
// bare ID part as a candidate for backward compatibility with identity_links
|
||||||
|
// that use raw IDs (e.g. "123" instead of "telegram:123").
|
||||||
|
if idx := strings.Index(rawCandidate, ":"); idx > 0 && idx < len(rawCandidate)-1 {
|
||||||
|
bareID := rawCandidate[idx+1:]
|
||||||
|
candidates[bareID] = true
|
||||||
|
}
|
||||||
|
|
||||||
if len(candidates) == 0 {
|
if len(candidates) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,51 @@ func TestBuildAgentPeerSessionKey_IdentityLink(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveLinkedPeerID_CanonicalPeerID(t *testing.T) {
|
||||||
|
// When peerID is already in canonical "platform:id" format,
|
||||||
|
// it should match identity_links that use the bare ID.
|
||||||
|
links := map[string][]string{
|
||||||
|
"john": {"123"},
|
||||||
|
}
|
||||||
|
got := resolveLinkedPeerID(links, "telegram", "telegram:123")
|
||||||
|
if got != "john" {
|
||||||
|
t.Errorf("resolveLinkedPeerID with canonical peerID = %q, want %q", got, "john")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLinkedPeerID_CanonicalInLinks(t *testing.T) {
|
||||||
|
// When identity_links contain canonical IDs and peerID is canonical too
|
||||||
|
links := map[string][]string{
|
||||||
|
"john": {"telegram:123", "discord:456"},
|
||||||
|
}
|
||||||
|
got := resolveLinkedPeerID(links, "telegram", "telegram:123")
|
||||||
|
if got != "john" {
|
||||||
|
t.Errorf("resolveLinkedPeerID canonical in links = %q, want %q", got, "john")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLinkedPeerID_BarePeerIDMatchesCanonicalLink(t *testing.T) {
|
||||||
|
// When peerID is bare "123" and links have "telegram:123",
|
||||||
|
// the scoped candidate "telegram:123" should match.
|
||||||
|
links := map[string][]string{
|
||||||
|
"john": {"telegram:123"},
|
||||||
|
}
|
||||||
|
got := resolveLinkedPeerID(links, "telegram", "123")
|
||||||
|
if got != "john" {
|
||||||
|
t.Errorf("resolveLinkedPeerID bare peer matches canonical link = %q, want %q", got, "john")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLinkedPeerID_NoMatch(t *testing.T) {
|
||||||
|
links := map[string][]string{
|
||||||
|
"john": {"telegram:123"},
|
||||||
|
}
|
||||||
|
got := resolveLinkedPeerID(links, "discord", "999")
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("resolveLinkedPeerID no match = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseAgentSessionKey_Valid(t *testing.T) {
|
func TestParseAgentSessionKey_Valid(t *testing.T) {
|
||||||
parsed := ParseAgentSessionKey("agent:sales:telegram:direct:user123")
|
parsed := ParseAgentSessionKey("agent:sales:telegram:direct:user123")
|
||||||
if parsed == nil {
|
if parsed == nil {
|
||||||
|
|
|
||||||
|
|
@ -296,7 +296,9 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
|
||||||
output = fmt.Sprintf("Scheduled command '%s' executed:\n%s", job.Payload.Command, result.ForLLM)
|
output = fmt.Sprintf("Scheduled command '%s' executed:\n%s", job.Payload.Command, result.ForLLM)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.msgBus.PublishOutbound(bus.OutboundMessage{
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
Content: output,
|
Content: output,
|
||||||
|
|
@ -306,7 +308,9 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
|
||||||
|
|
||||||
// If deliver=true, send message directly without agent processing
|
// If deliver=true, send message directly without agent processing
|
||||||
if job.Payload.Deliver {
|
if job.Payload.Deliver {
|
||||||
t.msgBus.PublishOutbound(bus.OutboundMessage{
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
Content: job.Payload.Message,
|
Content: job.Payload.Message,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@ type ToolResult struct {
|
||||||
// Err is the underlying error (not JSON serialized).
|
// Err is the underlying error (not JSON serialized).
|
||||||
// Used for internal error handling and logging.
|
// Used for internal error handling and logging.
|
||||||
Err error `json:"-"`
|
Err error `json:"-"`
|
||||||
|
|
||||||
|
// Media contains media store refs produced by this tool.
|
||||||
|
// When non-empty, the agent will publish these as OutboundMediaMessage.
|
||||||
|
Media []string `json:"media,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewToolResult creates a basic ToolResult with content for the LLM.
|
// NewToolResult creates a basic ToolResult with content for the LLM.
|
||||||
|
|
@ -120,6 +124,19 @@ func UserResult(content string) *ToolResult {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MediaResult creates a ToolResult with media refs for the user.
|
||||||
|
// The agent will publish these refs as OutboundMediaMessage.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// result := MediaResult("Image generated successfully", []string{"media://abc123"})
|
||||||
|
func MediaResult(forLLM string, mediaRefs []string) *ToolResult {
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: forLLM,
|
||||||
|
Media: mediaRefs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MarshalJSON implements custom JSON serialization.
|
// MarshalJSON implements custom JSON serialization.
|
||||||
// The Err field is excluded from JSON output via the json:"-" tag.
|
// The Err field is excluded from JSON output via the json:"-" tag.
|
||||||
func (tr *ToolResult) MarshalJSON() ([]byte, error) {
|
func (tr *ToolResult) MarshalJSON() ([]byte, error) {
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,9 @@ After completing the task, provide a clear summary of what was done.`
|
||||||
// Send announce message back to main agent
|
// Send announce message back to main agent
|
||||||
if sm.bus != nil {
|
if sm.bus != nil {
|
||||||
announceContent := fmt.Sprintf("Task '%s' completed.\n\nResult:\n%s", task.Label, task.Result)
|
announceContent := fmt.Sprintf("Task '%s' completed.\n\nResult:\n%s", task.Label, task.Result)
|
||||||
sm.bus.PublishInbound(bus.InboundMessage{
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
sm.bus.PublishInbound(pubCtx, bus.InboundMessage{
|
||||||
Channel: "system",
|
Channel: "system",
|
||||||
SenderID: fmt.Sprintf("subagent:%s", task.ID),
|
SenderID: fmt.Sprintf("subagent:%s", task.ID),
|
||||||
// Format: "original_channel:original_chat_id" for routing back
|
// Format: "original_channel:original_chat_id" for routing back
|
||||||
|
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SplitMessage splits long messages into chunks, preserving code block integrity.
|
|
||||||
// The function reserves a buffer (10% of maxLen, min 50) to leave room for closing code blocks,
|
|
||||||
// but may extend to maxLen when needed.
|
|
||||||
// Call SplitMessage with the full text content and the maximum allowed length of a single message;
|
|
||||||
// it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks.
|
|
||||||
func SplitMessage(content string, maxLen int) []string {
|
|
||||||
var messages []string
|
|
||||||
|
|
||||||
// Dynamic buffer: 10% of maxLen, but at least 50 chars if possible
|
|
||||||
codeBlockBuffer := max(maxLen/10, 50)
|
|
||||||
if codeBlockBuffer > maxLen/2 {
|
|
||||||
codeBlockBuffer = maxLen / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
for len(content) > 0 {
|
|
||||||
if len(content) <= maxLen {
|
|
||||||
messages = append(messages, content)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Effective split point: maxLen minus buffer, to leave room for code blocks
|
|
||||||
effectiveLimit := max(maxLen-codeBlockBuffer, maxLen/2)
|
|
||||||
|
|
||||||
// Find natural split point within the effective limit
|
|
||||||
msgEnd := findLastNewline(content[:effectiveLimit], 200)
|
|
||||||
if msgEnd <= 0 {
|
|
||||||
msgEnd = findLastSpace(content[:effectiveLimit], 100)
|
|
||||||
}
|
|
||||||
if msgEnd <= 0 {
|
|
||||||
msgEnd = effectiveLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this would end with an incomplete code block
|
|
||||||
candidate := content[:msgEnd]
|
|
||||||
unclosedIdx := findLastUnclosedCodeBlock(candidate)
|
|
||||||
|
|
||||||
if unclosedIdx >= 0 {
|
|
||||||
// Message would end with incomplete code block
|
|
||||||
// Try to extend up to maxLen to include the closing ```
|
|
||||||
if len(content) > msgEnd {
|
|
||||||
closingIdx := findNextClosingCodeBlock(content, msgEnd)
|
|
||||||
if closingIdx > 0 && closingIdx <= maxLen {
|
|
||||||
// Extend to include the closing ```
|
|
||||||
msgEnd = closingIdx
|
|
||||||
} else {
|
|
||||||
// Code block is too long to fit in one chunk or missing closing fence.
|
|
||||||
// Try to split inside by injecting closing and reopening fences.
|
|
||||||
headerEnd := strings.Index(content[unclosedIdx:], "\n")
|
|
||||||
if headerEnd == -1 {
|
|
||||||
headerEnd = unclosedIdx + 3
|
|
||||||
} else {
|
|
||||||
headerEnd += unclosedIdx
|
|
||||||
}
|
|
||||||
header := strings.TrimSpace(content[unclosedIdx:headerEnd])
|
|
||||||
|
|
||||||
// If we have a reasonable amount of content after the header, split inside
|
|
||||||
if msgEnd > headerEnd+20 {
|
|
||||||
// Find a better split point closer to maxLen
|
|
||||||
innerLimit := maxLen - 5 // Leave room for "\n```"
|
|
||||||
betterEnd := findLastNewline(content[:innerLimit], 200)
|
|
||||||
if betterEnd > headerEnd {
|
|
||||||
msgEnd = betterEnd
|
|
||||||
} else {
|
|
||||||
msgEnd = innerLimit
|
|
||||||
}
|
|
||||||
messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```")
|
|
||||||
content = strings.TrimSpace(header + "\n" + content[msgEnd:])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, try to split before the code block starts
|
|
||||||
newEnd := findLastNewline(content[:unclosedIdx], 200)
|
|
||||||
if newEnd <= 0 {
|
|
||||||
newEnd = findLastSpace(content[:unclosedIdx], 100)
|
|
||||||
}
|
|
||||||
if newEnd > 0 {
|
|
||||||
msgEnd = newEnd
|
|
||||||
} else {
|
|
||||||
// If we can't split before, we MUST split inside (last resort)
|
|
||||||
if unclosedIdx > 20 {
|
|
||||||
msgEnd = unclosedIdx
|
|
||||||
} else {
|
|
||||||
msgEnd = maxLen - 5
|
|
||||||
messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```")
|
|
||||||
content = strings.TrimSpace(header + "\n" + content[msgEnd:])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if msgEnd <= 0 {
|
|
||||||
msgEnd = effectiveLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
messages = append(messages, content[:msgEnd])
|
|
||||||
content = strings.TrimSpace(content[msgEnd:])
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages
|
|
||||||
}
|
|
||||||
|
|
||||||
// findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ```
|
|
||||||
// Returns the position of the opening ``` or -1 if all code blocks are complete
|
|
||||||
func findLastUnclosedCodeBlock(text string) int {
|
|
||||||
inCodeBlock := false
|
|
||||||
lastOpenIdx := -1
|
|
||||||
|
|
||||||
for i := 0; i < len(text); i++ {
|
|
||||||
if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
|
|
||||||
// Toggle code block state on each fence
|
|
||||||
if !inCodeBlock {
|
|
||||||
// Entering a code block: record this opening fence
|
|
||||||
lastOpenIdx = i
|
|
||||||
}
|
|
||||||
inCodeBlock = !inCodeBlock
|
|
||||||
i += 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if inCodeBlock {
|
|
||||||
return lastOpenIdx
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// findNextClosingCodeBlock finds the next closing ``` starting from a position
|
|
||||||
// Returns the position after the closing ``` or -1 if not found
|
|
||||||
func findNextClosingCodeBlock(text string, startIdx int) int {
|
|
||||||
for i := startIdx; i < len(text); i++ {
|
|
||||||
if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
|
|
||||||
return i + 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// findLastNewline finds the last newline character within the last N characters
|
|
||||||
// Returns the position of the newline or -1 if not found
|
|
||||||
func findLastNewline(s string, searchWindow int) int {
|
|
||||||
searchStart := max(len(s)-searchWindow, 0)
|
|
||||||
for i := len(s) - 1; i >= searchStart; i-- {
|
|
||||||
if s[i] == '\n' {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// findLastSpace finds the last space character within the last N characters
|
|
||||||
// Returns the position of the space or -1 if not found
|
|
||||||
func findLastSpace(s string, searchWindow int) int {
|
|
||||||
searchStart := max(len(s)-searchWindow, 0)
|
|
||||||
for i := len(s) - 1; i >= searchStart; i-- {
|
|
||||||
if s[i] == ' ' || s[i] == '\t' {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSplitMessage(t *testing.T) {
|
|
||||||
longText := strings.Repeat("a", 2500)
|
|
||||||
longCode := "```go\n" + strings.Repeat("fmt.Println(\"hello\")\n", 100) + "```" // ~2100 chars
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
content string
|
|
||||||
maxLen int
|
|
||||||
expectChunks int // Check number of chunks
|
|
||||||
checkContent func(t *testing.T, chunks []string) // Custom validation
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Empty message",
|
|
||||||
content: "",
|
|
||||||
maxLen: 2000,
|
|
||||||
expectChunks: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Short message fits in one chunk",
|
|
||||||
content: "Hello world",
|
|
||||||
maxLen: 2000,
|
|
||||||
expectChunks: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Simple split regular text",
|
|
||||||
content: longText,
|
|
||||||
maxLen: 2000,
|
|
||||||
expectChunks: 2,
|
|
||||||
checkContent: func(t *testing.T, chunks []string) {
|
|
||||||
if len(chunks[0]) > 2000 {
|
|
||||||
t.Errorf("Chunk 0 too large: %d", len(chunks[0]))
|
|
||||||
}
|
|
||||||
if len(chunks[0])+len(chunks[1]) != len(longText) {
|
|
||||||
t.Errorf("Total length mismatch. Got %d, want %d", len(chunks[0])+len(chunks[1]), len(longText))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Split at newline",
|
|
||||||
// 1750 chars then newline, then more chars.
|
|
||||||
// Dynamic buffer: 2000 / 10 = 200.
|
|
||||||
// Effective limit: 2000 - 200 = 1800.
|
|
||||||
// Split should happen at newline because it's at 1750 (< 1800).
|
|
||||||
// Total length must > 2000 to trigger split. 1750 + 1 + 300 = 2051.
|
|
||||||
content: strings.Repeat("a", 1750) + "\n" + strings.Repeat("b", 300),
|
|
||||||
maxLen: 2000,
|
|
||||||
expectChunks: 2,
|
|
||||||
checkContent: func(t *testing.T, chunks []string) {
|
|
||||||
if len(chunks[0]) != 1750 {
|
|
||||||
t.Errorf("Expected chunk 0 to be 1750 length (split at newline), got %d", len(chunks[0]))
|
|
||||||
}
|
|
||||||
if chunks[1] != strings.Repeat("b", 300) {
|
|
||||||
t.Errorf("Chunk 1 content mismatch. Len: %d", len(chunks[1]))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Long code block split",
|
|
||||||
content: "Prefix\n" + longCode,
|
|
||||||
maxLen: 2000,
|
|
||||||
expectChunks: 2,
|
|
||||||
checkContent: func(t *testing.T, chunks []string) {
|
|
||||||
// Check that first chunk ends with closing fence
|
|
||||||
if !strings.HasSuffix(chunks[0], "\n```") {
|
|
||||||
t.Error("First chunk should end with injected closing fence")
|
|
||||||
}
|
|
||||||
// Check that second chunk starts with execution header
|
|
||||||
if !strings.HasPrefix(chunks[1], "```go") {
|
|
||||||
t.Error("Second chunk should start with injected code block header")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Preserve Unicode characters",
|
|
||||||
content: strings.Repeat("\u4e16", 1000), // 3000 bytes
|
|
||||||
maxLen: 2000,
|
|
||||||
expectChunks: 2,
|
|
||||||
checkContent: func(t *testing.T, chunks []string) {
|
|
||||||
// Just verify we didn't panic and got valid strings.
|
|
||||||
// Go strings are UTF-8, if we split mid-rune it would be bad,
|
|
||||||
// but standard slicing might do that.
|
|
||||||
// Let's assume standard behavior is acceptable or check if it produces invalid rune?
|
|
||||||
if !strings.Contains(chunks[0], "\u4e16") {
|
|
||||||
t.Error("Chunk should contain unicode characters")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
got := SplitMessage(tc.content, tc.maxLen)
|
|
||||||
|
|
||||||
if tc.expectChunks == 0 {
|
|
||||||
if len(got) != 0 {
|
|
||||||
t.Errorf("Expected 0 chunks, got %d", len(got))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(got) != tc.expectChunks {
|
|
||||||
t.Errorf("Expected %d chunks, got %d", tc.expectChunks, len(got))
|
|
||||||
// Log sizes for debugging
|
|
||||||
for i, c := range got {
|
|
||||||
t.Logf("Chunk %d length: %d", i, len(c))
|
|
||||||
}
|
|
||||||
return // Stop further checks if count assumes specific split
|
|
||||||
}
|
|
||||||
|
|
||||||
if tc.checkContent != nil {
|
|
||||||
tc.checkContent(t, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSplitMessage_CodeBlockIntegrity(t *testing.T) {
|
|
||||||
// Focused test for the core requirement: splitting inside a code block preserves syntax highlighting
|
|
||||||
|
|
||||||
// 60 chars total approximately
|
|
||||||
content := "```go\npackage main\n\nfunc main() {\n\tprintln(\"Hello\")\n}\n```"
|
|
||||||
maxLen := 40
|
|
||||||
|
|
||||||
chunks := SplitMessage(content, maxLen)
|
|
||||||
|
|
||||||
if len(chunks) != 2 {
|
|
||||||
t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks)
|
|
||||||
}
|
|
||||||
|
|
||||||
// First chunk must end with "\n```"
|
|
||||||
if !strings.HasSuffix(chunks[0], "\n```") {
|
|
||||||
t.Errorf("First chunk should end with closing fence. Got: %q", chunks[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second chunk must start with the header "```go"
|
|
||||||
if !strings.HasPrefix(chunks[1], "```go") {
|
|
||||||
t.Errorf("Second chunk should start with code block header. Got: %q", chunks[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
// First chunk should contain meaningful content
|
|
||||||
if len(chunks[0]) > 40 {
|
|
||||||
t.Errorf("First chunk exceeded maxLen: length %d", len(chunks[0]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,31 @@
|
||||||
package utils
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SanitizeMessageContent removes Unicode control characters, format characters (RTL overrides,
|
||||||
|
// zero-width characters), and other non-graphic characters that could confuse an LLM
|
||||||
|
// or cause display issues in the agent UI.
|
||||||
|
func SanitizeMessageContent(input string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
// Pre-allocate memory to avoid multiple allocations
|
||||||
|
sb.Grow(len(input))
|
||||||
|
|
||||||
|
for _, r := range input {
|
||||||
|
// unicode.IsGraphic returns true if the rune is a Unicode graphic character.
|
||||||
|
// This includes letters, marks, numbers, punctuation, and symbols.
|
||||||
|
// It excludes control characters (Cc), format characters (Cf),
|
||||||
|
// surrogates (Cs), and private use (Co).
|
||||||
|
if unicode.IsGraphic(r) || r == '\n' || r == '\r' || r == '\t' {
|
||||||
|
sb.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
// Truncate returns a truncated version of s with at most maxLen runes.
|
// Truncate returns a truncated version of s with at most maxLen runes.
|
||||||
// Handles multi-byte Unicode characters properly.
|
// Handles multi-byte Unicode characters properly.
|
||||||
// If the string is truncated, "..." is appended to indicate truncation.
|
// If the string is truncated, "..." is appended to indicate truncation.
|
||||||
|
|
|
||||||
|
|
@ -104,3 +104,27 @@ func TestTruncate(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSanitizeMessageContent(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"empty", "", ""},
|
||||||
|
{"plain text unchanged", "Hello world", "Hello world"},
|
||||||
|
{"strip ZWSP", "Hello\u200bworld", "Helloworld"},
|
||||||
|
{"strip RTL override", "Hi\u202eevil", "Hievil"},
|
||||||
|
{"strip BOM", "\uFEFFcontent", "content"},
|
||||||
|
{"strip multiple", "a\u200c\u202ab\u202cc", "abc"},
|
||||||
|
{"unicode letters preserved", "café \u65e5\u672c\u8a9e", "café \u65e5\u672c\u8a9e"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := SanitizeMessageContent(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("SanitizeMessageContent(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue