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:
linhaolin1 2026-04-03 10:56:26 +08:00 committed by GitHub
parent 849e37cf79
commit b5ce6209fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 824 additions and 15 deletions

View file

@ -454,7 +454,7 @@ For full provider configuration details, see [Providers & Models](docs/providers
## 💬 Channels (Chat Apps)
Talk to your PicoClaw through 17+ messaging platforms:
Talk to your PicoClaw through 18+ messaging platforms:
| Channel | Setup | Protocol | Docs |
|---------|-------|----------|------|
@ -469,6 +469,7 @@ Talk to your PicoClaw through 17+ messaging platforms:
| **Feishu / Lark** | Medium (App ID + Secret) | WebSocket/SDK | [Guide](docs/channels/feishu/README.md) |
| **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) |
| **WeCom** | Easy (QR login or manual) | WebSocket | [Guide](docs/channels/wecom/README.md) |
| **VK** | Easy (group token) | Long Poll | [Guide](docs/channels/vk/README.md) |
| **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/chat-apps.md#irc) |
| **OneBot** | Medium (WebSocket URL) | OneBot v11 | [Guide](docs/channels/onebot/README.md) |
| **MaixCam** | Easy (enable) | TCP socket | [Guide](docs/channels/maixcam/README.md) |

View file

@ -448,7 +448,7 @@ PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider使用 `协议/模
## 💬 Channels聊天应用
通过 17+ 消息平台与你的 PicoClaw 对话:
通过 18+ 消息平台与你的 PicoClaw 对话:
| Channel | 配置难度 | 协议 | 文档 |
|---------|----------|------|------|
@ -463,6 +463,7 @@ PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider使用 `协议/模
| **飞书 / Lark** | 中等App ID + Secret | WebSocket/SDK | [指南](docs/channels/feishu/README.zh.md) |
| **LINE** | 中等credentials + webhook | Webhook | [指南](docs/channels/line/README.zh.md) |
| **企业微信** | 简单(扫码登录或手动配置) | WebSocket | [指南](docs/channels/wecom/README.zh.md) |
| **VK** | 简单(群组 token | Long Poll | [指南](docs/channels/vk/README.md) |
| **IRC** | 中等server + nick | IRC 协议 | [指南](docs/zh/chat-apps.md#irc) |
| **OneBot** | 中等WebSocket URL | OneBot v11 | [指南](docs/channels/onebot/README.zh.md) |
| **MaixCam** | 简单(启用即可) | TCP socket | [指南](docs/channels/maixcam/README.zh.md) |

194
docs/channels/vk/README.md Normal file
View file

@ -0,0 +1,194 @@
# VK (VKontakte)
The VK channel uses Bots Long Poll API for bot-based communication with VK social network. It supports text messages, media attachments (photos, videos, audio, documents, stickers), and group chat interactions.
## Configuration
```json
{
"channels": {
"vk": {
"enabled": true,
"token": "NOT_HERE",
"group_id": 123456789,
"allow_from": ["123456789"],
"group_trigger": {
"mention_only": false,
"prefixes": ["/bot", "!bot"]
}
}
}
}
```
| Field | Type | Required | Description |
| ---------------- | ------ | -------- | ------------------------------------------------------------------ |
| enabled | bool | Yes | Whether to enable the VK channel |
| token | string | Yes | Set to `NOT_HERE` - token is stored securely (see Token Storage) |
| group_id | int | Yes | VK Community ID (Group ID) |
| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
| group_trigger | object | No | Configuration for group chat triggers |
### Token Storage
For security reasons, the VK access token should not be stored directly in the configuration file. Instead:
1. Set `token` to `"NOT_HERE"` in the configuration
2. Store the actual token using one of these methods:
- **Environment variable**: Set `PICOCLAW_CHANNELS_VK_TOKEN` environment variable
- **Secure storage**: Use PicoClaw's secure token storage mechanism
Example using environment variable:
```bash
export PICOCLAW_CHANNELS_VK_TOKEN="vk1.a.abc123..."
```
### Group Trigger Configuration
| Field | Type | Description |
| ------------ | -------- | ------------------------------------------------------------------ |
| mention_only | bool | Only respond when bot is mentioned in group chats |
| prefixes | []string | List of prefixes that trigger bot response in group chats |
## Setup
### 1. Create a VK Community
1. Go to [VK](https://vk.com) and log in
2. Create a new community or use an existing one
3. Note your Community ID (found in the community URL, e.g., `public123456789`)
### 2. Enable Messages
1. Go to your community page
2. Click "Manage" → "Messages" → "Community Messages"
3. Enable community messages
### 3. Create Access Token
1. Go to "Manage" → "API usage" → "Access tokens"
2. Click "Create token"
3. Select the following permissions:
- `messages` - Access to messages
- `photos` - Access to photos (optional)
- `docs` - Access to documents (optional)
4. Copy the generated access token
5. Store the token securely (see Token Storage section below)
### 4. Configure PicoClaw
1. Add the token to your PicoClaw configuration
2. Set the `group_id` to your community ID (numeric value)
3. (Optional) Configure `allow_from` to restrict which user IDs can interact
## Features
### Supported Message Types
- **Text messages**: Full support for text messages
- **Photos**: Photos are displayed as `[photo]` placeholder
- **Videos**: Videos are displayed as `[video]` placeholder
- **Audio**: Audio files are displayed as `[audio]` placeholder
- **Voice messages**: Voice messages are displayed as `[voice]` placeholder and support transcription
- **Documents**: Documents are displayed as `[document: filename]`
- **Stickers**: Stickers are displayed as `[sticker]` placeholder
### Voice Support
The VK channel supports both voice message reception and text-to-speech capabilities:
- **ASR (Automatic Speech Recognition)**: Voice messages can be transcribed to text using configured voice models
- **TTS (Text-to-Speech)**: Text responses can be converted to voice messages
To enable voice transcription, configure a voice model in your providers setup. See [Voice Transcription](../../providers.md#voice-transcription) for details.
### Group Chat Support
The VK channel supports group chats with configurable triggers:
- **Mention-only mode**: Bot only responds when mentioned
- **Prefix mode**: Bot responds to messages starting with specified prefixes
- **Permissive mode**: Bot responds to all messages (default)
### Message Length
VK has a maximum message length of 4000 characters. PicoClaw automatically splits longer messages into multiple parts.
## Example Configuration
### Basic Configuration
```json
{
"channels": {
"vk": {
"enabled": true,
"token": "NOT_HERE",
"group_id": 123456789
}
}
}
```
### With User Whitelist
```json
{
"channels": {
"vk": {
"enabled": true,
"token": "NOT_HERE",
"group_id": 123456789,
"allow_from": ["123456789", "987654321"]
}
}
}
```
### With Group Chat Triggers
```json
{
"channels": {
"vk": {
"enabled": true,
"token": "NOT_HERE",
"group_id": 123456789,
"group_trigger": {
"prefixes": ["/bot", "!bot"]
}
}
}
}
```
## Troubleshooting
### Bot Not Responding
1. Check that the access token is valid
2. Verify that the `group_id` is correct
3. Ensure the user ID is in `allow_from` if configured
4. Check PicoClaw logs for error messages
### Permission Errors
Make sure the access token has the necessary permissions:
- `messages` - Required for sending and receiving messages
- `photos` - Optional, for handling photo attachments
- `docs` - Optional, for handling document attachments
### Group Chat Issues
If the bot doesn't respond in group chats:
1. Check `group_trigger` configuration
2. Try using a prefix to trigger the bot
3. Check if the bot has permission to read group messages
## API Reference
The VK channel uses the [VK SDK for Go](https://github.com/SevereCloud/vksdk) library, which supports VK API version 5.199.
For more information about VK API, see:
- [VK API Documentation](https://dev.vk.com/en)
- [VK Bots Long Poll API](https://dev.vk.com/en/api/bots-long-poll/getting-started)

3
go.mod
View file

@ -5,6 +5,7 @@ go 1.25.8
require (
fyne.io/systray v1.12.0
github.com/BurntSushi/toml v1.6.0
github.com/SevereCloud/vksdk/v3 v3.3.1
github.com/adhocore/gronx v1.19.6
github.com/anthropics/anthropic-sdk-go v1.26.0
github.com/atotto/clipboard v0.1.4
@ -89,6 +90,8 @@ require (
github.com/segmentio/encoding v0.5.4 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
go.mau.fi/libsignal v0.2.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect

6
go.sum
View file

@ -9,6 +9,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
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/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg=
github.com/SevereCloud/vksdk/v3 v3.3.1/go.mod h1:c6WaA5aocUYsXfkcUbg2qy45V9M1VDcqHHmHIN14NAw=
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
@ -274,6 +276,10 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
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/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532 h1:gxFHYeUDGziRb0zXYEqBFohC+NJbIW9L0tddaXMWr2o=

View file

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

13
pkg/channels/vk/init.go Normal file
View file

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

286
pkg/channels/vk/vk.go Normal file
View file

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

260
pkg/channels/vk/vk_test.go Normal file
View file

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

View file

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

View file

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