feat: sync AI compose and ChatBot features

This commit is contained in:
A 2026-07-03 19:43:20 +08:00
parent 35e5d38f4d
commit b7269b135f
75 changed files with 5426 additions and 123 deletions

View file

@ -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) 唯一索引)。

View 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()
}

View 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})
}

View file

@ -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)