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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user