From 4f9c35b145c7e3b41d5838a42574f66423b00c88 Mon Sep 17 00:00:00 2001 From: linhaolin1 Date: Thu, 2 Apr 2026 18:28:34 +0800 Subject: [PATCH] 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 --- docs/channels/vk/README.md | 169 +++++++++++++++++++++ go.mod | 5 +- go.sum | 6 + pkg/channels/manager.go | 4 + pkg/channels/vk/init.go | 13 ++ pkg/channels/vk/vk.go | 291 +++++++++++++++++++++++++++++++++++++ pkg/config/config.go | 16 ++ pkg/gateway/gateway.go | 1 + 8 files changed, 504 insertions(+), 1 deletion(-) create mode 100644 docs/channels/vk/README.md create mode 100644 pkg/channels/vk/init.go create mode 100644 pkg/channels/vk/vk.go diff --git a/docs/channels/vk/README.md b/docs/channels/vk/README.md new file mode 100644 index 000000000..dcc3ef4db --- /dev/null +++ b/docs/channels/vk/README.md @@ -0,0 +1,169 @@ +# 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": "your_vk_access_token", + "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 | VK Community Access Token | +| 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 | + +### 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 + +### 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 +- **Documents**: Documents are displayed as `[document: filename]` +- **Stickers**: Stickers are displayed as `[sticker]` placeholder + +### 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": "vk1.a.abc123...", + "group_id": 123456789 + } + } +} +``` + +### With User Whitelist + +```json +{ + "channels": { + "vk": { + "enabled": true, + "token": "vk1.a.abc123...", + "group_id": 123456789, + "allow_from": ["123456789", "987654321"] + } + } +} +``` + +### With Group Chat Triggers + +```json +{ + "channels": { + "vk": { + "enabled": true, + "token": "vk1.a.abc123...", + "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) diff --git a/go.mod b/go.mod index 5f311306e..b704320aa 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -87,6 +88,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 @@ -124,7 +127,7 @@ require ( golang.org/x/arch v0.24.0 // indirect golang.org/x/crypto v0.49.0 golang.org/x/net v0.52.0 - golang.org/x/sync v0.20.0 // indirect + golang.org/x/sync v0.20.0 golang.org/x/sys v0.42.0 ) diff --git a/go.sum b/go.sum index ca5dd0423..8e4dd7da2 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,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= @@ -277,6 +279,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= diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 5fbf35ebf..9a52d1b11 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -425,6 +425,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), }) diff --git a/pkg/channels/vk/init.go b/pkg/channels/vk/init.go new file mode 100644 index 000000000..6a5927a32 --- /dev/null +++ b/pkg/channels/vk/init.go @@ -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) + }) +} diff --git a/pkg/channels/vk/vk.go b/pkg/channels/vk/vk.go new file mode 100644 index 000000000..37900dbf5 --- /dev/null +++ b/pkg/channels/vk/vk.go @@ -0,0 +1,291 @@ +package vk + +import ( + "context" + "fmt" + "strconv" + "strings" + "sync" + + "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 + mu sync.Mutex +} + +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) stripPrefix(text string, prefixes []string) string { + for _, prefix := range prefixes { + if strings.HasPrefix(text, prefix) { + return strings.TrimSpace(strings.TrimPrefix(text, prefix)) + } + } + return 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 "sticker": + parts = append(parts, "[sticker]") + } + } + + return strings.Join(parts, " ") +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 7a11d1ab7..5f9842779 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -293,6 +293,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. @@ -547,6 +548,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 diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 8065a0795..509b5d37e 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -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"