feat(conversation): send welcome message on new chat
This commit is contained in:
@@ -107,6 +107,7 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha
|
||||
}
|
||||
|
||||
var conversation *models.Conversation
|
||||
var welcomeMessage *models.Message
|
||||
created := false
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
customerID, err := CustomerService.EnsureExternalCustomer(ctx, externalUser)
|
||||
@@ -149,7 +150,11 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha
|
||||
if err := ConversationParticipantService.CreateCustomerParticipant(ctx, conversation.ID, externalUser); err != nil {
|
||||
return err
|
||||
}
|
||||
return ConversationEventLogService.CreateEvent(ctx, conversation.ID, enums.IMEventTypeCreate, enums.IMSenderTypeCustomer, 0, "用户创建会话", "")
|
||||
if err := ConversationEventLogService.CreateEvent(ctx, conversation.ID, enums.IMEventTypeCreate, enums.IMSenderTypeCustomer, 0, "用户创建会话", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
welcomeMessage, err = MessageService.CreateAIWelcomeMessageTx(ctx, conversation, aiAgent, now)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -162,6 +167,12 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha
|
||||
|
||||
// 推送会话创建事件
|
||||
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationCreated)
|
||||
if welcomeMessage != nil {
|
||||
if updatedConversation := s.Get(conversation.ID); updatedConversation != nil {
|
||||
WsService.PublishMessageCreated(updatedConversation, welcomeMessage)
|
||||
WsService.PublishConversationChanged(updatedConversation, enums.IMRealtimeEventConversationUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
if aiAgent.ServiceMode == enums.IMConversationServiceModeHumanOnly {
|
||||
if _, err := ConversationHumanDispatchService.ApplyHumanOnlyCreate(conversation.ID, *aiAgent); err != nil {
|
||||
|
||||
@@ -258,6 +258,97 @@ func (s *messageService) SendAIServiceNotice(conversationID int64, aiAgentID int
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (s *messageService) CreateAIWelcomeMessageTx(ctx *sqls.TxContext, conversation *models.Conversation, aiAgent *models.AIAgent, now time.Time) (*models.Message, error) {
|
||||
if ctx == nil || conversation == nil || aiAgent == nil || strings.TrimSpace(aiAgent.WelcomeMessage) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
content, payload, summary, err := s.normalizeMessageContent(conversation.ID, enums.IMMessageTypeText, aiAgent.WelcomeMessage, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strs.IsBlank(content) && strs.IsBlank(payload) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
operator := &dto.AuthPrincipal{
|
||||
UserID: 0,
|
||||
Username: "system",
|
||||
Nickname: "system",
|
||||
}
|
||||
message := &models.Message{
|
||||
ConversationID: conversation.ID,
|
||||
SenderType: enums.IMSenderTypeAI,
|
||||
SenderID: aiAgent.ID,
|
||||
MessageType: enums.IMMessageTypeText,
|
||||
Content: content,
|
||||
Payload: payload,
|
||||
SeqNo: repositories.MessageRepository.NextSeqNo(ctx.Tx, conversation.ID),
|
||||
SendStatus: enums.IMMessageStatusSent,
|
||||
SentAt: &now,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: operator.UserID,
|
||||
CreateUserName: operator.Username,
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: operator.UserID,
|
||||
UpdateUserName: operator.Username,
|
||||
},
|
||||
}
|
||||
if err := repositories.MessageRepository.Create(ctx.Tx, message); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := ConversationReadStateService.MarkAgentRead(ctx, conversation, operator, message, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, conversation.ID)
|
||||
agentUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readSeqNo(agentReadState), enums.IMSenderTypeCustomer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
customerUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readSeqNo(customerReadState), enums.IMSenderTypeAgent, enums.IMSenderTypeAI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conversationUpdates := map[string]any{
|
||||
"last_message_id": message.ID,
|
||||
"last_message_at": now,
|
||||
"last_active_at": now,
|
||||
"last_message_summary": limitText(summary, 255),
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
"agent_unread_count": agentUnreadCount,
|
||||
"customer_unread_count": customerUnreadCount,
|
||||
}
|
||||
if err := repositories.ConversationRepository.Updates(ctx.Tx, conversation.ID, conversationUpdates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ConversationEventLogService.CreateEvent(ctx,
|
||||
conversation.ID,
|
||||
enums.IMEventTypeMessageSend,
|
||||
enums.IMSenderTypeAI,
|
||||
0,
|
||||
enums.GetIMSenderTypeLabel(enums.IMSenderTypeAI)+"发送消息",
|
||||
"",
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conversation.LastMessageID = message.ID
|
||||
conversation.LastMessageAt = now
|
||||
conversation.LastActiveAt = now
|
||||
conversation.LastMessageSummary = limitText(summary, 255)
|
||||
conversation.AgentUnreadCount = int(agentUnreadCount)
|
||||
conversation.CustomerUnreadCount = int(customerUnreadCount)
|
||||
conversation.UpdatedAt = now
|
||||
conversation.UpdateUserID = operator.UserID
|
||||
conversation.UpdateUserName = operator.Username
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func (s *messageService) SendCustomerMessage(conversationID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, external openidentity.ExternalUser) (*models.Message, error) {
|
||||
ext := external
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, &ext)
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/openidentity"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestAllowAIMessageOnPendingHandoff(t *testing.T) {
|
||||
@@ -27,3 +34,210 @@ func TestAllowAIMessageOnPendingHandoff(t *testing.T) {
|
||||
func ptrTime(v time.Time) *time.Time {
|
||||
return &v
|
||||
}
|
||||
|
||||
func setupMessageWelcomeTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
dbName := "message_welcome_test_" + strings.NewReplacer("/", "_").Replace(t.Name())
|
||||
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "t_",
|
||||
SingularTable: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite db: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("get sqlite db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Fatalf("close sqlite db: %v", err)
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(
|
||||
&models.AIAgent{},
|
||||
&models.Channel{},
|
||||
&models.ChannelMessageOutbox{},
|
||||
&models.Customer{},
|
||||
&models.CustomerIdentity{},
|
||||
&models.Conversation{},
|
||||
&models.ConversationParticipant{},
|
||||
&models.ConversationReadState{},
|
||||
&models.ConversationEventLog{},
|
||||
&models.Message{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
return db
|
||||
}
|
||||
|
||||
func createWelcomeTestAIAgent(t *testing.T, db *gorm.DB, welcomeMessage string) *models.AIAgent {
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
aiAgent := &models.AIAgent{
|
||||
Name: "welcome-test-agent",
|
||||
Status: enums.StatusOk,
|
||||
ServiceMode: enums.IMConversationServiceModeAIOnly,
|
||||
WelcomeMessage: welcomeMessage,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
if err := db.Create(aiAgent).Error; err != nil {
|
||||
t.Fatalf("create ai agent: %v", err)
|
||||
}
|
||||
return aiAgent
|
||||
}
|
||||
|
||||
func welcomeTestExternalUser(id string) openidentity.ExternalUser {
|
||||
return openidentity.ExternalUser{
|
||||
ExternalSource: enums.ExternalSourceUser,
|
||||
ExternalID: id,
|
||||
ExternalName: "访客" + id,
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationCreateCreatesAIWelcomeMessage(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, " 您好,请问有什么可以帮您? ")
|
||||
|
||||
conversation, err := ConversationService.Create(welcomeTestExternalUser("welcome-1"), 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
if conversation == nil {
|
||||
t.Fatalf("expected conversation")
|
||||
}
|
||||
|
||||
var messages []models.Message
|
||||
if err := db.Find(&messages).Error; err != nil {
|
||||
t.Fatalf("find messages: %v", err)
|
||||
}
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("expected exactly one welcome message, got %d", len(messages))
|
||||
}
|
||||
message := messages[0]
|
||||
if message.ConversationID != conversation.ID {
|
||||
t.Fatalf("expected conversation_id %d, got %d", conversation.ID, message.ConversationID)
|
||||
}
|
||||
if message.SenderType != enums.IMSenderTypeAI {
|
||||
t.Fatalf("expected sender type ai, got %q", message.SenderType)
|
||||
}
|
||||
if message.SenderID != aiAgent.ID {
|
||||
t.Fatalf("expected sender id %d, got %d", aiAgent.ID, message.SenderID)
|
||||
}
|
||||
if message.MessageType != enums.IMMessageTypeText {
|
||||
t.Fatalf("expected message type text, got %q", message.MessageType)
|
||||
}
|
||||
if message.Content != "您好,请问有什么可以帮您?" {
|
||||
t.Fatalf("expected trimmed welcome content, got %q", message.Content)
|
||||
}
|
||||
if message.SeqNo != 1 {
|
||||
t.Fatalf("expected seq no 1, got %d", message.SeqNo)
|
||||
}
|
||||
if message.SendStatus != enums.IMMessageStatusSent {
|
||||
t.Fatalf("expected sent status, got %d", message.SendStatus)
|
||||
}
|
||||
|
||||
var updated models.Conversation
|
||||
if err := db.First(&updated, conversation.ID).Error; err != nil {
|
||||
t.Fatalf("find conversation: %v", err)
|
||||
}
|
||||
if updated.LastMessageID != message.ID {
|
||||
t.Fatalf("expected last message id %d, got %d", message.ID, updated.LastMessageID)
|
||||
}
|
||||
if updated.LastMessageSummary != "您好,请问有什么可以帮您?" {
|
||||
t.Fatalf("expected last message summary, got %q", updated.LastMessageSummary)
|
||||
}
|
||||
if updated.CustomerUnreadCount != 1 {
|
||||
t.Fatalf("expected customer unread count 1, got %d", updated.CustomerUnreadCount)
|
||||
}
|
||||
if updated.AgentUnreadCount != 0 {
|
||||
t.Fatalf("expected agent unread count 0, got %d", updated.AgentUnreadCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationCreateDoesNotDuplicateWelcomeMessageForExistingConversation(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, "欢迎咨询")
|
||||
external := welcomeTestExternalUser("u-2")
|
||||
|
||||
first, err := ConversationService.Create(external, 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create first conversation: %v", err)
|
||||
}
|
||||
second, err := ConversationService.Create(external, 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create second conversation: %v", err)
|
||||
}
|
||||
if first.ID != second.ID {
|
||||
t.Fatalf("expected existing conversation id %d, got %d", first.ID, second.ID)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&models.Message{}).Where("conversation_id = ?", first.ID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count messages: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected exactly one welcome message, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationCreateSkipsBlankWelcomeMessage(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, " ")
|
||||
|
||||
conversation, err := ConversationService.Create(welcomeTestExternalUser("blank-welcome-1"), 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
if conversation == nil {
|
||||
t.Fatalf("expected conversation")
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&models.Message{}).Where("conversation_id = ?", conversation.ID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count messages: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected no welcome messages, got %d", count)
|
||||
}
|
||||
|
||||
var updated models.Conversation
|
||||
if err := db.First(&updated, conversation.ID).Error; err != nil {
|
||||
t.Fatalf("find conversation: %v", err)
|
||||
}
|
||||
if updated.LastMessageID != 0 {
|
||||
t.Fatalf("expected last message id 0, got %d", updated.LastMessageID)
|
||||
}
|
||||
if updated.CustomerUnreadCount != 0 {
|
||||
t.Fatalf("expected customer unread count 0, got %d", updated.CustomerUnreadCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationCreateWelcomeMessageDoesNotTriggerAIReplyHook(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, "欢迎咨询")
|
||||
|
||||
previousHook := TriggerAIReplyAsyncHook
|
||||
called := false
|
||||
TriggerAIReplyAsyncHook = func(conversation models.Conversation, message models.Message) {
|
||||
called = true
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
TriggerAIReplyAsyncHook = previousHook
|
||||
})
|
||||
|
||||
if _, err := ConversationService.Create(welcomeTestExternalUser("hook-welcome-1"), 11, aiAgent.ID); err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatalf("expected welcome message not to trigger ai reply hook")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +232,12 @@ export const KefuMessageList = forwardRef<KefuMessageListHandle, KefuMessageList
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{safeMessages.length === 0 ? (
|
||||
<div className="flex min-h-32 items-center justify-center px-3 py-6 text-center text-sm leading-6 text-muted-foreground">
|
||||
请描述你的问题,我们会尽快为你处理。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{safeMessages.map((message, index) => {
|
||||
const previousMessage = index > 0 ? safeMessages[index - 1] : null
|
||||
const showTimeline =
|
||||
|
||||
Reference in New Issue
Block a user