fix: Web Widget SDK + Auto-Reply AgentBot sender + LLM 真实模型对接

## 核心修复

### 1. Auto-Reply Sender 修复(所有渠道)
- AutoReplyListener.sendAutoReply() 通过 botInboxRepo 查询 inbox 关联的 AgentBot
- 使用正确的 SenderType="AgentBot"(非小写 agent_bot)传递真实 AgentBot ID
- bootstrap 注入 agentBotInboxRepo/agentBotRepo 依赖

### 2. 事件数据 BUG 修复(影响所有 Webhook 渠道)
- incoming_persister.dispatch(): 补全 sender_type/content 到 event.Data
- channel/webhook.go: HandleWebhook 同步分发也补全 sender_type/content
- 未补全前 AutoReplyListener 找不到字段直接跳过

### 3. Web Widget SDK 生产验证修复
- cookie → localStorage token 同步(frontend/index.html)
- 路由双注册修复(router.go)
- Vite SPA 模式 + /widget 重写(vite.config.ts)
- WidgetService 注入 Dispatcher 触发事件分发

### 4. LLM 真实模型对接
- 配置 deepseek-v4-flash @ http://10.58.144.6:2014/v1
- LLM-mode auto-reply 规则创建并验证通过
- Prompt 文档落地: docs/captain-ai-auto-replay-prompt.md

### 5. 新增基础设施
- Helm chart (deploy/helm/)
- Widget SDK 生产测试页面
- QA 报告

Closes: BUG-W2 (auth sync), BUG-W3 (route double-reg),
       BUG-WEBHOOK-EVENT (missing event data fields)
This commit is contained in:
Rogee
2026-07-28 14:03:19 +08:00
parent 05c5752a2b
commit 9816848ca2
32 changed files with 1688 additions and 15 deletions
+2 -1
View File
@@ -677,7 +677,7 @@ func Bootstrap(env string) (*App, error) {
channelDispatcher.Register(agentBotListener)
// Auto-reply listener — evaluates rules on incoming messages and sends auto-replies
autoReplyListener := service.NewAutoReplyListener(captainAutoReplyRuleRepo, autoReplyRuleService, conversationRepo, messageRepo, messageService)
autoReplyListener := service.NewAutoReplyListener(captainAutoReplyRuleRepo, autoReplyRuleService, conversationRepo, messageRepo, messageService, agentBotInboxRepo, agentBotRepo)
channelDispatcher.Register(autoReplyListener)
// Platform: InstallationConfig service (global key-value config for super-admin)
@@ -810,6 +810,7 @@ func Bootstrap(env string) (*App, error) {
widgetOfflineMessageRepo := repository.NewWidgetOfflineMessageRepo(db)
widgetService := service.NewWidgetService(inboxRepo, contactRepo, contactInboxRepo, conversationRepo, messageRepo, widgetTypingAdapter, widgetThemeConfigRepo, preChatFormRepo, widgetFileUploadRepo, widgetOfflineMessageRepo, inboxMemberRepo, tagRepo, campaignRepo)
widgetService.SetWorkerPool(workerPool)
widgetService.SetDispatcher(channelDispatcher)
widgetHandler := widget.NewHandler(widgetService).WithEventPublisher(eventPublisher)
// Upload: DirectUpload repo + service + handler (account-level + widget direct uploads)
+6 -4
View File
@@ -141,10 +141,12 @@ func (h *WebhookHandler) HandleWebhook(c *gin.Context) {
AccountID: inbox.AccountID,
Timestamp: incomingMsg.ReceivedAt.Unix(),
Data: map[string]interface{}{
"inbox": inbox,
"incoming_msg": incomingMsg,
"channel_type": string(channelType),
"source_id": incomingMsg.SourceID,
"inbox": inbox,
"incoming_msg": incomingMsg,
"channel_type": string(channelType),
"source_id": incomingMsg.SourceID,
"sender_type": incomingMsg.SenderType,
"content": incomingMsg.Content,
},
}
if err := h.dispatcher.Dispatch(c.Request.Context(), event); err != nil {
@@ -15,6 +15,7 @@ import (
"github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/worker"
applogger "github.com/gochat/gochat/pkg/logger"
)
// IncomingPersister is the durable boundary after provider-specific webhook parsing.
@@ -425,11 +426,15 @@ func (p *IncomingPersister) dispatch(ctx context.Context, eventType channel.Even
}
if result.Message != nil {
event.Data["message"] = result.Message
event.Data["sender_type"] = result.Message.SenderType
event.Data["content"] = result.Message.Content
}
}
if message != nil {
event.ConversationID = message.ConversationID
event.Data["message"] = message
event.Data["sender_type"] = message.SenderType
event.Data["content"] = message.Content
if message.SenderID != nil {
event.ContactID = *message.SenderID
}
@@ -438,6 +443,8 @@ func (p *IncomingPersister) dispatch(ctx context.Context, eventType channel.Even
if err := p.dispatcher.DispatchAsync(ctx, event); err != nil {
// Chatwoot's async side effects should not make provider webhooks fail.
_ = err
applogger.L().Warnf("IncomingPersister: failed to dispatch %s event for inbox=%d: %v",
eventType, inbox.ID, err)
}
}
+4
View File
@@ -1976,7 +1976,9 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
// Reference: Chatwoot custom_attribute_definitions_controller.rb
customAttrDefs := accountScoped.Group("/custom_attribute_definitions")
{
customAttrDefs.GET("", h.CustomAttributeDefinition.List)
customAttrDefs.GET("/", h.CustomAttributeDefinition.List)
customAttrDefs.POST("", h.CustomAttributeDefinition.Create)
customAttrDefs.POST("/", h.CustomAttributeDefinition.Create)
customAttrDefs.GET("/:id", h.CustomAttributeDefinition.Get)
customAttrDefs.PUT("/:id", h.CustomAttributeDefinition.Update)
@@ -2004,7 +2006,9 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
// Reference: Chatwoot custom_filters_controller.rb
customFilters := accountScoped.Group("/custom_filters")
{
customFilters.GET("", h.CustomFilter.List)
customFilters.GET("/", h.CustomFilter.List)
customFilters.POST("", h.CustomFilter.Create)
customFilters.POST("/", h.CustomFilter.Create)
customFilters.GET("/:id", h.CustomFilter.Get)
customFilters.PUT("/:id", h.CustomFilter.Update)
@@ -25,11 +25,13 @@ import (
// 4. If a rule matches, composes reply (static/LLM/mixed) and sends via MessageService
// 5. Respects DelaySeconds and OneTimeOnly flags
type AutoReplyListener struct {
ruleRepo *repository.CaptainAutoReplyRuleRepo
autoReplySvc *AutoReplyRuleService
ruleRepo *repository.CaptainAutoReplyRuleRepo
autoReplySvc *AutoReplyRuleService
conversationRepo *repository.ConversationRepo
messageRepo *repository.MessageRepo
messageSvc *MessageService
messageRepo *repository.MessageRepo
messageSvc *MessageService
botInboxRepo *repository.AgentBotInboxRepo
botRepo *repository.AgentBotRepo
}
// NewAutoReplyListener creates a new AutoReplyListener.
@@ -39,6 +41,8 @@ func NewAutoReplyListener(
conversationRepo *repository.ConversationRepo,
messageRepo *repository.MessageRepo,
messageSvc *MessageService,
botInboxRepo *repository.AgentBotInboxRepo,
botRepo *repository.AgentBotRepo,
) *AutoReplyListener {
return &AutoReplyListener{
ruleRepo: ruleRepo,
@@ -46,6 +50,8 @@ func NewAutoReplyListener(
conversationRepo: conversationRepo,
messageRepo: messageRepo,
messageSvc: messageSvc,
botInboxRepo: botInboxRepo,
botRepo: botRepo,
}
}
@@ -140,6 +146,8 @@ func (l *AutoReplyListener) OnEvent(ctx context.Context, event *channel.ChannelE
}
// sendAutoReply sends the composed reply as an outgoing message.
// It resolves the inbox's linked AgentBot as the sender so the reply
// appears to come from the assistant (Captain/AgentBot), not a human agent.
func (l *AutoReplyListener) sendAutoReply(ctx context.Context, event *channel.ChannelEvent, conversation *model.Conversation, result *AutoReplyMatchResult) error {
if l.messageSvc == nil {
applogger.L().Warnf("AutoReplyListener: message service not available, cannot send auto-reply")
@@ -151,12 +159,25 @@ func (l *AutoReplyListener) sendAutoReply(ctx context.Context, event *channel.Ch
return nil
}
// Send as a bot/outgoing message
// Use the assistant's ID as the sender if available
senderType := "agent_bot"
// Resolve the AgentBot linked to this inbox. If no AgentBot is linked,
// fall back to sending as a generic user message.
senderType := "user"
var senderID uint
if result.Rule.AssistantID > 0 {
senderID = 0 // agent_bot messages use bot_id, not user_id
if l.botInboxRepo != nil && l.botRepo != nil {
bindings, err := l.botInboxRepo.FindActiveByInboxID(ctx, conversation.InboxID)
if err == nil && len(bindings) > 0 {
bot, err := l.botRepo.GetByID(ctx, bindings[0].AgentBotID)
if err == nil && bot != nil {
senderID = bot.ID
senderType = string(model.SenderTypeAgentBot)
applogger.L().Infof("AutoReplyListener: using AgentBot %d (%s) as sender for inbox %d",
bot.ID, bot.Name, conversation.InboxID)
}
}
}
if senderType == "user" {
applogger.L().Warnf("AutoReplyListener: no AgentBot linked to inbox %d, falling back to user sender",
conversation.InboxID)
}
// Create the outgoing message via MessageService
@@ -166,6 +187,7 @@ func (l *AutoReplyListener) sendAutoReply(ctx context.Context, event *channel.Ch
ContentType: "text",
MessageType: "outgoing",
SenderType: senderType,
SenderID: senderID,
Private: false,
})
if err != nil {
@@ -13,6 +13,7 @@ import (
"time"
"github.com/gochat/gochat/internal/automation"
"github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/repository"
@@ -62,6 +63,7 @@ type WidgetService struct {
tagRepo *repository.TagRepo
campaignRepo *repository.CampaignRepo
worker *worker.WorkerPool
dispatcher *channel.Dispatcher
}
// NewWidgetService creates a new Widget service.
@@ -106,6 +108,10 @@ func (s *WidgetService) SetTranscriptDeliverer(deliverer automation.AutomationTr
s.transcriptMailer = deliverer
}
func (s *WidgetService) SetDispatcher(dispatcher *channel.Dispatcher) {
s.dispatcher = dispatcher
}
// --- DTOs ---
// WidgetInitRequest is the DTO for the /widget/init endpoint.
@@ -412,6 +418,30 @@ func (s *WidgetService) SendMessage(ctx context.Context, req WidgetSendMessageRe
return nil, err
}
// Dispatch message.created event to trigger auto-reply listener
if s.dispatcher != nil {
inbox, _ := s.inboxRepo.FindByID(ctx, conversation.InboxID)
event := &channel.ChannelEvent{
Type: channel.EventMessageCreated,
Channel: channel.ChannelWebWidget,
ConversationID: conversation.ID,
InboxID: conversation.InboxID,
AccountID: conversation.AccountID,
Timestamp: time.Now().Unix(),
Data: map[string]interface{}{
"inbox": inbox,
"content": msg.Content,
"sender_type": "Contact",
"channel_type": "web_widget",
"source_id": msg.SourceID,
},
}
if dispatchErr := s.dispatcher.Dispatch(ctx, event); dispatchErr != nil {
applogger.L().Warnf("widget message event dispatch failed: inbox=%d conv=%d err=%v",
conversation.InboxID, conversation.ID, dispatchErr)
}
}
applogger.L().Infof("Widget message: contact=%d conversation=%d message=%d",
contactInbox.ContactID, conversation.ID, msg.ID)
@@ -965,6 +995,31 @@ func (s *WidgetService) PublicCreateMessage(ctx context.Context, inboxIdentifier
if err != nil {
return nil, nil, nil, err
}
// Dispatch message.created event to trigger auto-reply listener
if s.dispatcher != nil {
inbox, _ := s.inboxRepo.FindByID(ctx, conversation.InboxID)
event := &channel.ChannelEvent{
Type: channel.EventMessageCreated,
Channel: channel.ChannelWebWidget,
ConversationID: conversation.ID,
InboxID: conversation.InboxID,
AccountID: conversation.AccountID,
Timestamp: time.Now().Unix(),
Data: map[string]interface{}{
"inbox": inbox,
"content": message.Content,
"sender_type": "Contact",
"channel_type": "web_widget",
"source_id": message.SourceID,
},
}
if dispatchErr := s.dispatcher.Dispatch(ctx, event); dispatchErr != nil {
applogger.L().Warnf("widget message event dispatch failed: inbox=%d conv=%d err=%v",
conversation.InboxID, conversation.ID, dispatchErr)
}
}
return message, conversation, attachments, nil
}