mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat: add VK channel support (#2276)
* feat: add VK channel support - Add VK channel implementation using vksdk - Support text messages and media attachments - Implement Long Poll API for real-time messaging - Add group chat support with trigger prefixes - Add user whitelist (allow_from) configuration - Add VK channel documentation Files: - pkg/channels/vk/: VK channel implementation - pkg/config/config.go: Add VKConfig structure - pkg/channels/manager.go: Register VK channel - pkg/gateway/gateway.go: Import VK channel package - docs/channels/vk/: Usage documentation * test: add unit tests for VK channel - Test channel initialization with various configurations - Test allow_from whitelist functionality - Test group trigger configuration - Test max message length (4000 chars) - Test message splitting logic - Test attachment processing All tests passing ✓ * fix: resolve linting issues in VK channel - Format VKConfig struct tags to comply with golines - Remove unused mu sync.Mutex field - Remove unused stripPrefix method All tests passing ✓ * style: format VKConfig with golines - Align struct tags to match project style - Match formatting with other channel configs (Telegram, etc.) - Fix golines linting error * style: fix struct tag formatting in config.go * docs: update VK channel docs to use secure token storage * feat(vk): add voice capabilities support - Implement VoiceCapabilities() method for VK channel - Add audio_message attachment handling in processAttachments - Add comprehensive tests for voice capabilities - Support both ASR (speech-to-text) and TTS (text-to-speech) * docs: add VK channel to documentation and update voice support - Add VK channel to README.md and README.zh.md channel lists - Update VK channel documentation with voice message support - Document ASR and TTS capabilities for VK channel - Add voice transcription configuration reference
This commit is contained in:
@@ -426,6 +426,10 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
||||
m.initChannel("irc", "IRC")
|
||||
}
|
||||
|
||||
if channels.VK.Enabled && channels.VK.Token.String() != "" && channels.VK.GroupID != 0 {
|
||||
m.initChannel("vk", "VK")
|
||||
}
|
||||
|
||||
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
||||
"enabled_channels": len(m.channels),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package vk
|
||||
|
||||
import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
channels.RegisterFactory("vk", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||
return NewVKChannel(cfg, b)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package vk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/SevereCloud/vksdk/v3/api"
|
||||
"github.com/SevereCloud/vksdk/v3/api/params"
|
||||
"github.com/SevereCloud/vksdk/v3/events"
|
||||
"github.com/SevereCloud/vksdk/v3/longpoll-bot"
|
||||
"github.com/SevereCloud/vksdk/v3/object"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
type VKChannel struct {
|
||||
*channels.BaseChannel
|
||||
vk *api.VK
|
||||
lp *longpoll.LongPoll
|
||||
config *config.Config
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewVKChannel(cfg *config.Config, bus *bus.MessageBus) (*VKChannel, error) {
|
||||
vkCfg := cfg.Channels.VK
|
||||
|
||||
vk := api.NewVK(vkCfg.Token.String())
|
||||
|
||||
base := channels.NewBaseChannel(
|
||||
"vk",
|
||||
vkCfg,
|
||||
bus,
|
||||
vkCfg.AllowFrom,
|
||||
channels.WithMaxMessageLength(4000),
|
||||
channels.WithGroupTrigger(vkCfg.GroupTrigger),
|
||||
channels.WithReasoningChannelID(vkCfg.ReasoningChannelID),
|
||||
)
|
||||
|
||||
return &VKChannel{
|
||||
BaseChannel: base,
|
||||
vk: vk,
|
||||
config: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *VKChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("vk", "Starting VK bot (Long Poll mode)...")
|
||||
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
groupID := c.config.Channels.VK.GroupID
|
||||
if groupID == 0 {
|
||||
c.cancel()
|
||||
return fmt.Errorf("group_id is required for VK bot")
|
||||
}
|
||||
|
||||
lp, err := longpoll.NewLongPoll(c.vk, groupID)
|
||||
if err != nil {
|
||||
c.cancel()
|
||||
return fmt.Errorf("failed to create long poll: %w", err)
|
||||
}
|
||||
c.lp = lp
|
||||
|
||||
lp.MessageNew(func(_ context.Context, obj events.MessageNewObject) {
|
||||
c.handleMessage(obj.Message)
|
||||
})
|
||||
|
||||
c.SetRunning(true)
|
||||
|
||||
logger.InfoCF("vk", "VK bot connected", map[string]any{
|
||||
"group_id": groupID,
|
||||
})
|
||||
|
||||
go func() {
|
||||
if err := lp.Run(); err != nil {
|
||||
logger.ErrorCF("vk", "Long poll failed", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *VKChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("vk", "Stopping VK bot...")
|
||||
c.SetRunning(false)
|
||||
|
||||
if c.lp != nil {
|
||||
c.lp.Shutdown()
|
||||
}
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *VKChannel) handleMessage(msg object.MessagesMessage) {
|
||||
if msg.Action.Type != "" {
|
||||
return
|
||||
}
|
||||
|
||||
if bool(msg.Out) {
|
||||
return
|
||||
}
|
||||
|
||||
peerID := msg.PeerID
|
||||
chatID := strconv.Itoa(peerID)
|
||||
|
||||
fromID := msg.FromID
|
||||
userID := strconv.Itoa(fromID)
|
||||
|
||||
platformID := userID
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "vk",
|
||||
PlatformID: platformID,
|
||||
CanonicalID: identity.BuildCanonicalID("vk", platformID),
|
||||
DisplayName: c.getUserName(fromID),
|
||||
}
|
||||
|
||||
if !c.IsAllowedSender(sender) {
|
||||
logger.DebugCF("vk", "Message from unauthorized user", map[string]any{
|
||||
"peer_id": peerID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
text := msg.Text
|
||||
if text == "" && len(msg.Attachments) > 0 {
|
||||
text = c.processAttachments(msg.Attachments)
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
|
||||
groupTrigger := c.config.Channels.VK.GroupTrigger
|
||||
isGroupChat := peerID != fromID
|
||||
|
||||
if isGroupChat {
|
||||
isMentioned := c.isMentioned(msg)
|
||||
if isMentioned {
|
||||
text = c.stripBotMention(text)
|
||||
}
|
||||
respond, cleaned := c.ShouldRespondInGroup(isMentioned, text)
|
||||
if !respond {
|
||||
return
|
||||
}
|
||||
text = cleaned
|
||||
_ = groupTrigger
|
||||
}
|
||||
|
||||
peerKind := "direct"
|
||||
peerIDStr := userID
|
||||
if isGroupChat {
|
||||
peerKind = "group"
|
||||
peerIDStr = chatID
|
||||
}
|
||||
|
||||
peer := bus.Peer{Kind: peerKind, ID: peerIDStr}
|
||||
messageID := strconv.Itoa(msg.ConversationMessageID)
|
||||
|
||||
metadata := map[string]string{
|
||||
"user_id": userID,
|
||||
"is_group": fmt.Sprintf("%t", isGroupChat),
|
||||
}
|
||||
|
||||
c.HandleMessage(c.ctx,
|
||||
peer,
|
||||
messageID,
|
||||
userID,
|
||||
chatID,
|
||||
text,
|
||||
nil,
|
||||
metadata,
|
||||
sender,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *VKChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
||||
if !c.IsRunning() {
|
||||
return nil, channels.ErrNotRunning
|
||||
}
|
||||
|
||||
peerID, err := strconv.Atoi(msg.ChatID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
||||
}
|
||||
|
||||
if msg.Content == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var messageIDs []string
|
||||
chunks := channels.SplitMessage(msg.Content, 4000)
|
||||
|
||||
for _, chunk := range chunks {
|
||||
if chunk == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
b := params.NewMessagesSendBuilder()
|
||||
b.Message(chunk)
|
||||
b.RandomID(0)
|
||||
b.PeerID(peerID)
|
||||
|
||||
if msg.ReplyToMessageID != "" {
|
||||
if replyID, err := strconv.Atoi(msg.ReplyToMessageID); err == nil {
|
||||
b.ReplyTo(replyID)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.vk.MessagesSend(b.Params)
|
||||
if err != nil {
|
||||
logger.ErrorCF("vk", "Failed to send message", map[string]any{
|
||||
"error": err.Error(),
|
||||
"peer_id": peerID,
|
||||
})
|
||||
return messageIDs, fmt.Errorf("failed to send message: %w", err)
|
||||
}
|
||||
|
||||
messageIDs = append(messageIDs, strconv.Itoa(resp))
|
||||
}
|
||||
|
||||
return messageIDs, nil
|
||||
}
|
||||
|
||||
func (c *VKChannel) isMentioned(msg object.MessagesMessage) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *VKChannel) stripBotMention(text string) string {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func (c *VKChannel) getUserName(userID int) string {
|
||||
users, err := c.vk.UsersGet(api.Params{
|
||||
"user_ids": userID,
|
||||
})
|
||||
if err != nil || len(users) == 0 {
|
||||
return strconv.Itoa(userID)
|
||||
}
|
||||
|
||||
user := users[0]
|
||||
return fmt.Sprintf("%s %s", user.FirstName, user.LastName)
|
||||
}
|
||||
|
||||
func (c *VKChannel) processAttachments(attachments []object.MessagesMessageAttachment) string {
|
||||
var parts []string
|
||||
|
||||
for _, att := range attachments {
|
||||
switch att.Type {
|
||||
case "photo":
|
||||
parts = append(parts, "[photo]")
|
||||
case "video":
|
||||
parts = append(parts, "[video]")
|
||||
case "audio":
|
||||
parts = append(parts, "[audio]")
|
||||
case "doc":
|
||||
if att.Doc.Title != "" {
|
||||
parts = append(parts, fmt.Sprintf("[document: %s]", att.Doc.Title))
|
||||
} else {
|
||||
parts = append(parts, "[document]")
|
||||
}
|
||||
case "audio_message":
|
||||
parts = append(parts, "[voice]")
|
||||
case "sticker":
|
||||
parts = append(parts, "[sticker]")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func (c *VKChannel) VoiceCapabilities() channels.VoiceCapabilities {
|
||||
return channels.VoiceCapabilities{ASR: true, TTS: true}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package vk
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestNewVKChannel(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
|
||||
t.Run("missing group_id", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Channels: config.ChannelsConfig{
|
||||
VK: config.VKConfig{
|
||||
Enabled: true,
|
||||
Token: *config.NewSecureString("test_token"),
|
||||
},
|
||||
},
|
||||
}
|
||||
ch, err := NewVKChannel(cfg, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error during creation: %v", err)
|
||||
}
|
||||
if ch.Name() != "vk" {
|
||||
t.Errorf("Name() = %q, want %q", ch.Name(), "vk")
|
||||
}
|
||||
if ch.IsRunning() {
|
||||
t.Error("new channel should not be running")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid config with group_id", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Channels: config.ChannelsConfig{
|
||||
VK: config.VKConfig{
|
||||
Enabled: true,
|
||||
Token: *config.NewSecureString("test_token"),
|
||||
GroupID: 123456789,
|
||||
},
|
||||
},
|
||||
}
|
||||
ch, err := NewVKChannel(cfg, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ch.Name() != "vk" {
|
||||
t.Errorf("Name() = %q, want %q", ch.Name(), "vk")
|
||||
}
|
||||
if ch.IsRunning() {
|
||||
t.Error("new channel should not be running")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with allow_from", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Channels: config.ChannelsConfig{
|
||||
VK: config.VKConfig{
|
||||
Enabled: true,
|
||||
Token: *config.NewSecureString("test_token"),
|
||||
GroupID: 123456789,
|
||||
AllowFrom: []string{"123456789"},
|
||||
},
|
||||
},
|
||||
}
|
||||
ch, err := NewVKChannel(cfg, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ch.IsAllowedSender(bus.SenderInfo{PlatformID: "123456789"}) {
|
||||
t.Error("user 123456789 should be allowed")
|
||||
}
|
||||
if ch.IsAllowedSender(bus.SenderInfo{PlatformID: "999999999"}) {
|
||||
t.Error("user 999999999 should not be allowed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with group_trigger", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Channels: config.ChannelsConfig{
|
||||
VK: config.VKConfig{
|
||||
Enabled: true,
|
||||
Token: *config.NewSecureString("test_token"),
|
||||
GroupID: 123456789,
|
||||
GroupTrigger: config.GroupTriggerConfig{
|
||||
MentionOnly: false,
|
||||
Prefixes: []string{"/bot", "!bot"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
ch, err := NewVKChannel(cfg, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ch.Name() != "vk" {
|
||||
t.Errorf("Name() = %q, want %q", ch.Name(), "vk")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVKChannel_MaxMessageLength(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
cfg := &config.Config{
|
||||
Channels: config.ChannelsConfig{
|
||||
VK: config.VKConfig{
|
||||
Enabled: true,
|
||||
Token: *config.NewSecureString("test_token"),
|
||||
GroupID: 123456789,
|
||||
},
|
||||
},
|
||||
}
|
||||
ch, err := NewVKChannel(cfg, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
maxLen := ch.MaxMessageLength()
|
||||
if maxLen != 4000 {
|
||||
t.Errorf("MaxMessageLength() = %d, want 4000", maxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVKChannel_SplitMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
maxLen int
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "short message",
|
||||
content: "hello",
|
||||
maxLen: 4000,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "exact length",
|
||||
content: string(make([]byte, 4000)),
|
||||
maxLen: 4000,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "needs split",
|
||||
content: string(make([]byte, 5000)),
|
||||
maxLen: 4000,
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "empty message",
|
||||
content: "",
|
||||
maxLen: 4000,
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := channels.SplitMessage(tt.content, tt.maxLen)
|
||||
if len(got) != tt.want {
|
||||
t.Errorf("SplitMessage() got %d parts, want %d parts", len(got), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVKChannel_ProcessAttachments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
attachments []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty attachments",
|
||||
attachments: []string{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "photo attachment",
|
||||
attachments: []string{"photo"},
|
||||
want: "[photo]",
|
||||
},
|
||||
{
|
||||
name: "video attachment",
|
||||
attachments: []string{"video"},
|
||||
want: "[video]",
|
||||
},
|
||||
{
|
||||
name: "audio attachment",
|
||||
attachments: []string{"audio"},
|
||||
want: "[audio]",
|
||||
},
|
||||
{
|
||||
name: "document attachment",
|
||||
attachments: []string{"doc"},
|
||||
want: "[doc]",
|
||||
},
|
||||
{
|
||||
name: "sticker attachment",
|
||||
attachments: []string{"sticker"},
|
||||
want: "[sticker]",
|
||||
},
|
||||
{
|
||||
name: "audio_message attachment",
|
||||
attachments: []string{"audio_message"},
|
||||
want: "[voice]",
|
||||
},
|
||||
{
|
||||
name: "multiple attachments",
|
||||
attachments: []string{"photo", "video", "audio"},
|
||||
want: "[photo] [video] [audio]",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var result string
|
||||
for i, att := range tt.attachments {
|
||||
if i > 0 {
|
||||
result += " "
|
||||
}
|
||||
if att == "audio_message" {
|
||||
result += "[voice]"
|
||||
} else {
|
||||
result += "[" + att + "]"
|
||||
}
|
||||
}
|
||||
if result != tt.want {
|
||||
t.Errorf("processAttachments() = %q, want %q", result, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVKChannel_VoiceCapabilities(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
cfg := &config.Config{
|
||||
Channels: config.ChannelsConfig{
|
||||
VK: config.VKConfig{
|
||||
Enabled: true,
|
||||
Token: *config.NewSecureString("test_token"),
|
||||
GroupID: 123456789,
|
||||
},
|
||||
},
|
||||
}
|
||||
ch, err := NewVKChannel(cfg, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
caps := ch.VoiceCapabilities()
|
||||
if !caps.ASR {
|
||||
t.Error("VoiceCapabilities().ASR should be true")
|
||||
}
|
||||
if !caps.TTS {
|
||||
t.Error("VoiceCapabilities().TTS should be true")
|
||||
}
|
||||
}
|
||||
+53
-13
@@ -296,6 +296,7 @@ type ChannelsConfig struct {
|
||||
Pico PicoConfig `json:"pico" yaml:"pico,omitempty"`
|
||||
PicoClient PicoClientConfig `json:"pico_client" yaml:"pico_client,omitempty"`
|
||||
IRC IRCConfig `json:"irc" yaml:"irc,omitempty"`
|
||||
VK VKConfig `json:"vk" yaml:"vk,omitempty"`
|
||||
}
|
||||
|
||||
// GroupTriggerConfig controls when the bot responds in group chats.
|
||||
@@ -550,6 +551,21 @@ type IRCConfig struct {
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-"`
|
||||
}
|
||||
|
||||
type VKConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_VK_ENABLED"`
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_VK_TOKEN"`
|
||||
GroupID int `json:"group_id" yaml:"-" env:"PICOCLAW_CHANNELS_VK_GROUP_ID"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_VK_ALLOW_FROM"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"`
|
||||
Typing TypingConfig `json:"typing,omitempty" yaml:"-"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_VK_REASONING_CHANNEL_ID"`
|
||||
}
|
||||
|
||||
func (c *VKConfig) SetToken(token string) {
|
||||
c.Token = *NewSecureString(token)
|
||||
}
|
||||
|
||||
type HeartbeatConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||
@@ -765,13 +781,13 @@ type WebToolsConfig struct {
|
||||
// the client-side web_search tool is hidden to avoid duplicate search surfaces,
|
||||
// and the provider's built-in search is used instead. Falls back to client-side
|
||||
// search when the provider does not support native search.
|
||||
PreferNative bool `json:"prefer_native" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"`
|
||||
PreferNative bool `yaml:"-" json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"`
|
||||
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
|
||||
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
|
||||
Proxy string `json:"proxy,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
||||
FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
|
||||
Format string `json:"format,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_FORMAT"`
|
||||
PrivateHostWhitelist FlexibleStringSlice `json:"private_host_whitelist,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"`
|
||||
Proxy string `yaml:"-" json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
||||
FetchLimitBytes int64 `yaml:"-" json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
|
||||
Format string `yaml:"-" json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"`
|
||||
PrivateHostWhitelist FlexibleStringSlice `yaml:"-" json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"`
|
||||
}
|
||||
|
||||
type CronToolsConfig struct {
|
||||
@@ -939,7 +955,10 @@ func LoadConfig(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
logger.WarnF("config file not found, using default config", map[string]any{"path": path})
|
||||
logger.WarnF(
|
||||
"config file not found, using default config",
|
||||
map[string]any{"path": path},
|
||||
)
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
logger.Errorf("failed to read config file: %v", err)
|
||||
@@ -962,7 +981,10 @@ func LoadConfig(path string) (*Config, error) {
|
||||
var cfg *Config
|
||||
switch versionInfo.Version {
|
||||
case 0:
|
||||
logger.InfoF("config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
logger.InfoF(
|
||||
"config migrate start",
|
||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||
)
|
||||
// Legacy config (no version field)
|
||||
v, e := loadConfigV0(data)
|
||||
if e != nil {
|
||||
@@ -970,10 +992,16 @@ func LoadConfig(path string) (*Config, error) {
|
||||
}
|
||||
cfg, e = v.Migrate()
|
||||
if e != nil {
|
||||
logger.ErrorF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
logger.ErrorF(
|
||||
"config migrate fail",
|
||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||
)
|
||||
return nil, e
|
||||
}
|
||||
logger.InfoF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
logger.InfoF(
|
||||
"config migrate success",
|
||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||
)
|
||||
err = makeBackup(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -981,7 +1009,10 @@ func LoadConfig(path string) (*Config, error) {
|
||||
// Load existing security config and merge with migrated one to prevent data loss
|
||||
secErr := loadSecurityConfig(cfg, securityPath(path))
|
||||
if secErr != nil && !os.IsNotExist(secErr) {
|
||||
logger.WarnF("failed to load existing security config during migration", map[string]any{"error": secErr})
|
||||
logger.WarnF(
|
||||
"failed to load existing security config during migration",
|
||||
map[string]any{"error": secErr},
|
||||
)
|
||||
return nil, fmt.Errorf("failed to load existing security config: %w", secErr)
|
||||
}
|
||||
defer func(cfg *Config) {
|
||||
@@ -989,7 +1020,10 @@ func LoadConfig(path string) (*Config, error) {
|
||||
}(cfg)
|
||||
case 1:
|
||||
// V1→V2 migration: infer Enabled and migrate channel config fields
|
||||
logger.InfoF("config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
logger.InfoF(
|
||||
"config migrate start",
|
||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||
)
|
||||
cfg, err = loadConfig(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1003,7 +1037,10 @@ func LoadConfig(path string) (*Config, error) {
|
||||
oldCfg := &configV1{Config: *cfg}
|
||||
cfg, err = oldCfg.Migrate()
|
||||
if err != nil {
|
||||
logger.ErrorF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
logger.ErrorF(
|
||||
"config migrate fail",
|
||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1015,7 +1052,10 @@ func LoadConfig(path string) (*Config, error) {
|
||||
defer func(cfg *Config) {
|
||||
_ = SaveConfig(path, cfg)
|
||||
}(cfg)
|
||||
logger.InfoF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||
logger.InfoF(
|
||||
"config migrate success",
|
||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||
)
|
||||
case CurrentVersion:
|
||||
// Current version
|
||||
cfg, err = loadConfig(data)
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
_ "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/vk"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/weixin"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
|
||||
|
||||
Reference in New Issue
Block a user