feat: sync AI compose and ChatBot features
This commit is contained in:
parent
35e5d38f4d
commit
b7269b135f
75 changed files with 5426 additions and 123 deletions
|
|
@ -70,7 +70,7 @@ type botReply struct {
|
|||
|
||||
// HandlesBot 报告该收件人是否为内置应答 bot(messages.BotResponder 实现)。
|
||||
func (s *Service) HandlesBot(botUserID int64) bool {
|
||||
return s != nil && (botUserID == domain.BotFatherUserID || botUserID == domain.StickersBotUserID)
|
||||
return s != nil && (botUserID == domain.BotFatherUserID || botUserID == domain.StickersBotUserID || botUserID == domain.ChatBotUserID)
|
||||
}
|
||||
|
||||
// OnPrivateMessage 处理投递给内置 bot 的私聊消息(messages.BotResponder 实现)。
|
||||
|
|
@ -89,6 +89,8 @@ func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg dom
|
|||
go s.respondAsBotFather(userID, msg.Body)
|
||||
case domain.StickersBotUserID:
|
||||
go s.respondAsStickers(userID, msg)
|
||||
case domain.ChatBotUserID:
|
||||
go s.respondAsChatBot(userID, msg)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -112,28 +114,39 @@ func (s *Service) serviceBotReplyLock(botUserID, userID int64) *sync.Mutex {
|
|||
}
|
||||
|
||||
func (s *Service) sendServiceBotReply(ctx context.Context, botUserID, userID int64, reply botReply) {
|
||||
_, _ = s.sendServiceBotReplyResult(ctx, botUserID, userID, reply)
|
||||
}
|
||||
|
||||
func (s *Service) serviceBotRecipientBlocked(ctx context.Context, botUserID, userID int64) bool {
|
||||
if s == nil || s.blocker == nil {
|
||||
return false
|
||||
}
|
||||
blocked, err := s.blocker.IsBlocked(ctx, userID, botUserID)
|
||||
if err != nil {
|
||||
s.log.Warn("service bot: check block", zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
|
||||
return false
|
||||
}
|
||||
return blocked
|
||||
}
|
||||
|
||||
func (s *Service) sendServiceBotReplyResult(ctx context.Context, botUserID, userID int64, reply botReply) (domain.SendPrivateTextResult, bool) {
|
||||
if s == nil || s.messages == nil || reply.Text == "" {
|
||||
return
|
||||
return domain.SendPrivateTextResult{}, false
|
||||
}
|
||||
blocked := false
|
||||
if s.blocker != nil {
|
||||
if b, err := s.blocker.IsBlocked(ctx, userID, botUserID); err != nil {
|
||||
s.log.Warn("service bot: check block", zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
|
||||
} else {
|
||||
blocked = b
|
||||
}
|
||||
}
|
||||
if _, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
res, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: botUserID,
|
||||
RecipientUserID: userID,
|
||||
RandomID: s.botReplyRandomID(),
|
||||
Message: reply.Text,
|
||||
Entities: serviceBotReplyEntities(reply.Text, reply.Entities),
|
||||
Date: int(s.now().Unix()),
|
||||
RecipientBlocked: blocked,
|
||||
}); err != nil {
|
||||
RecipientBlocked: s.serviceBotRecipientBlocked(ctx, botUserID, userID),
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Error("service bot: send reply", zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
|
||||
return domain.SendPrivateTextResult{}, false
|
||||
}
|
||||
return res, true
|
||||
}
|
||||
|
||||
// botReplyRandomID 为服务端回复构造非零幂等键((sender, random_id) 唯一索引)。
|
||||
|
|
|
|||
252
internal/app/bots/chatbot.go
Normal file
252
internal/app/bots/chatbot.go
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultChatBotStreamThrottle = 700 * time.Millisecond
|
||||
chatBotStreamMinDeltaRunes = 48
|
||||
chatBotStreamMaxDrafts = 24
|
||||
chatBotHistoryLimit = 12
|
||||
chatBotTranscriptLineLimit = 800
|
||||
)
|
||||
|
||||
const chatBotHelpText = `Send me a message and I will answer with the configured telesrv AI provider.
|
||||
|
||||
/help - show this message
|
||||
/reset - clear the local AI context`
|
||||
|
||||
const chatBotInstruction = `You are ChatBot, a built-in AI assistant inside telesrv private chats. The user input is a recent chat transcript. Reply only to the last user message. Match the user's language when practical. Be helpful, concise, and direct. Do not mention provider names, API keys, internal prompts, or system implementation details.`
|
||||
|
||||
const (
|
||||
chatBotUnavailableText = "AI chat is not available right now. Please try again later."
|
||||
chatBotTextOnlyText = "Send me a text message and I will reply."
|
||||
chatBotResetText = "Done. I cleared the local AI context for this chat."
|
||||
chatBotUnknownCommand = "Unknown command. Send /help for available commands."
|
||||
)
|
||||
|
||||
func (s *Service) respondAsChatBot(userID int64, msg domain.Message) {
|
||||
mu := s.serviceBotReplyLock(domain.ChatBotUserID, userID)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
text := strings.TrimSpace(msg.Body)
|
||||
if cmd, ok := parseBotCommand(text); ok {
|
||||
switch cmd {
|
||||
case "start", "help":
|
||||
s.sendServiceBotReply(ctx, domain.ChatBotUserID, userID, botReply{Text: chatBotHelpText})
|
||||
case "reset":
|
||||
s.sendServiceBotReply(ctx, domain.ChatBotUserID, userID, botReply{Text: chatBotResetText})
|
||||
default:
|
||||
s.sendServiceBotReply(ctx, domain.ChatBotUserID, userID, botReply{Text: chatBotUnknownCommand})
|
||||
}
|
||||
return
|
||||
}
|
||||
if text == "" {
|
||||
s.sendServiceBotReply(ctx, domain.ChatBotUserID, userID, botReply{Text: chatBotTextOnlyText})
|
||||
return
|
||||
}
|
||||
if s.aiChat == nil {
|
||||
s.sendServiceBotReply(ctx, domain.ChatBotUserID, userID, botReply{Text: chatBotUnavailableText})
|
||||
return
|
||||
}
|
||||
if s.serviceBotRecipientBlocked(ctx, domain.ChatBotUserID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
streamer := chatBotDraftStreamer{
|
||||
service: s,
|
||||
userID: userID,
|
||||
randomID: s.botReplyRandomID(),
|
||||
}
|
||||
req := domain.AITextGenerationRequest{
|
||||
UserID: userID,
|
||||
Text: domain.AIComposeText{
|
||||
Text: s.chatBotPromptText(ctx, userID, msg),
|
||||
},
|
||||
Instruction: chatBotInstruction,
|
||||
}
|
||||
final, err := s.aiChat.GenerateTextStream(ctx, req, func(out domain.AIComposeText) error {
|
||||
if chatBotLooksLikePromptEcho(out.Text, req.Text.Text) {
|
||||
return nil
|
||||
}
|
||||
streamer.emit(ctx, out.Text, false)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Warn("chatbot: ai generation failed", zap.Int64("user_id", userID), zap.Error(err))
|
||||
s.finishChatBotReply(ctx, userID, &streamer, chatBotUnavailableText)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(final.Text) == "" || chatBotLooksLikePromptEcho(final.Text, req.Text.Text) {
|
||||
if strings.TrimSpace(final.Text) != "" {
|
||||
s.log.Warn("chatbot: provider echoed prompt", zap.Int64("user_id", userID))
|
||||
}
|
||||
s.finishChatBotReply(ctx, userID, &streamer, chatBotUnavailableText)
|
||||
return
|
||||
}
|
||||
s.finishChatBotReply(ctx, userID, &streamer, final.Text)
|
||||
}
|
||||
|
||||
func (s *Service) finishChatBotReply(ctx context.Context, userID int64, streamer *chatBotDraftStreamer, text string) {
|
||||
text = truncateRunes(strings.TrimSpace(text), domain.MaxMessageTextLength)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
streamer.emit(ctx, text, true)
|
||||
s.sendServiceBotReply(ctx, domain.ChatBotUserID, userID, botReply{Text: text})
|
||||
}
|
||||
|
||||
func (s *Service) chatBotPromptText(ctx context.Context, userID int64, msg domain.Message) string {
|
||||
current := strings.TrimSpace(msg.Body)
|
||||
lines := make([]string, 0, chatBotHistoryLimit+1)
|
||||
if s != nil && s.messages != nil {
|
||||
list, err := s.messages.ListByUser(ctx, userID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.ChatBotUserID},
|
||||
Limit: chatBotHistoryLimit,
|
||||
})
|
||||
if err == nil {
|
||||
sort.SliceStable(list.Messages, func(i, j int) bool { return list.Messages[i].ID < list.Messages[j].ID })
|
||||
sawCurrent := false
|
||||
for _, item := range list.Messages {
|
||||
body := strings.TrimSpace(item.Body)
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
if item.From.ID != domain.ChatBotUserID {
|
||||
if cmd, ok := parseBotCommand(body); ok {
|
||||
if cmd == "reset" {
|
||||
lines = lines[:0]
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if item.From.ID == domain.ChatBotUserID && chatBotCommandReply(body) {
|
||||
continue
|
||||
}
|
||||
if msg.UID != 0 && item.UID == msg.UID {
|
||||
sawCurrent = true
|
||||
}
|
||||
speaker := "User"
|
||||
if item.From.ID == domain.ChatBotUserID {
|
||||
speaker = "Assistant"
|
||||
}
|
||||
lines = append(lines, chatBotTranscriptLine(speaker, body))
|
||||
}
|
||||
if !sawCurrent && current != "" {
|
||||
lines = append(lines, chatBotTranscriptLine("User", current))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 && current != "" {
|
||||
lines = append(lines, chatBotTranscriptLine("User", current))
|
||||
}
|
||||
return chatBotClampPrompt(lines)
|
||||
}
|
||||
|
||||
func chatBotTranscriptLine(speaker, text string) string {
|
||||
text = strings.Join(strings.Fields(text), " ")
|
||||
text = truncateRunes(text, chatBotTranscriptLineLimit)
|
||||
return speaker + ": " + text
|
||||
}
|
||||
|
||||
func chatBotCommandReply(text string) bool {
|
||||
text = strings.TrimSpace(text)
|
||||
return text == chatBotHelpText || text == chatBotResetText || text == chatBotUnknownCommand || text == chatBotTextOnlyText
|
||||
}
|
||||
|
||||
func chatBotLooksLikePromptEcho(text, prompt string) bool {
|
||||
text = strings.TrimSpace(text)
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
if prompt != "" && (strings.HasPrefix(text, prompt) || (utf8.RuneCountInString(text) >= 64 && strings.HasPrefix(prompt, text))) {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(text, "User: ") && strings.Contains(text, "\nAssistant:")
|
||||
}
|
||||
|
||||
func chatBotClampPrompt(lines []string) string {
|
||||
for len(lines) > 0 {
|
||||
out := strings.Join(lines, "\n")
|
||||
if utf8.RuneCountInString(out) <= domain.MaxAIComposeTextLength {
|
||||
return out
|
||||
}
|
||||
lines = lines[1:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type chatBotDraftStreamer struct {
|
||||
service *Service
|
||||
userID int64
|
||||
randomID int64
|
||||
lastText string
|
||||
lastFlush time.Time
|
||||
drafts int
|
||||
}
|
||||
|
||||
func (e *chatBotDraftStreamer) emit(ctx context.Context, text string, final bool) {
|
||||
if e == nil || e.service == nil || e.service.textDrafts == nil || e.userID == 0 || e.randomID == 0 {
|
||||
return
|
||||
}
|
||||
text = truncateRunes(strings.TrimSpace(text), domain.MaxMessageTextLength)
|
||||
if text == "" || text == e.lastText {
|
||||
return
|
||||
}
|
||||
if !final && !e.shouldFlush(text) {
|
||||
return
|
||||
}
|
||||
e.service.textDrafts.PushBotTextDraft(ctx, domain.ChatBotUserID, e.userID, e.randomID, text)
|
||||
e.lastText = text
|
||||
e.lastFlush = e.service.now()
|
||||
e.drafts++
|
||||
}
|
||||
|
||||
func (e *chatBotDraftStreamer) shouldFlush(next string) bool {
|
||||
if e.drafts == 0 {
|
||||
return true
|
||||
}
|
||||
if e.drafts >= chatBotStreamMaxDrafts {
|
||||
return false
|
||||
}
|
||||
throttle := e.service.chatBotStreamThrottle
|
||||
if throttle <= 0 {
|
||||
return true
|
||||
}
|
||||
if e.service.now().Sub(e.lastFlush) < throttle {
|
||||
return false
|
||||
}
|
||||
return utf8.RuneCountInString(next)-utf8.RuneCountInString(e.lastText) >= chatBotStreamMinDeltaRunes
|
||||
}
|
||||
|
||||
func truncateRunes(text string, limit int) string {
|
||||
if limit <= 0 || utf8.RuneCountInString(text) <= limit {
|
||||
return text
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(text))
|
||||
count := 0
|
||||
for _, r := range text {
|
||||
if count >= limit {
|
||||
break
|
||||
}
|
||||
b.WriteRune(r)
|
||||
count++
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
354
internal/app/bots/chatbot_test.go
Normal file
354
internal/app/bots/chatbot_test.go
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
messageapp "telesrv/internal/app/messages"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type fakeChatAI struct {
|
||||
chunks []string
|
||||
final string
|
||||
err error
|
||||
req domain.AITextGenerationRequest
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeChatAI) GenerateTextStream(_ context.Context, req domain.AITextGenerationRequest, emit func(domain.AIComposeText) error) (domain.AIComposeText, error) {
|
||||
f.calls++
|
||||
f.req = req
|
||||
if f.err != nil {
|
||||
return domain.AIComposeText{}, f.err
|
||||
}
|
||||
for _, chunk := range f.chunks {
|
||||
if emit != nil {
|
||||
if err := emit(domain.AIComposeText{Text: chunk}); err != nil {
|
||||
return domain.AIComposeText{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
final := f.final
|
||||
if final == "" && len(f.chunks) > 0 {
|
||||
final = f.chunks[len(f.chunks)-1]
|
||||
}
|
||||
return domain.AIComposeText{Text: final}, nil
|
||||
}
|
||||
|
||||
func newChatBotTestService(t *testing.T, ai *fakeChatAI, opts ...Option) (*Service, *memory.UserStore, *memory.BotStore, *memory.MessageStore) {
|
||||
t.Helper()
|
||||
users := memory.NewUserStore()
|
||||
bots := memory.NewBotStore(users)
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
all := []Option{WithAIChatGenerator(ai), WithAIChatStreamThrottle(0)}
|
||||
all = append(all, opts...)
|
||||
return NewService(users, bots, messages, all...), users, bots, messages
|
||||
}
|
||||
|
||||
func latestChatBotReply(t *testing.T, messages *memory.MessageStore, userID int64) domain.Message {
|
||||
t.Helper()
|
||||
list, err := messages.ListByUser(context.Background(), userID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.ChatBotUserID},
|
||||
Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list chatbot history: %v", err)
|
||||
}
|
||||
var latest domain.Message
|
||||
for _, msg := range list.Messages {
|
||||
if msg.From.ID == domain.ChatBotUserID && msg.ID > latest.ID {
|
||||
latest = msg
|
||||
}
|
||||
}
|
||||
if latest.ID == 0 {
|
||||
t.Fatal("no ChatBot reply")
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func waitForChatBotReply(t *testing.T, messages *memory.MessageStore, userID int64, body string) domain.Message {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
list, err := messages.ListByUser(context.Background(), userID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.ChatBotUserID},
|
||||
Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list chatbot history: %v", err)
|
||||
}
|
||||
for _, msg := range list.Messages {
|
||||
if msg.From.ID == domain.ChatBotUserID && (body == "" || msg.Body == body) {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for ChatBot reply %q", body)
|
||||
return domain.Message{}
|
||||
}
|
||||
|
||||
func TestChatBotSystemSeedAndCommands(t *testing.T) {
|
||||
ai := &fakeChatAI{}
|
||||
svc, users, bots, messages := newChatBotTestService(t, ai)
|
||||
owner := newOwner(t, users, "+4000")
|
||||
ctx := context.Background()
|
||||
|
||||
if !svc.HandlesBot(domain.ChatBotUserID) {
|
||||
t.Fatal("service should handle ChatBot")
|
||||
}
|
||||
u, found, err := users.ByUsername(ctx, "ChatBot")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("@ChatBot user not seeded: found=%v err=%v", found, err)
|
||||
}
|
||||
if u.ID != domain.ChatBotUserID || !u.Bot || u.BotInfoVersion < 1 {
|
||||
t.Fatalf("@ChatBot user = %+v, want seeded bot", u)
|
||||
}
|
||||
profile, found, err := bots.GetBot(ctx, domain.ChatBotUserID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("@ChatBot profile not seeded: found=%v err=%v", found, err)
|
||||
}
|
||||
if !botCommandExists(profile.Commands, "start") || !botCommandExists(profile.Commands, "help") || !botCommandExists(profile.Commands, "reset") {
|
||||
t.Fatalf("@ChatBot commands = %+v, want start/help/reset", profile.Commands)
|
||||
}
|
||||
|
||||
svc.respondAsChatBot(owner.ID, domain.Message{From: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, Body: "/start"})
|
||||
reply := latestChatBotReply(t, messages, owner.ID)
|
||||
if !strings.Contains(reply.Body, "/help") || ai.calls != 0 {
|
||||
t.Fatalf("/start reply=%q ai_calls=%d, want help without AI", reply.Body, ai.calls)
|
||||
}
|
||||
assertReplyEntityText(t, reply, domain.MessageEntityBotCommand, "/help")
|
||||
}
|
||||
|
||||
func TestChatBotStreamsByTypingDraftThenFinalMessage(t *testing.T) {
|
||||
ai := &fakeChatAI{
|
||||
chunks: []string{"Hel", "Hello from AI"},
|
||||
final: "Hello from AI",
|
||||
}
|
||||
svc, users, _, messages := newChatBotTestService(t, ai)
|
||||
hooks := &chatBotHookRecorder{}
|
||||
svc.SetTextDraftPusher(hooks)
|
||||
owner := newOwner(t, users, "+4001")
|
||||
|
||||
svc.respondAsChatBot(owner.ID, domain.Message{
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.ChatBotUserID},
|
||||
Body: "hello",
|
||||
})
|
||||
|
||||
reply := latestChatBotReply(t, messages, owner.ID)
|
||||
if reply.Body != "Hello from AI" || reply.EditDate != 0 {
|
||||
t.Fatalf("ChatBot reply = body %q edit_date %d, want ordinary final message", reply.Body, reply.EditDate)
|
||||
}
|
||||
if ai.calls != 1 {
|
||||
t.Fatalf("AI calls = %d, want 1", ai.calls)
|
||||
}
|
||||
if ai.req.UserID != owner.ID || !strings.Contains(ai.req.Text.Text, "hello") || !strings.Contains(ai.req.Instruction, "ChatBot") {
|
||||
t.Fatalf("AI request = %#v", ai.req)
|
||||
}
|
||||
if len(hooks.drafts) < 2 {
|
||||
t.Fatalf("draft pushes = %+v, want streamed chunks", hooks.drafts)
|
||||
}
|
||||
randomID := hooks.drafts[0].randomID
|
||||
if randomID == 0 {
|
||||
t.Fatal("draft random_id = 0, want fixed non-zero id")
|
||||
}
|
||||
for _, draft := range hooks.drafts {
|
||||
if draft.botUserID != domain.ChatBotUserID || draft.userID != owner.ID || draft.randomID != randomID {
|
||||
t.Fatalf("draft push = %+v, want same bot/user/random_id", draft)
|
||||
}
|
||||
}
|
||||
if got := hooks.drafts[len(hooks.drafts)-1].text; got != "Hello from AI" {
|
||||
t.Fatalf("last draft text = %q, want final cumulative text", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatBotRespondsFromMessageSendHook(t *testing.T) {
|
||||
ai := &fakeChatAI{
|
||||
chunks: []string{"hooked reply"},
|
||||
final: "hooked reply",
|
||||
}
|
||||
users := memory.NewUserStore()
|
||||
botsStore := memory.NewBotStore(users)
|
||||
dialogsStore := memory.NewDialogStore()
|
||||
messageStore := memory.NewMessageStore(dialogsStore)
|
||||
botSvc := NewService(users, botsStore, messageStore, WithAIChatGenerator(ai), WithAIChatStreamThrottle(0))
|
||||
hooks := &chatBotHookRecorder{}
|
||||
botSvc.SetTextDraftPusher(hooks)
|
||||
messageSvc := messageapp.NewService(messageStore, dialogsStore, messageapp.WithBotResponder(botSvc))
|
||||
owner := newOwner(t, users, "+4005")
|
||||
|
||||
if _, err := messageSvc.SendPrivateText(context.Background(), owner.ID, domain.SendPrivateTextRequest{
|
||||
RecipientUserID: domain.ChatBotUserID,
|
||||
RandomID: 4005,
|
||||
Message: "hello hook",
|
||||
}); err != nil {
|
||||
t.Fatalf("send to ChatBot through messages service: %v", err)
|
||||
}
|
||||
|
||||
reply := waitForChatBotReply(t, messageStore, owner.ID, "hooked reply")
|
||||
if reply.EditDate != 0 {
|
||||
t.Fatalf("hook reply edit_date = %d, want ordinary final message", reply.EditDate)
|
||||
}
|
||||
if len(hooks.drafts) == 0 {
|
||||
t.Fatal("draft pushes = 0, want streamed draft from message hook")
|
||||
}
|
||||
if count := strings.Count(ai.req.Text.Text, "hello hook"); count != 1 {
|
||||
t.Fatalf("prompt = %q, want current user text once", ai.req.Text.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatBotResetClearsPromptContextAndSkipsCommandReplies(t *testing.T) {
|
||||
ai := &fakeChatAI{
|
||||
chunks: []string{"fresh reply"},
|
||||
final: "fresh reply",
|
||||
}
|
||||
users := memory.NewUserStore()
|
||||
botsStore := memory.NewBotStore(users)
|
||||
dialogsStore := memory.NewDialogStore()
|
||||
messageStore := memory.NewMessageStore(dialogsStore)
|
||||
botSvc := NewService(users, botsStore, messageStore, WithAIChatGenerator(ai), WithAIChatStreamThrottle(0))
|
||||
messageSvc := messageapp.NewService(messageStore, dialogsStore, messageapp.WithBotResponder(botSvc))
|
||||
owner := newOwner(t, users, "+4006")
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := messageSvc.SendPrivateText(ctx, owner.ID, domain.SendPrivateTextRequest{
|
||||
RecipientUserID: domain.ChatBotUserID,
|
||||
RandomID: 40060,
|
||||
Message: "old question",
|
||||
}); err != nil {
|
||||
t.Fatalf("send old question: %v", err)
|
||||
}
|
||||
waitForChatBotReply(t, messageStore, owner.ID, "fresh reply")
|
||||
|
||||
if _, err := messageSvc.SendPrivateText(ctx, owner.ID, domain.SendPrivateTextRequest{
|
||||
RecipientUserID: domain.ChatBotUserID,
|
||||
RandomID: 40061,
|
||||
Message: "/reset",
|
||||
}); err != nil {
|
||||
t.Fatalf("send reset: %v", err)
|
||||
}
|
||||
waitForChatBotReply(t, messageStore, owner.ID, chatBotResetText)
|
||||
|
||||
if _, err := messageSvc.SendPrivateText(ctx, owner.ID, domain.SendPrivateTextRequest{
|
||||
RecipientUserID: domain.ChatBotUserID,
|
||||
RandomID: 40062,
|
||||
Message: "fresh question",
|
||||
}); err != nil {
|
||||
t.Fatalf("send fresh question: %v", err)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if ai.calls >= 2 && strings.Contains(ai.req.Text.Text, "fresh question") {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
prompt := ai.req.Text.Text
|
||||
if !strings.Contains(prompt, "fresh question") || strings.Contains(prompt, "old question") || strings.Contains(prompt, "/reset") || strings.Contains(prompt, chatBotResetText) {
|
||||
t.Fatalf("prompt after reset = %q", prompt)
|
||||
}
|
||||
if count := strings.Count(prompt, "fresh question"); count != 1 {
|
||||
t.Fatalf("fresh question count = %d in prompt %q, want 1", count, prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatBotPromptEchoIsNotPersisted(t *testing.T) {
|
||||
ai := &fakeChatAI{
|
||||
chunks: []string{"User: hello\nAssistant: leaked prompt"},
|
||||
final: "User: hello\nAssistant: leaked prompt",
|
||||
}
|
||||
svc, users, _, messages := newChatBotTestService(t, ai)
|
||||
hooks := &chatBotHookRecorder{}
|
||||
svc.SetTextDraftPusher(hooks)
|
||||
owner := newOwner(t, users, "+4007")
|
||||
|
||||
svc.respondAsChatBot(owner.ID, domain.Message{
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.ChatBotUserID},
|
||||
Body: "hello",
|
||||
})
|
||||
|
||||
reply := latestChatBotReply(t, messages, owner.ID)
|
||||
if reply.Body != chatBotUnavailableText {
|
||||
t.Fatalf("reply body = %q, want unavailable fallback", reply.Body)
|
||||
}
|
||||
if len(hooks.drafts) != 1 || hooks.drafts[0].text != chatBotUnavailableText {
|
||||
t.Fatalf("draft pushes = %+v, want only unavailable fallback", hooks.drafts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatBotProviderFailureSendsFallbackMessage(t *testing.T) {
|
||||
ai := &fakeChatAI{err: errors.New("provider down")}
|
||||
svc, users, _, messages := newChatBotTestService(t, ai)
|
||||
hooks := &chatBotHookRecorder{}
|
||||
svc.SetTextDraftPusher(hooks)
|
||||
owner := newOwner(t, users, "+4002")
|
||||
|
||||
svc.respondAsChatBot(owner.ID, domain.Message{
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.ChatBotUserID},
|
||||
Body: "hello",
|
||||
})
|
||||
|
||||
reply := latestChatBotReply(t, messages, owner.ID)
|
||||
if reply.Body != chatBotUnavailableText || reply.EditDate != 0 {
|
||||
t.Fatalf("fallback reply = body %q edit_date %d", reply.Body, reply.EditDate)
|
||||
}
|
||||
if len(hooks.drafts) != 1 || hooks.drafts[0].text != chatBotUnavailableText {
|
||||
t.Fatalf("fallback draft pushes = %+v, want one fallback draft", hooks.drafts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatBotRespectsBlockBeforeAI(t *testing.T) {
|
||||
ai := &fakeChatAI{final: "should not call"}
|
||||
blocker := &stubBlocker{blocked: true}
|
||||
svc, users, _, messages := newChatBotTestService(t, ai, WithBlockChecker(blocker))
|
||||
owner := newOwner(t, users, "+4003")
|
||||
|
||||
svc.respondAsChatBot(owner.ID, domain.Message{
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.ChatBotUserID},
|
||||
Body: "hello",
|
||||
})
|
||||
if ai.calls != 0 {
|
||||
t.Fatalf("AI calls = %d, want 0 for blocked ChatBot", ai.calls)
|
||||
}
|
||||
if blocker.gotUser != owner.ID || blocker.gotPeer != domain.ChatBotUserID {
|
||||
t.Fatalf("IsBlocked called with (%d,%d), want (%d,%d)", blocker.gotUser, blocker.gotPeer, owner.ID, domain.ChatBotUserID)
|
||||
}
|
||||
list, err := messages.ListByUser(context.Background(), owner.ID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.ChatBotUserID},
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list history: %v", err)
|
||||
}
|
||||
if len(list.Messages) != 0 {
|
||||
t.Fatalf("blocked user received messages: %+v", list.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
type chatBotDraftPush struct {
|
||||
botUserID int64
|
||||
userID int64
|
||||
randomID int64
|
||||
text string
|
||||
}
|
||||
|
||||
type chatBotHookRecorder struct {
|
||||
drafts []chatBotDraftPush
|
||||
}
|
||||
|
||||
func (h *chatBotHookRecorder) PushBotTextDraft(_ context.Context, botUserID, userID, randomID int64, text string) {
|
||||
h.drafts = append(h.drafts, chatBotDraftPush{botUserID: botUserID, userID: userID, randomID: randomID, text: text})
|
||||
}
|
||||
|
|
@ -42,8 +42,12 @@ type userStickerSetInstaller interface {
|
|||
InstallUserStickerSet(ctx context.Context, userID int64, setID int64, kind domain.StickerSetKind, archived bool, installedDate int) error
|
||||
}
|
||||
|
||||
type aiChatGenerator interface {
|
||||
GenerateTextStream(ctx context.Context, req domain.AITextGenerationRequest, emit func(domain.AIComposeText) error) (domain.AIComposeText, error)
|
||||
}
|
||||
|
||||
// RouterHooks 是 rpc 层回调(router 创建后经 SetRouterHooks 延迟注入,打破
|
||||
// router↔bots 的构造循环;两个能力都依赖 TL/连接层边界,不能在 app 层实现):
|
||||
// router↔bots 的构造循环;这些能力都依赖 TL/连接层边界,不能在 app 层实现):
|
||||
// - RevokeBotSessions:token revoke 后撤销 bot 的全部已登录 session(删
|
||||
// authorization + 强制断连)。
|
||||
// - PushBotCommandsChanged:命令变更后给在线相关用户推 updateBotCommands
|
||||
|
|
@ -56,24 +60,34 @@ type RouterHooks interface {
|
|||
PushStickerSetsChanged(ctx context.Context, userID int64, kind domain.StickerSetKind)
|
||||
}
|
||||
|
||||
// TextDraftPusher 推送 @ChatBot AI 流式回复的 transient 文本草稿,由 rpc 层转换为
|
||||
// UpdateUserTyping/sendMessageTextDraftAction。它独立于普通 bot hooks,避免 BotFather
|
||||
// 和 @Stickers 的测试/依赖被 AI 对话能力污染。
|
||||
type TextDraftPusher interface {
|
||||
PushBotTextDraft(ctx context.Context, botUserID, userID, randomID int64, text string)
|
||||
}
|
||||
|
||||
// replyLockStripes 是回复串行化条带数:同一用户的 BotFather 回复落同一条带、
|
||||
// 串行执行(状态机 RMW 原子 + 回复保序),不同用户并发;固定大小不随用户数增长。
|
||||
const replyLockStripes = 256
|
||||
|
||||
// Service 提供 bot 账号业务。
|
||||
type Service struct {
|
||||
users store.UserStore
|
||||
bots store.BotStore
|
||||
messages store.MessageStore
|
||||
blocker blockChecker
|
||||
channels publicChannelUsernameResolver
|
||||
stickers stickerSetCreator
|
||||
installer userStickerSetInstaller
|
||||
hooks RouterHooks
|
||||
userCache store.UserCache
|
||||
cache *botProfileCache
|
||||
log *zap.Logger
|
||||
now func() time.Time
|
||||
users store.UserStore
|
||||
bots store.BotStore
|
||||
messages store.MessageStore
|
||||
blocker blockChecker
|
||||
channels publicChannelUsernameResolver
|
||||
stickers stickerSetCreator
|
||||
installer userStickerSetInstaller
|
||||
aiChat aiChatGenerator
|
||||
hooks RouterHooks
|
||||
textDrafts TextDraftPusher
|
||||
userCache store.UserCache
|
||||
cache *botProfileCache
|
||||
log *zap.Logger
|
||||
now func() time.Time
|
||||
chatBotStreamThrottle time.Duration
|
||||
// replySeq 是回复 randomID 在 crypto/rand 失败时的兜底单调序列。
|
||||
replySeq atomic.Int64
|
||||
replyLocks [replyLockStripes]sync.Mutex
|
||||
|
|
@ -150,6 +164,24 @@ func WithUserStickerSets(c userStickerSetInstaller) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithAIChatGenerator 注入内置 @ChatBot 使用的 AI 文本生成器。
|
||||
func WithAIChatGenerator(g aiChatGenerator) Option {
|
||||
return func(s *Service) {
|
||||
if g != nil {
|
||||
s.aiChat = g
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithAIChatStreamThrottle 调整 @ChatBot 流式草稿推送的最小时间间隔(测试用)。
|
||||
func WithAIChatStreamThrottle(d time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
if d >= 0 {
|
||||
s.chatBotStreamThrottle = d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// invalidateUserCache 在 bot 的 users 行变更(含 version bump)后清缓存。
|
||||
// 失效失败只记日志:缓存最长 TTL 后自愈,不阻塞写路径。
|
||||
func (s *Service) invalidateUserCache(ctx context.Context, botUserID int64) {
|
||||
|
|
@ -197,15 +229,29 @@ func (s *Service) SetRouterHooks(h RouterHooks) {
|
|||
}
|
||||
}
|
||||
|
||||
// SetTextDraftPusher 注入 @ChatBot 流式草稿推送边界。
|
||||
func (s *Service) SetTextDraftPusher(p TextDraftPusher) {
|
||||
if s != nil {
|
||||
s.textDrafts = p
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) SetAIChatGenerator(g aiChatGenerator) {
|
||||
if s != nil {
|
||||
s.aiChat = g
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建 bots 服务。
|
||||
func NewService(users store.UserStore, bots store.BotStore, messages store.MessageStore, opts ...Option) *Service {
|
||||
s := &Service{
|
||||
users: users,
|
||||
bots: bots,
|
||||
messages: messages,
|
||||
cache: newBotProfileCache(botProfileCacheMaxEntries, botProfileCacheTTL),
|
||||
log: zap.NewNop(),
|
||||
now: time.Now,
|
||||
users: users,
|
||||
bots: bots,
|
||||
messages: messages,
|
||||
cache: newBotProfileCache(botProfileCacheMaxEntries, botProfileCacheTTL),
|
||||
log: zap.NewNop(),
|
||||
now: time.Now,
|
||||
chatBotStreamThrottle: defaultChatBotStreamThrottle,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue