Merge branch 'main' of https://github.com/sipeed/picoclaw into telegram_chunking
This commit is contained in:
commit
ca31e45346
12 changed files with 923 additions and 272 deletions
|
|
@ -623,6 +623,12 @@ func gatewayCmd() {
|
||||||
logger.InfoC("voice", "Groq transcription attached to Slack channel")
|
logger.InfoC("voice", "Groq transcription attached to Slack channel")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if onebotChannel, ok := channelManager.GetChannel("onebot"); ok {
|
||||||
|
if oc, ok := onebotChannel.(*channels.OneBotChannel); ok {
|
||||||
|
oc.SetTranscriber(transcriber)
|
||||||
|
logger.InfoC("voice", "Groq transcription attached to OneBot channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enabledChannels := channelManager.GetEnabledChannels()
|
enabledChannels := channelManager.GetEnabledChannels()
|
||||||
|
|
|
||||||
|
|
@ -602,6 +602,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
|
||||||
Name: tc.Name,
|
Name: tc.Name,
|
||||||
Arguments: string(argumentsJSON),
|
Arguments: string(argumentsJSON),
|
||||||
},
|
},
|
||||||
|
Name: tc.Name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
messages = append(messages, assistantMsg)
|
messages = append(messages, assistantMsg)
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
chunks := utils.SplitMessage(msg.Content, 1500) // Discord has a limit of 2000 characters per message, leave 500 for natural split e.g. code blocks
|
chunks := utils.SplitMessage(msg.Content, 2000) // Split messages into chunks, Discord length limit: 2000 chars
|
||||||
|
|
||||||
for _, chunk := range chunks {
|
for _, chunk := range chunks {
|
||||||
if err := c.sendChunk(ctx, channelID, chunk); err != nil {
|
if err := c.sendChunk(ctx, channelID, chunk); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
|
@ -14,20 +16,28 @@ import (
|
||||||
"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/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/voice"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OneBotChannel struct {
|
type OneBotChannel struct {
|
||||||
*BaseChannel
|
*BaseChannel
|
||||||
config config.OneBotConfig
|
config config.OneBotConfig
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
dedup map[string]struct{}
|
dedup map[string]struct{}
|
||||||
dedupRing []string
|
dedupRing []string
|
||||||
dedupIdx int
|
dedupIdx int
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
writeMu sync.Mutex
|
writeMu sync.Mutex
|
||||||
echoCounter int64
|
echoCounter int64
|
||||||
|
selfID int64
|
||||||
|
pending map[string]chan json.RawMessage
|
||||||
|
pendingMu sync.Mutex
|
||||||
|
transcriber *voice.GroqTranscriber
|
||||||
|
lastMessageID sync.Map
|
||||||
|
pendingEmojiMsg sync.Map
|
||||||
}
|
}
|
||||||
|
|
||||||
type oneBotRawEvent struct {
|
type oneBotRawEvent struct {
|
||||||
|
|
@ -43,9 +53,11 @@ type oneBotRawEvent struct {
|
||||||
SelfID json.RawMessage `json:"self_id"`
|
SelfID json.RawMessage `json:"self_id"`
|
||||||
Time json.RawMessage `json:"time"`
|
Time json.RawMessage `json:"time"`
|
||||||
MetaEventType string `json:"meta_event_type"`
|
MetaEventType string `json:"meta_event_type"`
|
||||||
|
NoticeType string `json:"notice_type"`
|
||||||
Echo string `json:"echo"`
|
Echo string `json:"echo"`
|
||||||
RetCode json.RawMessage `json:"retcode"`
|
RetCode json.RawMessage `json:"retcode"`
|
||||||
Status BotStatus `json:"status"`
|
Status json.RawMessage `json:"status"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BotStatus struct {
|
type BotStatus struct {
|
||||||
|
|
@ -53,42 +65,36 @@ type BotStatus struct {
|
||||||
Good bool `json:"good"`
|
Good bool `json:"good"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isAPIResponse(raw json.RawMessage) bool {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) == nil {
|
||||||
|
return s == "ok" || s == "failed"
|
||||||
|
}
|
||||||
|
var bs BotStatus
|
||||||
|
if json.Unmarshal(raw, &bs) == nil {
|
||||||
|
return bs.Online || bs.Good
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
type oneBotSender struct {
|
type oneBotSender struct {
|
||||||
UserID json.RawMessage `json:"user_id"`
|
UserID json.RawMessage `json:"user_id"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
Card string `json:"card"`
|
Card string `json:"card"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type oneBotEvent struct {
|
|
||||||
PostType string
|
|
||||||
MessageType string
|
|
||||||
SubType string
|
|
||||||
MessageID string
|
|
||||||
UserID int64
|
|
||||||
GroupID int64
|
|
||||||
Content string
|
|
||||||
RawContent string
|
|
||||||
IsBotMentioned bool
|
|
||||||
Sender oneBotSender
|
|
||||||
SelfID int64
|
|
||||||
Time int64
|
|
||||||
MetaEventType string
|
|
||||||
}
|
|
||||||
|
|
||||||
type oneBotAPIRequest struct {
|
type oneBotAPIRequest struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Params interface{} `json:"params"`
|
Params interface{} `json:"params"`
|
||||||
Echo string `json:"echo,omitempty"`
|
Echo string `json:"echo,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type oneBotSendPrivateMsgParams struct {
|
type oneBotMessageSegment struct {
|
||||||
UserID int64 `json:"user_id"`
|
Type string `json:"type"`
|
||||||
Message string `json:"message"`
|
Data map[string]interface{} `json:"data"`
|
||||||
}
|
|
||||||
|
|
||||||
type oneBotSendGroupMsgParams struct {
|
|
||||||
GroupID int64 `json:"group_id"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
|
func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
|
||||||
|
|
@ -101,9 +107,30 @@ func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*One
|
||||||
dedup: make(map[string]struct{}, dedupSize),
|
dedup: make(map[string]struct{}, dedupSize),
|
||||||
dedupRing: make([]string, dedupSize),
|
dedupRing: make([]string, dedupSize),
|
||||||
dedupIdx: 0,
|
dedupIdx: 0,
|
||||||
|
pending: make(map[string]chan json.RawMessage),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
|
||||||
|
c.transcriber = transcriber
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) {
|
||||||
|
go func() {
|
||||||
|
_, err := c.sendAPIRequest("set_msg_emoji_like", map[string]interface{}{
|
||||||
|
"message_id": messageID,
|
||||||
|
"emoji_id": emojiID,
|
||||||
|
"set": set,
|
||||||
|
}, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
logger.DebugCF("onebot", "Failed to set emoji like", map[string]interface{}{
|
||||||
|
"message_id": messageID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
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")
|
||||||
|
|
@ -121,12 +148,12 @@ func (c *OneBotChannel) Start(ctx context.Context) error {
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
go c.listen()
|
go c.listen()
|
||||||
|
c.fetchSelfID()
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.config.ReconnectInterval > 0 {
|
if c.config.ReconnectInterval > 0 {
|
||||||
go c.reconnectLoop()
|
go c.reconnectLoop()
|
||||||
} else {
|
} else {
|
||||||
// If reconnect is disabled but initial connection failed, we cannot recover
|
|
||||||
if c.conn == nil {
|
if c.conn == nil {
|
||||||
return fmt.Errorf("failed to connect to OneBot and reconnect is disabled")
|
return fmt.Errorf("failed to connect to OneBot and reconnect is disabled")
|
||||||
}
|
}
|
||||||
|
|
@ -152,14 +179,141 @@ func (c *OneBotChannel) connect() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
conn.SetPongHandler(func(appData string) error {
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.conn = conn
|
c.conn = conn
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
go c.pinger(conn)
|
||||||
|
|
||||||
logger.InfoC("onebot", "WebSocket connected")
|
logger.InfoC("onebot", "WebSocket connected")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) pinger(conn *websocket.Conn) {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
c.writeMu.Lock()
|
||||||
|
err := conn.WriteMessage(websocket.PingMessage, nil)
|
||||||
|
c.writeMu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
logger.DebugCF("onebot", "Ping write failed, stopping pinger", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) fetchSelfID() {
|
||||||
|
resp, err := c.sendAPIRequest("get_login_info", nil, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("onebot", "Failed to get_login_info", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginInfo struct {
|
||||||
|
UserID json.RawMessage `json:"user_id"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
}
|
||||||
|
for _, extract := range []func() (*loginInfo, error){
|
||||||
|
func() (*loginInfo, error) {
|
||||||
|
var w struct {
|
||||||
|
Data loginInfo `json:"data"`
|
||||||
|
}
|
||||||
|
err := json.Unmarshal(resp, &w)
|
||||||
|
return &w.Data, err
|
||||||
|
},
|
||||||
|
func() (*loginInfo, error) {
|
||||||
|
var f loginInfo
|
||||||
|
err := json.Unmarshal(resp, &f)
|
||||||
|
return &f, err
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
info, err := extract()
|
||||||
|
if err != nil || len(info.UserID) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if uid, err := parseJSONInt64(info.UserID); err == nil && uid > 0 {
|
||||||
|
atomic.StoreInt64(&c.selfID, uid)
|
||||||
|
logger.InfoCF("onebot", "Bot self ID retrieved", map[string]interface{}{
|
||||||
|
"self_id": uid,
|
||||||
|
"nickname": info.Nickname,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.WarnCF("onebot", "Could not parse self ID from get_login_info response", map[string]interface{}{
|
||||||
|
"response": string(resp),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) sendAPIRequest(action string, params interface{}, timeout time.Duration) (json.RawMessage, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if conn == nil {
|
||||||
|
return nil, fmt.Errorf("WebSocket not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
echo := fmt.Sprintf("api_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&c.echoCounter, 1))
|
||||||
|
|
||||||
|
ch := make(chan json.RawMessage, 1)
|
||||||
|
c.pendingMu.Lock()
|
||||||
|
c.pending[echo] = ch
|
||||||
|
c.pendingMu.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
c.pendingMu.Lock()
|
||||||
|
delete(c.pending, echo)
|
||||||
|
c.pendingMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
req := oneBotAPIRequest{
|
||||||
|
Action: action,
|
||||||
|
Params: params,
|
||||||
|
Echo: echo,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal API request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.writeMu.Lock()
|
||||||
|
err = conn.WriteMessage(websocket.TextMessage, data)
|
||||||
|
c.writeMu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write API request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case resp := <-ch:
|
||||||
|
return resp, nil
|
||||||
|
case <-time.After(timeout):
|
||||||
|
return nil, fmt.Errorf("API request %s timed out after %v", action, timeout)
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return nil, fmt.Errorf("context cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *OneBotChannel) reconnectLoop() {
|
func (c *OneBotChannel) reconnectLoop() {
|
||||||
interval := time.Duration(c.config.ReconnectInterval) * time.Second
|
interval := time.Duration(c.config.ReconnectInterval) * time.Second
|
||||||
if interval < 5*time.Second {
|
if interval < 5*time.Second {
|
||||||
|
|
@ -183,6 +337,7 @@ func (c *OneBotChannel) reconnectLoop() {
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
go c.listen()
|
go c.listen()
|
||||||
|
c.fetchSelfID()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -197,6 +352,13 @@ func (c *OneBotChannel) Stop(ctx context.Context) error {
|
||||||
c.cancel()
|
c.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.pendingMu.Lock()
|
||||||
|
for echo, ch := range c.pending {
|
||||||
|
close(ch)
|
||||||
|
delete(c.pending, echo)
|
||||||
|
}
|
||||||
|
c.pendingMu.Unlock()
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
if c.conn != nil {
|
if c.conn != nil {
|
||||||
c.conn.Close()
|
c.conn.Close()
|
||||||
|
|
@ -225,10 +387,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
c.writeMu.Lock()
|
echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1))
|
||||||
c.echoCounter++
|
|
||||||
echo := fmt.Sprintf("send_%d", c.echoCounter)
|
|
||||||
c.writeMu.Unlock()
|
|
||||||
|
|
||||||
req := oneBotAPIRequest{
|
req := oneBotAPIRequest{
|
||||||
Action: action,
|
Action: action,
|
||||||
|
|
@ -252,67 +411,78 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if msgID, ok := c.pendingEmojiMsg.LoadAndDelete(msg.ChatID); ok {
|
||||||
|
if mid, ok := msgID.(string); ok && mid != "" {
|
||||||
|
c.setMsgEmojiLike(mid, 289, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMessageSegment {
|
||||||
|
var segments []oneBotMessageSegment
|
||||||
|
|
||||||
|
if lastMsgID, ok := c.lastMessageID.Load(chatID); ok {
|
||||||
|
if msgID, ok := lastMsgID.(string); ok && msgID != "" {
|
||||||
|
segments = append(segments, oneBotMessageSegment{
|
||||||
|
Type: "reply",
|
||||||
|
Data: map[string]interface{}{"id": msgID},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
segments = append(segments, oneBotMessageSegment{
|
||||||
|
Type: "text",
|
||||||
|
Data: map[string]interface{}{"text": content},
|
||||||
|
})
|
||||||
|
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) {
|
func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) {
|
||||||
chatID := msg.ChatID
|
chatID := msg.ChatID
|
||||||
|
segments := c.buildMessageSegments(chatID, msg.Content)
|
||||||
|
|
||||||
if len(chatID) > 6 && chatID[:6] == "group:" {
|
var action, idKey string
|
||||||
groupID, err := strconv.ParseInt(chatID[6:], 10, 64)
|
var rawID string
|
||||||
if err != nil {
|
if rest, ok := strings.CutPrefix(chatID, "group:"); ok {
|
||||||
return "", nil, fmt.Errorf("invalid group ID in chatID: %s", chatID)
|
action, idKey, rawID = "send_group_msg", "group_id", rest
|
||||||
}
|
} else if rest, ok := strings.CutPrefix(chatID, "private:"); ok {
|
||||||
return "send_group_msg", oneBotSendGroupMsgParams{
|
action, idKey, rawID = "send_private_msg", "user_id", rest
|
||||||
GroupID: groupID,
|
} else {
|
||||||
Message: msg.Content,
|
action, idKey, rawID = "send_private_msg", "user_id", chatID
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(chatID) > 8 && chatID[:8] == "private:" {
|
id, err := strconv.ParseInt(rawID, 10, 64)
|
||||||
userID, err := strconv.ParseInt(chatID[8:], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, fmt.Errorf("invalid user ID in chatID: %s", chatID)
|
|
||||||
}
|
|
||||||
return "send_private_msg", oneBotSendPrivateMsgParams{
|
|
||||||
UserID: userID,
|
|
||||||
Message: msg.Content,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, err := strconv.ParseInt(chatID, 10, 64)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, fmt.Errorf("invalid chatID for OneBot: %s", chatID)
|
return "", nil, fmt.Errorf("invalid %s in chatID: %s", idKey, chatID)
|
||||||
}
|
}
|
||||||
|
return action, map[string]interface{}{idKey: id, "message": segments}, nil
|
||||||
return "send_private_msg", oneBotSendPrivateMsgParams{
|
|
||||||
UserID: userID,
|
|
||||||
Message: msg.Content,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OneBotChannel) listen() {
|
func (c *OneBotChannel) listen() {
|
||||||
|
c.mu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if conn == nil {
|
||||||
|
logger.WarnC("onebot", "WebSocket connection is nil, listener exiting")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-c.ctx.Done():
|
case <-c.ctx.Done():
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
c.mu.Lock()
|
|
||||||
conn := c.conn
|
|
||||||
c.mu.Unlock()
|
|
||||||
|
|
||||||
if conn == nil {
|
|
||||||
logger.WarnC("onebot", "WebSocket connection is nil, listener exiting")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, message, err := conn.ReadMessage()
|
_, message, err := conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("onebot", "WebSocket read error", map[string]interface{}{
|
logger.ErrorCF("onebot", "WebSocket read error", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
if c.conn != nil {
|
if c.conn == conn {
|
||||||
c.conn.Close()
|
c.conn.Close()
|
||||||
c.conn = nil
|
c.conn = nil
|
||||||
}
|
}
|
||||||
|
|
@ -320,10 +490,7 @@ func (c *OneBotChannel) listen() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("onebot", "Raw WebSocket message received", map[string]interface{}{
|
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
"length": len(message),
|
|
||||||
"payload": string(message),
|
|
||||||
})
|
|
||||||
|
|
||||||
var raw oneBotRawEvent
|
var raw oneBotRawEvent
|
||||||
if err := json.Unmarshal(message, &raw); err != nil {
|
if err := json.Unmarshal(message, &raw); err != nil {
|
||||||
|
|
@ -334,20 +501,37 @@ func (c *OneBotChannel) listen() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if raw.Echo != "" || raw.Status.Online || raw.Status.Good {
|
logger.DebugCF("onebot", "WebSocket event", map[string]interface{}{
|
||||||
logger.DebugCF("onebot", "Received API response, skipping", map[string]interface{}{
|
"length": len(message),
|
||||||
"echo": raw.Echo,
|
"post_type": raw.PostType,
|
||||||
"status": raw.Status,
|
"sub_type": raw.SubType,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if raw.Echo != "" {
|
||||||
|
c.pendingMu.Lock()
|
||||||
|
ch, ok := c.pending[raw.Echo]
|
||||||
|
c.pendingMu.Unlock()
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
select {
|
||||||
|
case ch <- message:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.DebugCF("onebot", "Received API response (no waiter)", map[string]interface{}{
|
||||||
|
"echo": raw.Echo,
|
||||||
|
"status": string(raw.Status),
|
||||||
|
})
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("onebot", "Parsed raw event", map[string]interface{}{
|
if isAPIResponse(raw.Status) {
|
||||||
"post_type": raw.PostType,
|
logger.DebugCF("onebot", "Received API response without echo, skipping", map[string]interface{}{
|
||||||
"message_type": raw.MessageType,
|
"status": string(raw.Status),
|
||||||
"sub_type": raw.SubType,
|
})
|
||||||
"meta_event_type": raw.MetaEventType,
|
continue
|
||||||
})
|
}
|
||||||
|
|
||||||
c.handleRawEvent(&raw)
|
c.handleRawEvent(&raw)
|
||||||
}
|
}
|
||||||
|
|
@ -386,9 +570,12 @@ func parseJSONString(raw json.RawMessage) string {
|
||||||
type parseMessageResult struct {
|
type parseMessageResult struct {
|
||||||
Text string
|
Text string
|
||||||
IsBotMentioned bool
|
IsBotMentioned bool
|
||||||
|
Media []string
|
||||||
|
LocalFiles []string
|
||||||
|
ReplyTo string
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult {
|
func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) parseMessageResult {
|
||||||
if len(raw) == 0 {
|
if len(raw) == 0 {
|
||||||
return parseMessageResult{}
|
return parseMessageResult{}
|
||||||
}
|
}
|
||||||
|
|
@ -408,60 +595,155 @@ func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult
|
||||||
}
|
}
|
||||||
|
|
||||||
var segments []map[string]interface{}
|
var segments []map[string]interface{}
|
||||||
if err := json.Unmarshal(raw, &segments); err == nil {
|
if err := json.Unmarshal(raw, &segments); err != nil {
|
||||||
var text string
|
return parseMessageResult{}
|
||||||
mentioned := false
|
}
|
||||||
selfIDStr := strconv.FormatInt(selfID, 10)
|
|
||||||
for _, seg := range segments {
|
var textParts []string
|
||||||
segType, _ := seg["type"].(string)
|
mentioned := false
|
||||||
data, _ := seg["data"].(map[string]interface{})
|
selfIDStr := strconv.FormatInt(selfID, 10)
|
||||||
switch segType {
|
var media []string
|
||||||
case "text":
|
var localFiles []string
|
||||||
if data != nil {
|
var replyTo string
|
||||||
if t, ok := data["text"].(string); ok {
|
|
||||||
text += t
|
for _, seg := range segments {
|
||||||
}
|
segType, _ := seg["type"].(string)
|
||||||
|
data, _ := seg["data"].(map[string]interface{})
|
||||||
|
|
||||||
|
switch segType {
|
||||||
|
case "text":
|
||||||
|
if data != nil {
|
||||||
|
if t, ok := data["text"].(string); ok {
|
||||||
|
textParts = append(textParts, t)
|
||||||
}
|
}
|
||||||
case "at":
|
}
|
||||||
if data != nil && selfID > 0 {
|
|
||||||
qqVal := fmt.Sprintf("%v", data["qq"])
|
case "at":
|
||||||
if qqVal == selfIDStr || qqVal == "all" {
|
if data != nil && selfID > 0 {
|
||||||
mentioned = true
|
qqVal := fmt.Sprintf("%v", data["qq"])
|
||||||
|
if qqVal == selfIDStr || qqVal == "all" {
|
||||||
|
mentioned = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "image", "video", "file":
|
||||||
|
if data != nil {
|
||||||
|
url, _ := data["url"].(string)
|
||||||
|
if url != "" {
|
||||||
|
defaults := map[string]string{"image": "image.jpg", "video": "video.mp4", "file": "file"}
|
||||||
|
filename := defaults[segType]
|
||||||
|
if f, ok := data["file"].(string); ok && f != "" {
|
||||||
|
filename = f
|
||||||
|
} else if n, ok := data["name"].(string); ok && n != "" {
|
||||||
|
filename = n
|
||||||
|
}
|
||||||
|
localPath := utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||||
|
LoggerPrefix: "onebot",
|
||||||
|
})
|
||||||
|
if localPath != "" {
|
||||||
|
media = append(media, localPath)
|
||||||
|
localFiles = append(localFiles, localPath)
|
||||||
|
textParts = append(textParts, fmt.Sprintf("[%s]", segType))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "record":
|
||||||
|
if data != nil {
|
||||||
|
url, _ := data["url"].(string)
|
||||||
|
if url != "" {
|
||||||
|
localPath := utils.DownloadFile(url, "voice.amr", utils.DownloadOptions{
|
||||||
|
LoggerPrefix: "onebot",
|
||||||
|
})
|
||||||
|
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]interface{}{
|
||||||
|
"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]")
|
||||||
|
media = append(media, localPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "reply":
|
||||||
|
if data != nil {
|
||||||
|
if id, ok := data["id"]; ok {
|
||||||
|
replyTo = fmt.Sprintf("%v", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "face":
|
||||||
|
if data != nil {
|
||||||
|
faceID, _ := data["id"]
|
||||||
|
textParts = append(textParts, fmt.Sprintf("[face:%v]", faceID))
|
||||||
|
}
|
||||||
|
|
||||||
|
case "forward":
|
||||||
|
textParts = append(textParts, "[forward message]")
|
||||||
|
|
||||||
|
default:
|
||||||
|
|
||||||
}
|
}
|
||||||
return parseMessageResult{Text: strings.TrimSpace(text), IsBotMentioned: mentioned}
|
|
||||||
}
|
}
|
||||||
return parseMessageResult{}
|
|
||||||
|
return parseMessageResult{
|
||||||
|
Text: strings.TrimSpace(strings.Join(textParts, "")),
|
||||||
|
IsBotMentioned: mentioned,
|
||||||
|
Media: media,
|
||||||
|
LocalFiles: localFiles,
|
||||||
|
ReplyTo: replyTo,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
|
func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
|
||||||
switch raw.PostType {
|
switch raw.PostType {
|
||||||
case "message":
|
case "message":
|
||||||
evt, err := c.normalizeMessageEvent(raw)
|
if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 {
|
||||||
if err != nil {
|
if !c.IsAllowed(strconv.FormatInt(userID, 10)) {
|
||||||
logger.WarnCF("onebot", "Failed to normalize message event", map[string]interface{}{
|
logger.DebugCF("onebot", "Message rejected by allowlist", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"user_id": userID,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
c.handleMessage(evt)
|
c.handleMessage(raw)
|
||||||
|
|
||||||
|
case "message_sent":
|
||||||
|
logger.DebugCF("onebot", "Bot sent message event", map[string]interface{}{
|
||||||
|
"message_type": raw.MessageType,
|
||||||
|
"message_id": parseJSONString(raw.MessageID),
|
||||||
|
})
|
||||||
|
|
||||||
case "meta_event":
|
case "meta_event":
|
||||||
c.handleMetaEvent(raw)
|
c.handleMetaEvent(raw)
|
||||||
|
|
||||||
case "notice":
|
case "notice":
|
||||||
logger.DebugCF("onebot", "Notice event received", map[string]interface{}{
|
c.handleNoticeEvent(raw)
|
||||||
"sub_type": raw.SubType,
|
|
||||||
})
|
|
||||||
case "request":
|
case "request":
|
||||||
logger.DebugCF("onebot", "Request event received", map[string]interface{}{
|
logger.DebugCF("onebot", "Request event received", map[string]interface{}{
|
||||||
"sub_type": raw.SubType,
|
"sub_type": raw.SubType,
|
||||||
})
|
})
|
||||||
|
|
||||||
case "":
|
case "":
|
||||||
logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]interface{}{
|
logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]interface{}{
|
||||||
"echo": raw.Echo,
|
"echo": raw.Echo,
|
||||||
"status": raw.Status,
|
"status": raw.Status,
|
||||||
})
|
})
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logger.DebugCF("onebot", "Unknown post_type", map[string]interface{}{
|
logger.DebugCF("onebot", "Unknown post_type", map[string]interface{}{
|
||||||
"post_type": raw.PostType,
|
"post_type": raw.PostType,
|
||||||
|
|
@ -469,18 +751,51 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent, error) {
|
func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) {
|
||||||
|
if raw.MetaEventType == "lifecycle" {
|
||||||
|
logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{"sub_type": raw.SubType})
|
||||||
|
} else if raw.MetaEventType != "heartbeat" {
|
||||||
|
logger.DebugCF("onebot", "Meta event: "+raw.MetaEventType, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) handleNoticeEvent(raw *oneBotRawEvent) {
|
||||||
|
fields := map[string]interface{}{
|
||||||
|
"notice_type": raw.NoticeType,
|
||||||
|
"sub_type": raw.SubType,
|
||||||
|
"group_id": parseJSONString(raw.GroupID),
|
||||||
|
"user_id": parseJSONString(raw.UserID),
|
||||||
|
"message_id": parseJSONString(raw.MessageID),
|
||||||
|
}
|
||||||
|
switch raw.NoticeType {
|
||||||
|
case "group_recall", "group_increase", "group_decrease",
|
||||||
|
"friend_add", "group_admin", "group_ban":
|
||||||
|
logger.InfoCF("onebot", "Notice: "+raw.NoticeType, fields)
|
||||||
|
default:
|
||||||
|
logger.DebugCF("onebot", "Notice: "+raw.NoticeType, fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
|
||||||
|
// Parse fields from raw event
|
||||||
userID, err := parseJSONInt64(raw.UserID)
|
userID, err := parseJSONInt64(raw.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse user_id: %w (raw: %s)", err, string(raw.UserID))
|
logger.WarnCF("onebot", "Failed to parse user_id", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"raw": string(raw.UserID),
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
groupID, _ := parseJSONInt64(raw.GroupID)
|
groupID, _ := parseJSONInt64(raw.GroupID)
|
||||||
selfID, _ := parseJSONInt64(raw.SelfID)
|
selfID, _ := parseJSONInt64(raw.SelfID)
|
||||||
ts, _ := parseJSONInt64(raw.Time)
|
|
||||||
messageID := parseJSONString(raw.MessageID)
|
messageID := parseJSONString(raw.MessageID)
|
||||||
|
|
||||||
parsed := parseMessageContentEx(raw.Message, selfID)
|
if selfID == 0 {
|
||||||
|
selfID = atomic.LoadInt64(&c.selfID)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed := c.parseMessageSegments(raw.Message, selfID)
|
||||||
isBotMentioned := parsed.IsBotMentioned
|
isBotMentioned := parsed.IsBotMentioned
|
||||||
|
|
||||||
content := raw.RawMessage
|
content := raw.RawMessage
|
||||||
|
|
@ -495,6 +810,10 @@ func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if parsed.Text != "" && content != parsed.Text && (len(parsed.Media) > 0 || parsed.ReplyTo != "") {
|
||||||
|
content = parsed.Text
|
||||||
|
}
|
||||||
|
|
||||||
var sender oneBotSender
|
var sender oneBotSender
|
||||||
if len(raw.Sender) > 0 {
|
if len(raw.Sender) > 0 {
|
||||||
if err := json.Unmarshal(raw.Sender, &sender); err != nil {
|
if err := json.Unmarshal(raw.Sender, &sender); err != nil {
|
||||||
|
|
@ -505,137 +824,107 @@ func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("onebot", "Normalized message event", map[string]interface{}{
|
// Clean up temp files when done
|
||||||
"message_type": raw.MessageType,
|
if len(parsed.LocalFiles) > 0 {
|
||||||
"user_id": userID,
|
defer func() {
|
||||||
"group_id": groupID,
|
for _, f := range parsed.LocalFiles {
|
||||||
"message_id": messageID,
|
if err := os.Remove(f); err != nil {
|
||||||
"content_len": len(content),
|
logger.DebugCF("onebot", "Failed to remove temp file", map[string]interface{}{
|
||||||
"nickname": sender.Nickname,
|
"path": f,
|
||||||
})
|
"error": err.Error(),
|
||||||
|
})
|
||||||
return &oneBotEvent{
|
}
|
||||||
PostType: raw.PostType,
|
}
|
||||||
MessageType: raw.MessageType,
|
}()
|
||||||
SubType: raw.SubType,
|
|
||||||
MessageID: messageID,
|
|
||||||
UserID: userID,
|
|
||||||
GroupID: groupID,
|
|
||||||
Content: content,
|
|
||||||
RawContent: raw.RawMessage,
|
|
||||||
IsBotMentioned: isBotMentioned,
|
|
||||||
Sender: sender,
|
|
||||||
SelfID: selfID,
|
|
||||||
Time: ts,
|
|
||||||
MetaEventType: raw.MetaEventType,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) {
|
|
||||||
switch raw.MetaEventType {
|
|
||||||
case "lifecycle":
|
|
||||||
logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{
|
|
||||||
"sub_type": raw.SubType,
|
|
||||||
})
|
|
||||||
case "heartbeat":
|
|
||||||
logger.DebugC("onebot", "Heartbeat received")
|
|
||||||
default:
|
|
||||||
logger.DebugCF("onebot", "Unknown meta_event_type", map[string]interface{}{
|
|
||||||
"meta_event_type": raw.MetaEventType,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func (c *OneBotChannel) handleMessage(evt *oneBotEvent) {
|
if c.isDuplicate(messageID) {
|
||||||
if c.isDuplicate(evt.MessageID) {
|
|
||||||
logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{
|
logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{
|
||||||
"message_id": evt.MessageID,
|
"message_id": messageID,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
content := evt.Content
|
|
||||||
if content == "" {
|
if content == "" {
|
||||||
logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{
|
logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{
|
||||||
"message_id": evt.MessageID,
|
"message_id": messageID,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
senderID := strconv.FormatInt(evt.UserID, 10)
|
senderID := strconv.FormatInt(userID, 10)
|
||||||
var chatID string
|
var chatID string
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"message_id": evt.MessageID,
|
"message_id": messageID,
|
||||||
}
|
}
|
||||||
|
|
||||||
switch evt.MessageType {
|
if parsed.ReplyTo != "" {
|
||||||
|
metadata["reply_to_message_id"] = parsed.ReplyTo
|
||||||
|
}
|
||||||
|
|
||||||
|
switch raw.MessageType {
|
||||||
case "private":
|
case "private":
|
||||||
chatID = "private:" + senderID
|
chatID = "private:" + senderID
|
||||||
logger.InfoCF("onebot", "Received private message", map[string]interface{}{
|
|
||||||
"sender": senderID,
|
|
||||||
"message_id": evt.MessageID,
|
|
||||||
"length": len(content),
|
|
||||||
"content": truncate(content, 100),
|
|
||||||
})
|
|
||||||
|
|
||||||
case "group":
|
case "group":
|
||||||
groupIDStr := strconv.FormatInt(evt.GroupID, 10)
|
groupIDStr := strconv.FormatInt(groupID, 10)
|
||||||
chatID = "group:" + groupIDStr
|
chatID = "group:" + groupIDStr
|
||||||
metadata["group_id"] = groupIDStr
|
metadata["group_id"] = groupIDStr
|
||||||
|
|
||||||
senderUserID, _ := parseJSONInt64(evt.Sender.UserID)
|
senderUserID, _ := parseJSONInt64(sender.UserID)
|
||||||
if senderUserID > 0 {
|
if senderUserID > 0 {
|
||||||
metadata["sender_user_id"] = strconv.FormatInt(senderUserID, 10)
|
metadata["sender_user_id"] = strconv.FormatInt(senderUserID, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
if evt.Sender.Card != "" {
|
if sender.Card != "" {
|
||||||
metadata["sender_name"] = evt.Sender.Card
|
metadata["sender_name"] = sender.Card
|
||||||
} else if evt.Sender.Nickname != "" {
|
} else if sender.Nickname != "" {
|
||||||
metadata["sender_name"] = evt.Sender.Nickname
|
metadata["sender_name"] = sender.Nickname
|
||||||
}
|
}
|
||||||
|
|
||||||
triggered, strippedContent := c.checkGroupTrigger(content, evt.IsBotMentioned)
|
triggered, strippedContent := c.checkGroupTrigger(content, isBotMentioned)
|
||||||
if !triggered {
|
if !triggered {
|
||||||
logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]interface{}{
|
logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]interface{}{
|
||||||
"sender": senderID,
|
"sender": senderID,
|
||||||
"group": groupIDStr,
|
"group": groupIDStr,
|
||||||
"is_mentioned": evt.IsBotMentioned,
|
"is_mentioned": isBotMentioned,
|
||||||
"content": truncate(content, 100),
|
"content": truncate(content, 100),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
content = strippedContent
|
content = strippedContent
|
||||||
|
|
||||||
logger.InfoCF("onebot", "Received group message", map[string]interface{}{
|
|
||||||
"sender": senderID,
|
|
||||||
"group": groupIDStr,
|
|
||||||
"message_id": evt.MessageID,
|
|
||||||
"is_mentioned": evt.IsBotMentioned,
|
|
||||||
"length": len(content),
|
|
||||||
"content": truncate(content, 100),
|
|
||||||
})
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]interface{}{
|
logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]interface{}{
|
||||||
"type": evt.MessageType,
|
"type": raw.MessageType,
|
||||||
"message_id": evt.MessageID,
|
"message_id": messageID,
|
||||||
"user_id": evt.UserID,
|
"user_id": userID,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if evt.Sender.Nickname != "" {
|
logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]interface{}{
|
||||||
metadata["nickname"] = evt.Sender.Nickname
|
"sender": senderID,
|
||||||
}
|
"chat_id": chatID,
|
||||||
|
"message_id": messageID,
|
||||||
logger.DebugCF("onebot", "Forwarding message to bus", map[string]interface{}{
|
"length": len(content),
|
||||||
"sender_id": senderID,
|
"content": truncate(content, 100),
|
||||||
"chat_id": chatID,
|
"media_count": len(parsed.Media),
|
||||||
"content": truncate(content, 100),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
c.HandleMessage(senderID, chatID, content, []string{}, metadata)
|
if sender.Nickname != "" {
|
||||||
|
metadata["nickname"] = sender.Nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
c.lastMessageID.Store(chatID, messageID)
|
||||||
|
|
||||||
|
if raw.MessageType == "group" && messageID != "" && messageID != "0" {
|
||||||
|
c.setMsgEmojiLike(messageID, 289, true)
|
||||||
|
c.pendingEmojiMsg.Store(chatID, messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HandleMessage(senderID, chatID, content, parsed.Media, metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OneBotChannel) isDuplicate(messageID string) bool {
|
func (c *OneBotChannel) isDuplicate(messageID string) bool {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
// Package constants provides shared constants across the codebase.
|
// Package constants provides shared constants across the codebase.
|
||||||
package constants
|
package constants
|
||||||
|
|
||||||
// InternalChannels defines channels that are used for internal communication
|
// internalChannels defines channels that are used for internal communication
|
||||||
// and should not be exposed to external users or recorded as last active channel.
|
// and should not be exposed to external users or recorded as last active channel.
|
||||||
var InternalChannels = map[string]bool{
|
var internalChannels = map[string]struct{}{
|
||||||
"cli": true,
|
"cli": {},
|
||||||
"system": true,
|
"system": {},
|
||||||
"subagent": true,
|
"subagent": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsInternalChannel returns true if the channel is an internal channel.
|
// IsInternalChannel returns true if the channel is an internal channel.
|
||||||
func IsInternalChannel(channel string) bool {
|
func IsInternalChannel(channel string) bool {
|
||||||
return InternalChannels[channel]
|
_, found := internalChannels[channel]
|
||||||
|
return found
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDef
|
||||||
|
|
||||||
if maxTokens, ok := asInt(options["max_tokens"]); ok {
|
if maxTokens, ok := asInt(options["max_tokens"]); ok {
|
||||||
lowerModel := strings.ToLower(model)
|
lowerModel := strings.ToLower(model)
|
||||||
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") {
|
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") || strings.Contains(lowerModel, "gpt-5") {
|
||||||
requestBody["max_completion_tokens"] = maxTokens
|
requestBody["max_completion_tokens"] = maxTokens
|
||||||
} else {
|
} else {
|
||||||
requestBody["max_tokens"] = maxTokens
|
requestBody["max_tokens"] = maxTokens
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -24,12 +23,6 @@ type AvailableSkill struct {
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BuiltinSkill struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSkillInstaller(workspace string) *SkillInstaller {
|
func NewSkillInstaller(workspace string) *SkillInstaller {
|
||||||
return &SkillInstaller{
|
return &SkillInstaller{
|
||||||
workspace: workspace,
|
workspace: workspace,
|
||||||
|
|
@ -123,49 +116,3 @@ func (si *SkillInstaller) ListAvailableSkills(ctx context.Context) ([]AvailableS
|
||||||
|
|
||||||
return skills, nil
|
return skills, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (si *SkillInstaller) ListBuiltinSkills() []BuiltinSkill {
|
|
||||||
builtinSkillsDir := filepath.Join(filepath.Dir(si.workspace), "picoclaw", "skills")
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(builtinSkillsDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var skills []BuiltinSkill
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() {
|
|
||||||
_ = entry
|
|
||||||
skillName := entry.Name()
|
|
||||||
skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md")
|
|
||||||
|
|
||||||
data, err := os.ReadFile(skillFile)
|
|
||||||
description := ""
|
|
||||||
if err == nil {
|
|
||||||
content := string(data)
|
|
||||||
if idx := strings.Index(content, "\n"); idx > 0 {
|
|
||||||
firstLine := content[:idx]
|
|
||||||
if strings.Contains(firstLine, "description:") {
|
|
||||||
descLine := strings.Index(content[idx:], "\n")
|
|
||||||
if descLine > 0 {
|
|
||||||
description = strings.TrimSpace(content[idx+descLine : idx+descLine])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// skill := BuiltinSkill{
|
|
||||||
// Name: skillName,
|
|
||||||
// Path: description,
|
|
||||||
// Enabled: true,
|
|
||||||
// }
|
|
||||||
|
|
||||||
status := "✓"
|
|
||||||
fmt.Printf(" %s %s\n", status, entry.Name())
|
|
||||||
if description != "" {
|
|
||||||
fmt.Printf(" %s\n", description)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return skills
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@ func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []provider
|
||||||
Name: tc.Name,
|
Name: tc.Name,
|
||||||
Arguments: string(argumentsJSON),
|
Arguments: string(argumentsJSON),
|
||||||
},
|
},
|
||||||
|
Name: tc.Name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
messages = append(messages, assistantMsg)
|
messages = append(messages, assistantMsg)
|
||||||
|
|
|
||||||
|
|
@ -492,8 +492,10 @@ func (t *WebFetchTool) extractText(htmlContent string) string {
|
||||||
|
|
||||||
result = strings.TrimSpace(result)
|
result = strings.TrimSpace(result)
|
||||||
|
|
||||||
re = regexp.MustCompile(`\s+`)
|
re = regexp.MustCompile(`[^\S\n]+`)
|
||||||
result = re.ReplaceAllLiteralString(result, " ")
|
result = re.ReplaceAllString(result, " ")
|
||||||
|
re = regexp.MustCompile(`\n{3,}`)
|
||||||
|
result = re.ReplaceAllString(result, "\n\n")
|
||||||
|
|
||||||
lines := strings.Split(result, "\n")
|
lines := strings.Split(result, "\n")
|
||||||
var cleanLines []string
|
var cleanLines []string
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,80 @@ func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWebFetchTool_extractText verifies text extraction preserves newlines
|
||||||
|
func TestWebFetchTool_extractText(t *testing.T) {
|
||||||
|
tool := &WebFetchTool{}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantFunc func(t *testing.T, got string)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "preserves newlines between block elements",
|
||||||
|
input: "<html><body><h1>Title</h1>\n<p>Paragraph 1</p>\n<p>Paragraph 2</p></body></html>",
|
||||||
|
wantFunc: func(t *testing.T, got string) {
|
||||||
|
lines := strings.Split(got, "\n")
|
||||||
|
if len(lines) < 2 {
|
||||||
|
t.Errorf("Expected multiple lines, got %d: %q", len(lines), got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "Title") || !strings.Contains(got, "Paragraph 1") || !strings.Contains(got, "Paragraph 2") {
|
||||||
|
t.Errorf("Missing expected text: %q", got)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "removes script and style tags",
|
||||||
|
input: "<script>alert('x');</script><style>body{}</style><p>Keep this</p>",
|
||||||
|
wantFunc: func(t *testing.T, got string) {
|
||||||
|
if strings.Contains(got, "alert") || strings.Contains(got, "body{}") {
|
||||||
|
t.Errorf("Expected script/style content removed, got: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "Keep this") {
|
||||||
|
t.Errorf("Expected 'Keep this' to remain, got: %q", got)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "collapses excessive blank lines",
|
||||||
|
input: "<p>A</p>\n\n\n\n\n<p>B</p>",
|
||||||
|
wantFunc: func(t *testing.T, got string) {
|
||||||
|
if strings.Contains(got, "\n\n\n") {
|
||||||
|
t.Errorf("Expected excessive blank lines collapsed, got: %q", got)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "collapses horizontal whitespace",
|
||||||
|
input: "<p>hello world</p>",
|
||||||
|
wantFunc: func(t *testing.T, got string) {
|
||||||
|
if strings.Contains(got, " ") {
|
||||||
|
t.Errorf("Expected spaces collapsed, got: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "hello world") {
|
||||||
|
t.Errorf("Expected 'hello world', got: %q", got)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty input",
|
||||||
|
input: "",
|
||||||
|
wantFunc: func(t *testing.T, got string) {
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("Expected empty string, got: %q", got)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tool.extractText(tt.input)
|
||||||
|
tt.wantFunc(t, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestWebTool_WebFetch_MissingDomain verifies error handling for URL without domain
|
// TestWebTool_WebFetch_MissingDomain verifies error handling for URL without domain
|
||||||
func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
|
func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
|
||||||
tool := NewWebFetchTool(50000)
|
tool := NewWebFetchTool(50000)
|
||||||
|
|
|
||||||
179
pkg/utils/message.go
Normal file
179
pkg/utils/message.go
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
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 := maxLen / 10
|
||||||
|
if codeBlockBuffer < 50 {
|
||||||
|
codeBlockBuffer = 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 := maxLen - codeBlockBuffer
|
||||||
|
if effectiveLimit < maxLen/2 {
|
||||||
|
effectiveLimit = 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 := len(s) - searchWindow
|
||||||
|
if searchStart < 0 {
|
||||||
|
searchStart = 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 := len(s) - searchWindow
|
||||||
|
if searchStart < 0 {
|
||||||
|
searchStart = 0
|
||||||
|
}
|
||||||
|
for i := len(s) - 1; i >= searchStart; i-- {
|
||||||
|
if s[i] == ' ' || s[i] == '\t' {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
151
pkg/utils/message_test.go
Normal file
151
pkg/utils/message_test.go
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
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]))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue