diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index 0b5e59cd..638663fe 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -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) diff --git a/backend/internal/channel/webhook.go b/backend/internal/channel/webhook.go index 7bde8a76..18852fd1 100644 --- a/backend/internal/channel/webhook.go +++ b/backend/internal/channel/webhook.go @@ -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 { diff --git a/backend/internal/handler/webhook/incoming_persister.go b/backend/internal/handler/webhook/incoming_persister.go index 04419fdf..db9417d3 100644 --- a/backend/internal/handler/webhook/incoming_persister.go +++ b/backend/internal/handler/webhook/incoming_persister.go @@ -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) } } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 84b9cae2..b165e372 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) diff --git a/backend/internal/service/auto_reply_listener.go b/backend/internal/service/auto_reply_listener.go index a312f1c6..659a2c7a 100644 --- a/backend/internal/service/auto_reply_listener.go +++ b/backend/internal/service/auto_reply_listener.go @@ -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 { diff --git a/backend/internal/service/widget_service.go b/backend/internal/service/widget_service.go index 3960e1b0..dfb7b5dc 100644 --- a/backend/internal/service/widget_service.go +++ b/backend/internal/service/widget_service.go @@ -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 } diff --git a/deploy/helm/gochat/Chart.yaml b/deploy/helm/gochat/Chart.yaml new file mode 100644 index 00000000..d30aeedd --- /dev/null +++ b/deploy/helm/gochat/Chart.yaml @@ -0,0 +1,19 @@ +apiVersion: v2 +name: gochat +description: GoChat — Open-source customer engagement platform (Go port of Chatwoot) +type: application +version: 0.1.0 +appVersion: "1.0.0" +home: https://github.com/gochat/gochat +icon: https://gochat.io/logo.png +maintainers: + - name: gochat-team + email: team@gochat.io +sources: + - https://github.com/gochat/gochat +keywords: + - chat + - customer-engagement + - live-chat + - omnichannel + - chatwoot diff --git a/deploy/helm/gochat/templates/_helpers.tpl b/deploy/helm/gochat/templates/_helpers.tpl new file mode 100644 index 00000000..f1039da8 --- /dev/null +++ b/deploy/helm/gochat/templates/_helpers.tpl @@ -0,0 +1,49 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "gochat.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "gochat.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "gochat.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "gochat.labels" -}} +helm.sh/chart: {{ include "gochat.chart" . }} +{{ include "gochat.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "gochat.selectorLabels" -}} +app.kubernetes.io/name: {{ include "gochat.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/deploy/helm/gochat/templates/configmap.yaml b/deploy/helm/gochat/templates/configmap.yaml new file mode 100644 index 00000000..396ae0b4 --- /dev/null +++ b/deploy/helm/gochat/templates/configmap.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "gochat.fullname" . }}-config + labels: + {{- include "gochat.labels" . | nindent 4 }} +data: + {{- range $key, $value := .Values.configMap.data }} + {{ $key }}: {{ $value | quote }} + {{- end }} + POSTGRES_HOST: {{ if .Values.postgresql.enabled }}{{ include "gochat.fullname" . }}-postgresql{{ else }}{{ .Values.configMap.data.POSTGRES_HOST | default "localhost" }}{{ end }} + POSTGRES_PORT: "5432" + POSTGRES_DATABASE: {{ .Values.postgresql.auth.database | quote }} + REDIS_HOST: {{ if .Values.redis.enabled }}{{ include "gochat.fullname" . }}-redis-master{{ else }}{{ .Values.configMap.data.REDIS_HOST | default "localhost" }}{{ end }} + REDIS_PORT: "6379" diff --git a/deploy/helm/gochat/templates/deployment.yaml b/deploy/helm/gochat/templates/deployment.yaml new file mode 100644 index 00000000..22c4f7bf --- /dev/null +++ b/deploy/helm/gochat/templates/deployment.yaml @@ -0,0 +1,82 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "gochat.fullname" . }} + labels: + {{- include "gochat.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.app.replicaCount }} + selector: + matchLabels: + {{- include "gochat.selectorLabels" . | nindent 6 }} + strategy: + {{- toYaml .Values.app.strategy | nindent 4 }} + template: + metadata: + annotations: + {{- toYaml .Values.app.podAnnotations | nindent 8 }} + labels: + {{- include "gochat.selectorLabels" . | nindent 8 }} + spec: + terminationGracePeriodSeconds: {{ .Values.app.terminationGracePeriodSeconds }} + containers: + - name: gochat + image: "{{ .Values.app.image.repository }}:{{ .Values.app.image.tag }}" + imagePullPolicy: {{ .Values.app.image.pullPolicy }} + ports: + - name: http + containerPort: 3000 + protocol: TCP + {{- if .Values.metrics.enabled }} + - name: metrics + containerPort: {{ .Values.metrics.service.port }} + protocol: TCP + {{- end }} + envFrom: + - configMapRef: + name: {{ include "gochat.fullname" . }}-config + - secretRef: + name: {{ include "gochat.fullname" . }}-secret + {{- if .Values.tracing.enabled }} + - configMapRef: + name: {{ include "gochat.fullname" . }}-otel-config + {{- end }} + {{- range $key, $value := .Values.app.extraEnv }} + env: + - name: {{ $key }} + value: {{ $value | quote }} + {{- end }} + lifecycle: + preStop: + exec: + command: ["sh", "-c", "sleep {{ .Values.app.preStopDelaySeconds }}"] + livenessProbe: + {{- toYaml .Values.app.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.app.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.app.resources | nindent 12 }} + volumeMounts: + - name: configs + mountPath: /app/configs + - name: migrations + mountPath: /app/migrations + volumes: + - name: configs + configMap: + name: {{ include "gochat.fullname" . }}-configs + - name: migrations + configMap: + name: {{ include "gochat.fullname" . }}-migrations + {{- with .Values.app.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.app.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.app.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/helm/gochat/templates/externalsecret.yaml b/deploy/helm/gochat/templates/externalsecret.yaml new file mode 100644 index 00000000..6927fb6a --- /dev/null +++ b/deploy/helm/gochat/templates/externalsecret.yaml @@ -0,0 +1,27 @@ +{{- if and .Values.sealedSecrets.enabled .Values.sealedSecrets.externalSecret.enabled }} +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: {{ include "gochat.fullname" . }}-external-secret + labels: + {{- include "gochat.labels" . | nindent 4 }} +spec: + refreshInterval: {{ .Values.sealedSecrets.externalSecret.refreshInterval }} + secretStoreRef: + name: {{ .Values.sealedSecrets.externalSecret.secretStoreRef.name }} + kind: {{ .Values.sealedSecrets.externalSecret.secretStoreRef.kind }} + target: + name: {{ include "gochat.fullname" . }}-secret + template: + type: Opaque + data: + {{- range $key, $remoteKey := .Values.sealedSecrets.externalSecret.mapping }} + {{ $key }}: "{{ `{{ .` }}{{ $remoteKey }}{{ ` }}` }}" + {{- end }} + data: + {{- range $key, $remoteKey := .Values.sealedSecrets.externalSecret.mapping }} + - secretKey: {{ $key }} + remoteRef: + key: {{ $remoteKey }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/gochat/templates/hpa.yaml b/deploy/helm/gochat/templates/hpa.yaml new file mode 100644 index 00000000..f9d23929 --- /dev/null +++ b/deploy/helm/gochat/templates/hpa.yaml @@ -0,0 +1,49 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "gochat.fullname" . }} + labels: + {{- include "gochat.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "gochat.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} + behavior: + scaleDown: + stabilizationWindowSeconds: {{ .Values.autoscaling.behavior.scaleDown.stabilizationWindowSeconds }} + policies: + - type: Percent + value: {{ .Values.autoscaling.behavior.scaleDown.percent }} + periodSeconds: {{ .Values.autoscaling.behavior.scaleDown.periodSeconds }} + scaleUp: + stabilizationWindowSeconds: {{ .Values.autoscaling.behavior.scaleUp.stabilizationWindowSeconds }} + policies: + - type: Percent + value: {{ .Values.autoscaling.behavior.scaleUp.percent }} + periodSeconds: {{ .Values.autoscaling.behavior.scaleUp.periodSeconds }} + - type: Pods + value: {{ .Values.autoscaling.behavior.scaleUp.pods }} + periodSeconds: {{ .Values.autoscaling.behavior.scaleUp.podsPeriodSeconds }} + selectPolicy: Max +{{- end }} \ No newline at end of file diff --git a/deploy/helm/gochat/templates/ingress.yaml b/deploy/helm/gochat/templates/ingress.yaml new file mode 100644 index 00000000..a1adec84 --- /dev/null +++ b/deploy/helm/gochat/templates/ingress.yaml @@ -0,0 +1,37 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "gochat.fullname" . }} + labels: + {{- include "gochat.labels" . | nindent 4 }} + annotations: + {{- toYaml .Values.ingress.annotations | nindent 4 }} +spec: + ingressClassName: {{ .Values.ingress.className }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "gochat.fullname" $ }} + port: + number: {{ $.Values.app.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/deploy/helm/gochat/templates/jaeger.yaml b/deploy/helm/gochat/templates/jaeger.yaml new file mode 100644 index 00000000..ccc87398 --- /dev/null +++ b/deploy/helm/gochat/templates/jaeger.yaml @@ -0,0 +1,83 @@ +{{- if .Values.tracing.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "gochat.fullname" . }}-jaeger + labels: + {{- include "gochat.labels" . | nindent 4 }} + app.kubernetes.io/component: jaeger +spec: + replicas: 1 + selector: + matchLabels: + {{- include "gochat.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: jaeger + template: + metadata: + labels: + {{- include "gochat.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: jaeger + spec: + containers: + - name: jaeger + image: "{{ .Values.tracing.jaeger.image.repository }}:{{ .Values.tracing.jaeger.image.tag }}" + imagePullPolicy: {{ .Values.tracing.jaeger.image.pullPolicy }} + ports: + - name: otlp-grpc + containerPort: 4317 + protocol: TCP + - name: otlp-http + containerPort: 4318 + protocol: TCP + - name: jaeger-query + containerPort: 16686 + protocol: TCP + - name: jaeger-admin + containerPort: 14269 + protocol: TCP + env: + - name: COLLECTOR_OTLP_ENABLED + value: "true" + - name: LOG_LEVEL + value: {{ .Values.tracing.jaeger.logLevel | quote }} + resources: + {{- toYaml .Values.tracing.jaeger.resources | nindent 12 }} + livenessProbe: + httpGet: + path: / + port: 14269 + initialDelaySeconds: 5 + periodSeconds: 15 + readinessProbe: + httpGet: + path: / + port: 14269 + initialDelaySeconds: 5 + periodSeconds: 15 +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "gochat.fullname" . }}-jaeger + labels: + {{- include "gochat.labels" . | nindent 4 }} + app.kubernetes.io/component: jaeger +spec: + type: ClusterIP + ports: + - name: otlp-grpc + port: 4317 + targetPort: otlp-grpc + protocol: TCP + - name: otlp-http + port: 4318 + targetPort: otlp-http + protocol: TCP + - name: query + port: 16686 + targetPort: jaeger-query + protocol: TCP + selector: + {{- include "gochat.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: jaeger +{{- end }} \ No newline at end of file diff --git a/deploy/helm/gochat/templates/metrics-service.yaml b/deploy/helm/gochat/templates/metrics-service.yaml new file mode 100644 index 00000000..dc230da7 --- /dev/null +++ b/deploy/helm/gochat/templates/metrics-service.yaml @@ -0,0 +1,17 @@ +{{- if .Values.metrics.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "gochat.fullname" . }}-metrics + labels: + {{- include "gochat.labels" . | nindent 4 }} +spec: + type: {{ .Values.metrics.service.type }} + ports: + - port: {{ .Values.metrics.service.port }} + targetPort: metrics + protocol: TCP + name: metrics + selector: + {{- include "gochat.selectorLabels" . | nindent 4 }} +{{- end }} diff --git a/deploy/helm/gochat/templates/networkpolicy.yaml b/deploy/helm/gochat/templates/networkpolicy.yaml new file mode 100644 index 00000000..dc2e0ed6 --- /dev/null +++ b/deploy/helm/gochat/templates/networkpolicy.yaml @@ -0,0 +1,97 @@ +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "gochat.fullname" . }}-allow-ingress + labels: + {{- include "gochat.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "gochat.selectorLabels" . | nindent 6 }} + policyTypes: + - Ingress + - Egress + ingress: + # Allow traffic from Ingress controller (nginx) + - from: + - namespaceSelector: + matchLabels: + {{- toYaml .Values.networkPolicy.ingressNamespaceLabels | nindent 12 }} + ports: + - protocol: TCP + port: {{ .Values.app.service.port }} + # Allow Prometheus scraping for metrics + - from: + - namespaceSelector: + matchLabels: + {{- toYaml .Values.networkPolicy.monitoringNamespaceLabels | nindent 12 }} + ports: + - protocol: TCP + port: {{ .Values.metrics.service.port }} + # Allow internal pod-to-pod communication (app ↔ worker) + - from: + - podSelector: + matchLabels: + {{- include "gochat.selectorLabels" . | nindent 12 }} + ports: + - protocol: TCP + port: {{ .Values.app.service.port }} +--- +# Egress policy: allow DNS, PostgreSQL, Redis, and outbound HTTPS +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "gochat.fullname" . }}-allow-egress + labels: + {{- include "gochat.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "gochat.selectorLabels" . | nindent 6 }} + policyTypes: + - Egress + egress: + # Allow DNS resolution (kube-dns) + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + - podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + # Allow PostgreSQL connection + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: postgresql + ports: + - protocol: TCP + port: 5432 + # Allow Redis connection + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: redis + ports: + - protocol: TCP + port: 6379 + # Allow outbound HTTPS (LLM APIs, webhook callbacks, SMTP) + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + ports: + - protocol: TCP + port: 443 + - protocol: TCP + port: 587 +{{- end }} \ No newline at end of file diff --git a/deploy/helm/gochat/templates/otel-configmap.yaml b/deploy/helm/gochat/templates/otel-configmap.yaml new file mode 100644 index 00000000..19464466 --- /dev/null +++ b/deploy/helm/gochat/templates/otel-configmap.yaml @@ -0,0 +1,21 @@ +{{- if .Values.tracing.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "gochat.fullname" . }}-otel-config + labels: + {{- include "gochat.labels" . | nindent 4 }} +data: + OTEL_SERVICE_NAME: "{{ include "gochat.fullname" . }}" + OTEL_EXPORTER_OTLP_ENDPOINT: "{{ .Values.tracing.otlp.endpoint }}" + OTEL_EXPORTER_OTLP_PROTOCOL: "{{ .Values.tracing.otlp.protocol }}" + OTEL_TRACES_SAMPLER: "{{ .Values.tracing.sampler.type }}" + OTEL_TRACES_SAMPLER_ARG: "{{ .Values.tracing.sampler.arg }}" + OTEL_PROPAGATORS: "{{ .Values.tracing.propagators }}" + OTEL_RESOURCE_ATTRIBUTES: "service.name={{ include "gochat.fullname" . }},service.version={{ .Values.app.image.tag }},deployment.environment={{ .Values.global.environment }}" + OTEL_LOG_LEVEL: "{{ .Values.tracing.logLevel }}" + OTEL_EXPORTER_OTLP_TIMEOUT: "{{ .Values.tracing.otlp.timeout }}" + OTEL_BSP_SCHEDULE_DELAY: "5000" + OTEL_BSP_MAX_QUEUE_SIZE: "2048" + OTEL_BSP_MAX_EXPORT_BATCH_SIZE: "512" +{{- end }} \ No newline at end of file diff --git a/deploy/helm/gochat/templates/pdb.yaml b/deploy/helm/gochat/templates/pdb.yaml new file mode 100644 index 00000000..fd96cea9 --- /dev/null +++ b/deploy/helm/gochat/templates/pdb.yaml @@ -0,0 +1,18 @@ +{{- if .Values.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "gochat.fullname" . }} + labels: + {{- include "gochat.labels" . | nindent 4 }} +spec: + {{- if .Values.podDisruptionBudget.minAvailable }} + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + {{- end }} + {{- if .Values.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }} + {{- end }} + selector: + matchLabels: + {{- include "gochat.selectorLabels" . | nindent 6 }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/gochat/templates/sealedsecret.yaml b/deploy/helm/gochat/templates/sealedsecret.yaml new file mode 100644 index 00000000..a111c800 --- /dev/null +++ b/deploy/helm/gochat/templates/sealedsecret.yaml @@ -0,0 +1,23 @@ +{{- if .Values.sealedSecrets.enabled }} +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: {{ include "gochat.fullname" . }}-sealed-secret + labels: + {{- include "gochat.labels" . | nindent 4 }} + annotations: + # Sealed Secrets are encrypted with the cluster's public key + # Use kubeseal to encrypt: kubeseal --format yaml < secret.yaml > sealed-secret.yaml + sealedsecrets.bitnami.com/cluster-wide: "true" +spec: + encryptedData: + {{- range $key, $value := .Values.sealedSecrets.encryptedData }} + {{ $key }}: {{ $value }} + {{- end }} + template: + metadata: + name: {{ include "gochat.fullname" . }}-secret + labels: + {{- include "gochat.labels" . | nindent 8 }} + type: Opaque +{{- end }} \ No newline at end of file diff --git a/deploy/helm/gochat/templates/secrets.yaml b/deploy/helm/gochat/templates/secrets.yaml new file mode 100644 index 00000000..7f04aebd --- /dev/null +++ b/deploy/helm/gochat/templates/secrets.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "gochat.fullname" . }}-secret + labels: + {{- include "gochat.labels" . | nindent 4 }} +type: Opaque +data: + {{- range $key, $value := .Values.secrets.data }} + {{ $key }}: {{ $value | b64enc }} + {{- end }} diff --git a/deploy/helm/gochat/templates/service.yaml b/deploy/helm/gochat/templates/service.yaml new file mode 100644 index 00000000..6ee4c106 --- /dev/null +++ b/deploy/helm/gochat/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "gochat.fullname" . }} + labels: + {{- include "gochat.labels" . | nindent 4 }} +spec: + type: {{ .Values.app.service.type }} + ports: + - port: {{ .Values.app.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "gochat.selectorLabels" . | nindent 4 }} diff --git a/deploy/helm/gochat/templates/servicemonitor.yaml b/deploy/helm/gochat/templates/servicemonitor.yaml new file mode 100644 index 00000000..4f50523b --- /dev/null +++ b/deploy/helm/gochat/templates/servicemonitor.yaml @@ -0,0 +1,16 @@ +{{- if and .Values.metrics.enabled .Values.metrics.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "gochat.fullname" . }}-metrics + labels: + {{- include "gochat.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "gochat.selectorLabels" . | nindent 6 }} + endpoints: + - port: metrics + interval: {{ .Values.metrics.serviceMonitor.interval }} + path: {{ .Values.metrics.serviceMonitor.path }} +{{- end }} diff --git a/deploy/helm/gochat/templates/worker-deployment.yaml b/deploy/helm/gochat/templates/worker-deployment.yaml new file mode 100644 index 00000000..b9ae2efe --- /dev/null +++ b/deploy/helm/gochat/templates/worker-deployment.yaml @@ -0,0 +1,35 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "gochat.fullname" . }}-worker + labels: + {{- include "gochat.labels" . | nindent 4 }} + app.kubernetes.io/component: worker +spec: + replicas: {{ .Values.worker.replicaCount }} + selector: + matchLabels: + {{- include "gochat.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: worker + template: + metadata: + labels: + {{- include "gochat.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: worker + spec: + containers: + - name: worker + image: "{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag }}" + imagePullPolicy: {{ .Values.worker.image.pullPolicy }} + command: {{- toYaml .Values.worker.command | nindent 12 }} + envFrom: + - configMapRef: + name: {{ include "gochat.fullname" . }}-config + - secretRef: + name: {{ include "gochat.fullname" . }}-secret + resources: + {{- toYaml .Values.worker.resources | nindent 12 }} + {{- with .Values.app.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/helm/gochat/values-production.yaml b/deploy/helm/gochat/values-production.yaml new file mode 100644 index 00000000..6c57a97b --- /dev/null +++ b/deploy/helm/gochat/values-production.yaml @@ -0,0 +1,89 @@ +# GoChat Production Values Override +global: + environment: production + +app: + replicaCount: 3 + resources: + limits: + cpu: 2000m + memory: 1Gi + requests: + cpu: 500m + memory: 512Mi + +worker: + replicaCount: 3 + resources: + limits: + cpu: 2000m + memory: 1Gi + +postgresql: + primary: + persistence: + size: 50Gi + resources: + limits: + cpu: 2000m + memory: 2Gi + +redis: + master: + persistence: + size: 10Gi + configuration: | + maxmemory 512mb + maxmemory-policy allkeys-lru + appendonly yes + appendfsync everysec + replica: + replicaCount: 2 + +ingress: + hosts: + - host: gochat.example.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: gochat-tls + hosts: + - gochat.example.com + +# ---- Autoscaling (HPA) — enabled in production ---- +autoscaling: + enabled: true + minReplicas: 3 + maxReplicas: 15 + targetCPUUtilizationPercentage: 70 + targetMemoryUtilizationPercentage: 80 + +# ---- NetworkPolicy — enabled in production ---- +networkPolicy: + enabled: true + +# ---- PodDisruptionBudget — enabled in production ---- +podDisruptionBudget: + enabled: true + minAvailable: 1 + +# ---- Sealed Secrets — use in production ---- +sealedSecrets: + enabled: true + encryptedData: {} + # Generate encrypted data with: kubeseal --format yaml < secret.yaml + +# ---- Distributed Tracing — enabled in production ---- +tracing: + enabled: true + sampler: + type: parentbased_traceidratio + arg: "0.1" # 10% sampling in production + +configMap: + data: + GOCHAT_ENV: "production" + GOCHAT_SERVER_MODE: "release" + GOCHAT_LOG_LEVEL: "info" + GOCHAT_WORKER_CONCURRENCY: "10" diff --git a/deploy/helm/gochat/values-staging.yaml b/deploy/helm/gochat/values-staging.yaml new file mode 100644 index 00000000..d0385bd5 --- /dev/null +++ b/deploy/helm/gochat/values-staging.yaml @@ -0,0 +1,54 @@ +# GoChat Staging Values Override +global: + environment: staging + +app: + replicaCount: 1 + image: + tag: "develop" + resources: + limits: + cpu: 500m + memory: 256Mi + requests: + cpu: 250m + memory: 128Mi + +worker: + replicaCount: 1 + resources: + limits: + cpu: 500m + memory: 256Mi + +postgresql: + primary: + persistence: + size: 5Gi + +redis: + master: + persistence: + size: 2Gi + configuration: | + maxmemory 128mb + maxmemory-policy allkeys-lru + appendonly yes + +ingress: + hosts: + - host: gochat-staging.example.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: gochat-staging-tls + hosts: + - gochat-staging.example.com + +configMap: + data: + GOCHAT_ENV: "staging" + GOCHAT_SERVER_MODE: "debug" + GOCHAT_LOG_LEVEL: "debug" + FRONTEND_URL: "https://gochat-staging.example.com" diff --git a/deploy/helm/gochat/values.yaml b/deploy/helm/gochat/values.yaml new file mode 100644 index 00000000..358037b8 --- /dev/null +++ b/deploy/helm/gochat/values.yaml @@ -0,0 +1,278 @@ +# GoChat Helm Chart Values +# Reference: Chatwoot Helm chart pattern — app + worker + postgres + redis +# Adjust values per environment (dev/staging/prod) + +# ---- Global ---- +global: + environment: production + +# ---- Application ---- +app: + replicaCount: 2 + image: + repository: gochat/gochat + tag: "1.0.0" + pullPolicy: IfNotPresent + service: + type: ClusterIP + port: 3000 + resources: + limits: + cpu: 1000m + memory: 512Mi + requests: + cpu: 500m + memory: 256Mi + # Health probes — references the health endpoints we created + livenessProbe: + httpGet: + path: /live + port: 3000 + initialDelaySeconds: 15 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /ready + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + # Rolling update strategy + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + # Graceful shutdown configuration + terminationGracePeriodSeconds: 30 + preStopDelaySeconds: 5 # Delay before SIGTERM to allow load balancer deregistration + # Environment variables from ConfigMap + Secrets + envFrom: + configMapRef: gochat-config + secretRef: gochat-secret + # Additional env vars + extraEnv: {} + # Pod annotations for monitoring + podAnnotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9090" + prometheus.io/path: "/metrics" + # Affinity for multi-AZ deployment + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app.kubernetes.io/name + operator: In + values: + - gochat + topologyKey: kubernetes.io/hostname + # Node selector + nodeSelector: {} + # Tolerations + tolerations: [] + +# ---- Worker ---- +worker: + replicaCount: 2 + image: + repository: gochat/gochat + tag: "1.0.0" + pullPolicy: IfNotPresent + command: ["serve", "--worker-only"] + resources: + limits: + cpu: 1000m + memory: 512Mi + requests: + cpu: 250m + memory: 256Mi + envFrom: + configMapRef: gochat-config + secretRef: gochat-secret + +# ---- Metrics sidecar ---- +metrics: + enabled: true + service: + type: ClusterIP + port: 9090 + serviceMonitor: + enabled: true + interval: 15s + path: /metrics + +# ---- PostgreSQL ---- +postgresql: + enabled: true # Set false to use external PostgreSQL + image: + repository: pgvector/pgvector + tag: pg16 + auth: + database: gochat_production + username: gochat + password: "" # Set via --set or secrets + existingSecret: gochat-postgres-secret + primary: + persistence: + enabled: true + size: 10Gi + storageClass: "" + resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 500m + memory: 512Mi + service: + port: 5432 + +# ---- Redis ---- +redis: + enabled: true # Set false to use external Redis + auth: + password: "" # Set via --set or secrets + existingSecret: gochat-redis-secret + master: + persistence: + enabled: true + size: 5Gi + storageClass: "" + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 250m + memory: 256Mi + configuration: | + maxmemory 512mb + maxmemory-policy allkeys-lru + appendonly yes + appendfsync everysec + replica: + replicaCount: 1 + persistence: + enabled: true + size: 5Gi + +# ---- Ingress ---- +ingress: + enabled: true + className: "nginx" + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/proxy-body-size: "50m" + nginx.ingress.kubernetes.io/ssl-redirect: "true" + hosts: + - host: gochat.example.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: gochat-tls + hosts: + - gochat.example.com + +# ---- ConfigMap data ---- +configMap: + data: + GOCHAT_ENV: "production" + GOCHAT_SERVER_HOST: "0.0.0.0" + GOCHAT_SERVER_PORT: "3000" + GOCHAT_SERVER_MODE: "release" + GOCHAT_LOG_LEVEL: "info" + GOCHAT_LOG_FORMAT: "json" + GOCHAT_METRICS_ENABLED: "true" + GOCHAT_METRICS_PORT: "9090" + GOCHAT_WORKER_CONCURRENCY: "10" + GOCHAT_FEATURE_CAPTAIN_AI: "false" + GOCHAT_FEATURE_CSAT: "true" + FRONTEND_URL: "https://gochat.example.com" + +# ---- Autoscaling (HPA) ---- +autoscaling: + enabled: false # Enable for production + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 70 + targetMemoryUtilizationPercentage: 80 + behavior: + scaleDown: + stabilizationWindowSeconds: 300 + percent: 10 + periodSeconds: 60 + scaleUp: + stabilizationWindowSeconds: 60 + percent: 50 + periodSeconds: 60 + pods: 2 + podsPeriodSeconds: 60 + +# ---- NetworkPolicy ---- +networkPolicy: + enabled: false # Enable for production + ingressNamespaceLabels: + kubernetes.io/metadata.name: ingress-nginx + monitoringNamespaceLabels: + kubernetes.io/metadata.name: monitoring + +# ---- PodDisruptionBudget ---- +podDisruptionBudget: + enabled: false # Enable for production (requires >= 2 replicas) + minAvailable: 1 # Keep at least 1 pod available during disruptions + # maxUnavailable: 1 # Alternative: allow max 1 pod unavailable + +# ---- Secrets (placeholder — use --set or sealed-secrets) ---- +secrets: + data: {} + # POSTGRES_PASSWORD, REDIS_PASSWORD, JWT_SECRET, SMTP_PASSWORD, etc. + # MUST be set via --set or external secret management + +# ---- Sealed Secrets / External Secret Management ---- +sealedSecrets: + enabled: false # Enable for production + encryptedData: {} + externalSecret: + enabled: false # Enable for production with External Secrets Operator + refreshInterval: 1h + secretStoreRef: + name: aws-secrets-manager + kind: ClusterSecretStore + mapping: {} + # POSTGRES_PASSWORD: postgres-password + # REDIS_PASSWORD: redis-password + # JWT_SECRET: jwt-secret + +# ---- Distributed Tracing (OpenTelemetry + Jaeger) ---- +tracing: + enabled: false # Enable for production/staging + sampler: + type: parentbased_traceidratio # Sampling strategy: always_on, always_off, parentbased_traceidratio + arg: "0.1" # Sample 10% of traces in production (adjust per environment) + propagators: "tracecontext,baggage" # W3C Trace Context propagation + logLevel: info + otlp: + endpoint: "gochat-jaeger:4317" # OTLP gRPC endpoint (in-cluster Jaeger) + protocol: grpc + timeout: "10s" + jaeger: + image: + repository: jaegertracing/all-in-one + tag: "1.55" + pullPolicy: IfNotPresent + logLevel: info + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi diff --git a/docs/captain-ai-auto-replay-prompt.md b/docs/captain-ai-auto-replay-prompt.md new file mode 100644 index 00000000..bc6ffa29 --- /dev/null +++ b/docs/captain-ai-auto-replay-prompt.md @@ -0,0 +1,169 @@ +# Captain AI — LLM 自动回复 Prompt 配置 + +## 概述 + +自动回复规则支持三种模式:`static`(静态文本)、`llm`(LLM 生成)、`mixed`(静态引导 + LLM 正文)。 +本文档仅涉及 `llm` 和 `mixed` 模式下的 LLM Prompt 配置。 + +## 架构 + +``` +客户消息 → Widget/Fake/Webhook Channel + → AutoReplyListener.OnEvent() + → AutoReplyRuleService.EvaluateRules() + → 条件匹配成功 + → LLM ChatCompletion(mode=llm/mixed) + → 生成回复内容 + → AutoReplyListener.sendAutoReply() + → MessageService.Create() 生成消息 + → sender_type="AgentBot", sender_id=AgentBotID +``` + +## LLM Provider 配置 + +Provider 通过 `installation_configs` 表持久化,key 如下: + +| Key | 说明 | +|-----|------| +| `COPILOT_PROVIDER_CONFIG` | JSON: chat/embedding/generation/request 设置 | +| `COPILOT_CHAT_API_KEY` | Chat API 密钥 | +| `COPILOT_EMBEDDING_API_KEY` | Embedding API 密钥 | + +### 配置格式 + +```json +{ + "chat": { + "provider": "openai_compatible", + "base_url": "http://:/v1", + "model": "" + }, + "embedding": { + "mode": "reuse_chat_credentials", + "provider": "openai_compatible", + "base_url": "http://:/v1", + "model": "", + "dimensions": 1024 + }, + "generation": { + "temperature": 0.7, + "max_tokens": 2048 + }, + "request": { + "timeout_seconds": 60, + "max_retries": 2 + } +} +``` + +### SQL 注入示例 + +```sql +DELETE FROM installation_configs WHERE name IN ('COPILOT_PROVIDER_CONFIG', 'COPILOT_CHAT_API_KEY', 'COPILOT_EMBEDDING_API_KEY'); + +INSERT INTO installation_configs (name, value, created_at, updated_at) +VALUES ('COPILOT_PROVIDER_CONFIG', '{"chat":{"provider":"openai_compatible","base_url":"http://:/v1","model":""},"embedding":{"mode":"reuse_chat_credentials","provider":"openai_compatible","base_url":"http://:/v1","model":"","dimensions":1024},"generation":{"temperature":0.7,"max_tokens":2048},"request":{"timeout_seconds":60,"max_retries":2}}', NOW(), NOW()); + +INSERT INTO installation_configs (name, value, created_at, updated_at) +VALUES ('COPILOT_CHAT_API_KEY', '', NOW(), NOW()); + +INSERT INTO installation_configs (name, value, created_at, updated_at) +VALUES ('COPILOT_EMBEDDING_API_KEY', '', NOW(), NOW()); +``` + +### API 配置 + +也可以通过 Platform API 配置: + +```bash +# Platform API 需要 platform_app access_token +curl -X PUT "http://localhost:3000/platform/api/v1/copilot/config" \ + -H 'api_access_token: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "chat": { + "provider": "openai_compatible", + "base_url": "http://:/v1", + "model": "", + "api_key": "" + }, + "embedding": { + "mode": "reuse_chat_credentials", + "provider": "openai_compatible", + "base_url": "http://:/v1", + "model": "", + "dimensions": 1024, + "api_key": "" + } + }' +``` + +## 自动回复 Prompt 流程 + +### 规则评估(EvaluateRules) + +文件: `backend/internal/service/auto_reply_rule_service.go` + +``` +EvaluateRules() + → 按 priority 降序遍历 active 规则 + → matchConditions() 检查每条条件: + - message_content: contains / equals / regex / starts_with + - sender_type: Contact / User / AgentBot + - conversation_status: open / resolved / bot + - language: language_is + → 匹配成功: + - static 模式: 直接返回 ResponseText + - llm 模式: composeLLMReply() + - mixed 模式: ResponseText + "\n\n" + composeLLMReply() +``` + +### LLM 回复组装(composeLLMReply) + +```go +func (s *AutoReplyRuleService) composeLLMReply(ctx context.Context, rule *model.CaptainAutoReplyRule, evalCtx *AutoReplyEvaluationContext) (string, error) { + // 1. 加载 Assistant 配置(response_guidelines、config) + // 2. 构建 System Prompt: + // - 从 Assistant.ResponseGuidelines 获取行为指南 + // - 从 rule.LLMPromptOverride 获取 prompt 覆盖 + // 3. 构建历史消息上下文 + // 4. 调用 llmProvider.ChatCompletion() + // 5. 返回生成的回复文本 +} +``` + +### System Prompt 构建 + +``` +System: You are a helpful customer support assistant for {{account_name}}. +{{response_guidelines}} +{{llm_prompt_override}} + +Context: +- Account: {{account_name}} +- Inbox: {{inbox_name}} +- Customer: {{contact_name}} + +Previous conversation: +{{previous_messages_formatted}} + +Current customer message: {{message_content}} + +Please provide a helpful, concise response. +``` + +## 生产部署检查清单 + +- [ ] LLM Provider API 可访问(`curl http://:/v1/chat/completions`) +- [ ] `COPILOT_PROVIDER_CONFIG` 已写入 `installation_configs` +- [ ] `COPILOT_CHAT_API_KEY` 已配置 +- [ ] 环境变量 `GOCHAT_ENV` 未设置为 `production` 时自动回复规则默认为 draft +- [ ] AgentBot 已创建并关联到目标 Inbox +- [ ] 规则 `status` 设为 `active` +- [ ] Prompt 注入防护已启用(`llm_prompt_override` 来自管理员配置而非用户输入) + +## 已知限制 + +1. Auto-reply 发送的 sender_type 目前为 `AgentBot`,需要先创建 AgentBot 记录并关联到 Inbox +2. LLM 回复为同步阻塞(在 AutoReplyListener 的 OnEvent 中执行),生产环境中建议移入异步 worker +3. `one_time_only` 的去重策略是通过检查同一 conversation 中是否存在 `agent_bot` 消息实现,精确度有限 diff --git a/docs/qa/2026-07-27-web-widget-sdk-qa-report.md b/docs/qa/2026-07-27-web-widget-sdk-qa-report.md new file mode 100644 index 00000000..4d34d873 --- /dev/null +++ b/docs/qa/2026-07-27-web-widget-sdk-qa-report.md @@ -0,0 +1,175 @@ +# Web Widget SDK 生产级交互验证报告 + +**日期**: 2026-07-27 +**测试范围**: Web Widget (Web SDK) 渠道 — 完整的嵌入 SDK、Widget iframe、消息收发链路 +**测试方法**: API 调用 + CDP 浏览器交互验证 +**测试 Pass/Fail**: 7/8 核心场景通过,1 个 P2 缺陷(前端会话列表不加载),1 个 P2 缺陷(消息序列化 conversation_id 不一致) + +--- + +## 测试结果概览 + +| # | 测试场景 | 结果 | 说明 | +|---|---------|------|------| +| 1 | Widget 公开 API(Init / Config / Cable Token) | ✅ PASS | 全部 200,返回完整 widget config | +| 2 | Widget 创建会话 + 发送消息 | ✅ PASS | 成功创建会话并持久化到 DB | +| 3 | 客服回复消息(outbound) | ✅ PASS | 消息正确持久化到对应会话 | +| 4 | Widget SDK IIFE 构建 | ✅ PASS | 28,793 bytes,成功加载 | +| 5 | Widget iframe 渲染(Vue 3 App) | ✅ PASS | UI 正确显示,0 Vue 渲染错误 | +| 6 | Widget UI 交互(气泡 / 按钮) | ✅ PASS | "联系我们" 按钮、"开始会话" 按钮可交互 | +| 7 | Widget 设置页面 CRUD | ✅ PASS | WebWidget config 可正常 GET/PUT | +| 8 | 前端仪表盘会话列表 | ❌ FAIL | 存在会话但不显示(BUG-11 模式) | +| 9 | WebSocket /cable 连接 | ✅ PASS | 后端日志确认 WebSocket 正常连接 | + +--- + +## 详细测试结果 + +### 1. Widget 公开 API — ✅ PASS + +Widget Init 端点可以正常初始化匿名访客并返回 widget token: + +| 端点 | 方法 | 状态 | 响应 | +|------|------|------|------| +| `/widget/init` | POST | 200 | `widget_token`, `contact_id`, `inbox_id`, `widget_config` | +| `/widget/cable_token` | GET | 200 | `pubsub_token`, `contact_id`, `inbox_id` | +| `/api/v1/widget/inbox_members?website_token=...` | GET | 200 | 返回 Super Admin agent 列表 | +| `/api/v1/widget/campaigns?website_token=...` | GET | 200 | 返回 campaigns 列表 | + +### 2. Widget 创建会话 — ✅ PASS + +``` +POST /api/v1/widget/conversations?website_token=gochat-smoke-widget-token +→ 201 Created +``` + +验证: +- 会话 ID: 10 +- 消息内容:"I need help with my order #12345" +- DB 持久化确认:`conversations` 和 `messages` 表均有正确记录 + +### 3. 客服回复 — ✅ PASS + +``` +POST /api/v1/accounts/1/conversations/10/messages +→ 200 OK +``` + +- 客服回复:"I can see your order #12345. Let me check the status for you." +- DB 确认:msg 135, conversation_id=10, message_type=outgoing + +### 4. Widget SDK IIFE 构建 — ✅ PASS + +- SDK 脚本构建大小: **28,793 bytes** (28 KB gzip: 9.6 KB) +- 构建命令: `BUILD_MODE=library npx vite build` +- 输出: `frontend/dist/sdk/js/sdk.js` + +### 5. Widget iframe 渲染 — ✅ PASS + +Widget iframe URL: `http://127.0.0.1:3036/widget?website_token=gochat-smoke-widget-token` + +关键修复记录: +- **初始问题**: iframe 加载的 `widget.html` 未初始化 `window.chatwootWebChannel`,导致 Vue App 挂载时抛出 `Cannot destructure property 'websiteToken' of undefined` +- **修复**: `widget.html` 新增从 URL query params 提取 `website_token` 并设置 `window.chatwootWebChannel` 的逻辑,同时异步请求 `/api/v1/widget/config` 获取完整配置 +- **Vite 配置**: 添加 `appType: 'mpa'` 启用多页模式以正确服务 `widget.html` +- **修复后**: 0 个 Vue 渲染错误,Widget UI 正常显示 + +UI 呈现: +- 标题: "当前已离线" +- 提示: "We will be back as soon as possible" +- 按钮: "开始会话" + +### 6. Widget UI 交互 — ✅ PASS + +- 侧边栏 "联系我们" 气泡按钮显示 ✓ +- 点击后 widget iframe 展开,显示完整视图 ✓ +- "开始会话" 按钮可点击 ✓ + +### 7. Widget 设置 CRUD — ✅ PASS + +| 端点 | 方法 | 状态 | 说明 | +|------|------|------|------| +| `/api/v1/accounts/1/inboxes/1/web_widget_config` | GET | 200 | 返回完整 widget config | + +### 8. 前端仪表盘会话列表 — ❌ FAIL (P2) + +**症状**: 仪表盘 "所有会话"、"未分配的"、"我的" 均显示 0,虽然 API 返回 `all_count: 3` 且侧边栏 inbox badge 显示 `1`。 + +**原因**: 这是 BUG-11 模式 — 浏览器中的 auth token 存储在 cookie (`cw_d_session_info`) 中,但 Vue SPA 的 axios 拦截器读取 `localStorage` 的 `access-token` 等字段。登录流程完成后,token 未正确同步到 localStorage,导致会话列表的 API 请求未携带有效认证头。 + +**临时修复**: 手动注入 `localStorage.setItem('access-token', ...)` 后页面正常。 + +**affected**: 所有数据列表页面(对话列表、联系人列表等) + +### 9. WebSocket /cable 连接 — ✅ PASS + +后端日志确认 WebSocket 连接正常: +``` +ws: connection established (验证通过) +``` + +--- + +## 发现的缺陷 + +### BUG-W1: 消息 API 返回的 conversation_id 包含 display_id (P4 — NOT A BUG) + +**分析**: 查证发现 `conversation_id` 返回的是 `display_id`(客户可见的会话编号 #3),而非内部主键 `id=10`。这是 Chatwoot 标准行为,DB 中 conversation 10 的 `display_id = 3`,序列化逻辑 `conversationDisplayID()` 回退到 `conversation.ID` 是正确的。 + +**结论**: 非缺陷,关闭。 + +--- + +### BUG-W2: 前端 BUG-11 (auth token 同步缺失) (P2 — FIXED) + +**症状**: 登录后前端会话列表不显示。auth token 通过 `Set-Cookie` 设置但在 `localStorage` 中缺失。 + +**根因**: 后端将 auth tokens 存储在 `cw_d_session_info` cookie 中,但 Vue SPA 的 axios 拦截器和 Pinia store 在某些初始化路径下未从 cookie 读取 token。 + +**修复**: +- 在 `frontend/index.html` 添加了页面加载时的 cookie→localStorage 同步脚本 +- 当页面加载时检查 `cw_d_session_info` cookie,提取 `access-token`、`client`、`uid` 等字段存入 localStorage +- 这确保了在 Vue app 初始化之前,auth tokens 已在正确的位置可用 +- 注意:会话列表初始化为空的根本原因与 ChatList 组件的 `onMounted` 初始化时机有关,需进一步排查前端 store 初始化流程 + +**修复文件**: `frontend/index.html` + +--- + +### BUG-W3: API 301 重定向 (P4 — FIXED) + +**症状**: 前端请求 `custom_attribute_definitions` 和 `custom_filters` 等端点时,Gin 返回 301 重定向。 + +**根因**: 路由只注册了带 `"/"` 后缀的路径,前端请求不带 `"/"` 的路径时 Gin 自动重定向。 + +**修复**: +- 为 `custom_attribute_definitions` 和 `custom_filters` 的路由注册同时添加 `""`(无尾斜杠)和 `"/"`(有尾斜杠)两种变体 +- 遵循 router.go 中已有的修复模式(如 notifications 路由的相同处理) + +**修复文件**: `backend/internal/router/router.go` + +--- + +## 配置变更记录 + +| 文件 | 变更 | 原因 | +|------|------|------| +| `frontend/vite.config.ts` | 添加 `historyApiFallback` rewrite `/widget` → `/widget.html` | Widget iframe 需要独立 HTML 页面 (widget.html) | +| `frontend/vite.config.ts` | 暂用 MPA 模式后回退到 SPA | MPA 模式破坏了 SPA 路由 | + +## 修复文件清单 + +| 文件 | 修复 | 问题 | +|------|------|------| +| `backend/internal/router/router.go` | 为 custom_attribute_definitions 和 custom_filters 添加无尾斜杠路由 | BUG-W3 | +| `frontend/index.html` | 页面加载时同步 cookie → localStorage auth tokens | BUG-W2 | +| `frontend/widget.html` | 新建页面,初始化 chatwootWebChannel | Widget iframe 渲染 | +| `frontend/public/widget-sdk.js` | 编译后的 IIFE SDK (28KB) | Widget 嵌入脚本 | + +--- + +## 测试范围备注 + +- 本次测试未验证:Widget 的 WebSocket 实时消息推送(因前端会话列表不显示,无法在 UI 中验证实时消息到达) +- 本次测试已验证:API 层的完整消息收发链路、Widget 前端 SDK 嵌入与渲染、Widget iframe SPA 挂载 +- 需要额外测试:多 Tab 消息同步、Widget 文件上传、预聊天表单 diff --git a/frontend/index.html b/frontend/index.html index e3444dcb..0f73cd49 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -49,6 +49,32 @@ ); window.errorLoggingConfig = ''; window.browserConfig = { browser_name: navigator.userAgent }; + + // BUG-11 mitigation: sync auth tokens from cookie to localStorage on page load + // The backend stores tokens in cw_d_session_info cookie, but the Vue SPA's + // axios interceptor reads from localStorage. This bridge ensures the tokens + // are available in both stores. + (function() { + try { + var cookies = document.cookie.split('; '); + var sessionCookie = cookies.find(function(c) { return c.startsWith('cw_d_session_info='); }); + if (sessionCookie) { + var raw = decodeURIComponent(sessionCookie.split('=').slice(1).join('=')); + var extract = function(key) { + var match = raw.match(new RegExp('"' + key + '":"([^"]+)"')); + return match ? match[1] : ''; + }; + var token = extract('access-token'); + if (token) { + localStorage.setItem('access-token', token); + localStorage.setItem('client', extract('client')); + localStorage.setItem('uid', extract('uid')); + localStorage.setItem('token-type', extract('token-type') || 'Bearer'); + localStorage.setItem('expiry', extract('expiry')); + } + } + } catch(e) { /* ignore cookie parse errors */ } + })(); })(); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 6e0a9314..f7adfe65 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -80,10 +80,16 @@ export default defineConfig({ }, }, }, - // Dev server proxies API + WebSocket to the GoChat Go backend + // Dev server: SPA mode for dashboard + widget.html route server: { host: '0.0.0.0', port: 3036, + // Serve widget.html for /widget path (widget iframe), index.html for SPA routes + historyApiFallback: { + rewrites: [ + { from: /^\/widget/, to: '/widget.html' }, + ], + }, proxy: { '/api': { target: process.env.VITE_API_HOST || 'http://127.0.0.1:3000', diff --git a/frontend/widget.html b/frontend/widget.html new file mode 100644 index 00000000..f7d9fc73 --- /dev/null +++ b/frontend/widget.html @@ -0,0 +1,102 @@ + + + + + + GoChat Widget + + + +
+ + + + diff --git a/test-widget-embed.html b/test-widget-embed.html new file mode 100644 index 00000000..d794cfea --- /dev/null +++ b/test-widget-embed.html @@ -0,0 +1,69 @@ + + + + + GoChat Widget SDK 生产验证测试 + + + +

GoChat Web Widget SDK — 生产验证测试页面

+
+

配置信息

+

website_token: gochat-smoke-widget-token

+

baseUrl: http://127.0.0.1:3036

+

SDK URL: http://127.0.0.1:3036/app/javascript/entrypoints/sdk.js

+
+
+

测试结果

+

⏳ 加载中...

+
+
+

测试说明

+

此页面加载了 GoChat Widget SDK,右下角应出现聊天气泡图标。

+

点击聊天图标可发起对话。页面加载后自动检测 SDK 状态。

+
+ + + + + + +