Files
meshtastic_mqtt_server/autoreply/service.go
T
2026-06-17 23:54:38 +08:00

259 lines
6.5 KiB
Go

package autoreply
import (
"context"
"fmt"
"strings"
"sync"
"time"
"meshtastic_mqtt_server/completion"
"meshtastic_mqtt_server/conversation"
"meshtastic_mqtt_server/llm"
"meshtastic_mqtt_server/message"
"meshtastic_mqtt_server/toolmanager"
"meshtastic_mqtt_server/toolrouter"
)
const (
// MaxReplyLength is the maximum length for Meshtastic messages
MaxReplyLength = 200
// PollInterval is how often to check the queue for new messages
PollInterval = 5 * time.Second
// MaxProcessingTime is the maximum time to spend processing a single message
MaxProcessingTime = 120 * time.Second
)
// MessageQueue is the interface for accessing the LLM message queue
type MessageQueue interface {
// GetPendingMessages returns pending messages for a bot
GetPendingMessages(botID uint64, limit int) ([]QueuedMessage, error)
// MarkAsProcessing marks a message as being processed
MarkAsProcessing(id uint64) error
// MarkAsProcessed marks a message as successfully processed
MarkAsProcessed(id uint64, reply string) error
// MarkAsFailed marks a message as failed
MarkAsFailed(id uint64, error string) error
}
// QueuedMessage represents a message in the LLM queue
type QueuedMessage struct {
ID uint64
BotID uint64
BotNodeID string
BotNodeNum int64
FromNodeID string
FromNodeNum int64
LongName *string
ShortName *string
Text string
PacketID int64
ChannelID *string
Topic string
ReceivedAt time.Time
}
// BotSender is the interface for sending bot messages
type BotSender interface {
SendText(ctx context.Context, botID uint64, toNodeNum int64, text string) error
}
// Service manages automatic AI replies for bots
type Service struct {
llmState *llm.State
toolRouter *toolrouter.State
toolMgr *toolmanager.Manager
convStore *conversation.Store
msgQueue MessageQueue
botSender BotSender
running bool
mu sync.Mutex
cancel context.CancelFunc
wg sync.WaitGroup
}
// NewService creates a new auto-reply service
func NewService(
llmState *llm.State,
toolRouter *toolrouter.State,
toolMgr *toolmanager.Manager,
convStore *conversation.Store,
msgQueue MessageQueue,
botSender BotSender,
) *Service {
return &Service{
llmState: llmState,
toolRouter: toolRouter,
toolMgr: toolMgr,
convStore: convStore,
msgQueue: msgQueue,
botSender: botSender,
}
}
// Start starts the auto-reply service
func (s *Service) Start(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
return fmt.Errorf("auto-reply service is already running")
}
ctx, cancel := context.WithCancel(ctx)
s.cancel = cancel
s.running = true
s.wg.Add(1)
go s.run(ctx)
return nil
}
// Stop stops the auto-reply service
func (s *Service) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if !s.running {
return
}
if s.cancel != nil {
s.cancel()
}
s.wg.Wait()
s.running = false
}
// run is the main processing loop
func (s *Service) run(ctx context.Context) {
defer s.wg.Done()
ticker := time.NewTicker(PollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.processQueue(ctx)
}
}
}
// processQueue processes pending messages in the queue
func (s *Service) processQueue(ctx context.Context) {
// Get all bots (we'd typically get this from bot store, but for now
// we'll rely on the queue to provide messages per bot)
// For now, process up to 10 messages at a time
messages, err := s.msgQueue.GetPendingMessages(0, 10)
if err != nil {
return
}
for _, msg := range messages {
select {
case <-ctx.Done():
return
default:
s.processMessage(ctx, msg)
}
}
}
// processMessage processes a single queued message
func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) {
// Mark message as processing
if err := s.msgQueue.MarkAsProcessing(msg.ID); err != nil {
return
}
// Create processing context with timeout
procCtx, cancel := context.WithTimeout(ctx, MaxProcessingTime)
defer cancel()
// Get or create conversation for this bot
conv, err := s.convStore.GetOrCreateForBot(msg.BotID, msg.BotNodeID, msg.FromNodeID)
if err != nil {
_ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("failed to get conversation: %v", err))
return
}
// Add the user message to the conversation
userMsg := message.ChatMessage{
Role: "user",
Content: s.formatUserMessage(msg),
}
if err := s.convStore.AddMessage(conv.ID, userMsg); err != nil {
_ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("failed to add message: %v", err))
return
}
// Get the LLM profile
profile := s.llmState.ActiveProfile()
if profile == nil {
_ = s.msgQueue.MarkAsFailed(msg.ID, "no active LLM profile")
return
}
// Reload conversation to get updated messages
conv, err = s.convStore.Get(conv.ID)
if err != nil {
_ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("failed to reload conversation: %v", err))
return
}
// Run the tool loop to get augmented messages
augmentedMessages, err := toolrouter.RunAgentToolLoop(procCtx, s.toolRouter, profile, conv.Messages, s.toolMgr, nil)
_ = augmentedMessages // We'll use this in the future with proper tool support
// For now, use simple completion since we don't have tools registered yet
reply, err := completion.CompleteText(procCtx, profile, conv.Messages, 512)
if err != nil {
_ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("LLM completion failed: %v", err))
return
}
// Truncate reply for Meshtastic
if len([]byte(reply)) > MaxReplyLength {
reply = string([]byte(reply)[:MaxReplyLength-3]) + "..."
}
// Add assistant reply to conversation
assistantMsg := message.ChatMessage{
Role: "assistant",
Content: reply,
}
if err := s.convStore.AddMessage(conv.ID, assistantMsg); err != nil {
// Non-fatal, continue
}
// Send the reply via the bot
if err := s.botSender.SendText(procCtx, msg.BotID, msg.FromNodeNum, reply); err != nil {
_ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("failed to send reply: %v", err))
return
}
// Mark message as processed
_ = s.msgQueue.MarkAsProcessed(msg.ID, reply)
}
// formatUserMessage formats the incoming message for the LLM
func (s *Service) formatUserMessage(msg QueuedMessage) string {
var sb strings.Builder
if msg.LongName != nil && *msg.LongName != "" {
sb.WriteString(fmt.Sprintf("[来自 %s (%s)] ", *msg.LongName, msg.FromNodeID))
} else if msg.ShortName != nil && *msg.ShortName != "" {
sb.WriteString(fmt.Sprintf("[来自 %s (%s)] ", *msg.ShortName, msg.FromNodeID))
} else {
sb.WriteString(fmt.Sprintf("[来自 %s] ", msg.FromNodeID))
}
sb.WriteString(msg.Text)
return sb.String()
}