支持访客实时输入草稿展示与联系方式自动识别

坐席输入框上方显示「对方正在输入」草稿;从草稿/消息提取手机微信邮箱QQ并追加入库,不覆盖已有联系方式。
This commit is contained in:
yml2213
2026-07-19 00:19:24 +08:00
parent fea5f4acf5
commit 6070cf47dc
11 changed files with 465 additions and 17 deletions
+53 -5
View File
@@ -4,13 +4,25 @@ import (
"encoding/json"
"log"
"net/http"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/gorilla/websocket"
"kefu-cloud/server/internal/model"
)
// ProcessDraftContacts 由 handler 包注入,避免 ws ↔ handler 循环依赖。
// tenantID, customerID, sessionID, draftText
var ProcessDraftContacts func(tenantID, customerID, sessionID uint, text string)
func processDraftContacts(tenantID, customerID, sessionID uint, text string) {
if ProcessDraftContacts != nil {
ProcessDraftContacts(tenantID, customerID, sessionID, text)
}
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
Subprotocols: []string{"kefu-v1", "kefu-visitor-v1"},
@@ -36,6 +48,8 @@ type Event struct {
type ClientEvent struct {
Type string `json:"type"`
SessionID uint `json:"session_id"`
// Text 访客输入框草稿(type=input_draft 或 typing 附带)
Text string `json:"text,omitempty"`
}
type Hub struct {
@@ -166,8 +180,14 @@ func (h *Hub) BroadcastToSessionStaff(tenantID uint, agentID *uint, message []by
}
}
const maxDraftTextRunes = 500
func handleClientEvent(client *Client, event ClientEvent) {
if event.Type != "typing" || event.SessionID == 0 {
if event.SessionID == 0 {
return
}
// typing:仅指示;input_draft:带正文草稿
if event.Type != "typing" && event.Type != "input_draft" {
return
}
var session model.Session
@@ -175,7 +195,7 @@ func handleClientEvent(client *Client, event ClientEvent) {
return
}
// 访客输入中 → 通知可接待的客服
// 访客输入中 / 草稿 → 通知坐席
if client.Kind == "visitor" {
if client.SessionID == nil || *client.SessionID != event.SessionID {
return
@@ -183,6 +203,34 @@ func handleClientEvent(client *Client, event ClientEvent) {
if session.Status == "ended" || session.Status == "archived" {
return
}
if event.Type == "input_draft" {
text := strings.TrimSpace(event.Text)
if utf8.RuneCountInString(text) > maxDraftTextRunes {
text = string([]rune(text)[:maxDraftTextRunes])
}
now := time.Now()
_ = model.DB.Model(&session).Updates(map[string]interface{}{
"draft_text": text,
"draft_updated_at": now,
}).Error
// 异步提取联系方式(不阻塞 WS
go processDraftContacts(session.TenantID, session.CustomerID, session.ID, text)
payload, err := NewEvent("input_draft", session.ID, map[string]interface{}{
"from": "visitor",
"text": text,
})
if err != nil {
return
}
// 排队中也让租户坐席能看到
DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
return
}
// 兼容旧 typing(无正文)
payload, err := NewEvent("typing", session.ID, map[string]string{"from": "visitor"})
if err != nil {
return
@@ -195,8 +243,8 @@ func handleClientEvent(client *Client, event ClientEvent) {
return
}
// 客服输入中 → 通知访客
if client.Kind != "agent" {
// 客服输入中 → 通知访客(不传草稿内容)
if client.Kind != "agent" || event.Type != "typing" {
return
}
if client.Role == "agent" && (session.AgentID == nil || *session.AgentID != client.UserID) {
@@ -217,7 +265,7 @@ func HandleWebSocket(client *Client) {
client.Conn.Close()
}()
client.Conn.SetReadLimit(1024)
client.Conn.SetReadLimit(4096) // 允许访客草稿正文
client.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
client.Conn.SetPongHandler(func(string) error {
client.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))