fix(shangwutong): 完善访客资料同步

This commit is contained in:
Rogee
2026-08-06 15:14:32 +08:00
parent 3bceb2d0b2
commit 5c47d1d5fe
24 changed files with 1800 additions and 952 deletions
@@ -152,9 +152,15 @@ func (i *Inbound) ensureResources(ctx context.Context, account *dbgen.Account, e
contactAttributes := cloneMap(mapped.ContactAttributes)
contactAttributes["swt_inbox_id"] = account.GochatInboxID
contactAdditional := cloneMap(mapped.ContactAdditional)
if inferred := shangwutongLocationAttributes(mapped.ContactAttributes); len(inferred) > 0 {
for key, value := range inferred {
contactAdditional[key] = value
}
}
contactRequest := gochat.ContactRequest{
SourceID: event.SwtSid, Name: mapped.ContactName, PhoneNumber: mapped.ContactPhone,
AdditionalAttributes: contactAttributes,
CustomAttributes: contactAttributes, AdditionalAttributes: contactAdditional,
}
needsContactWrite := state.contactID == 0 || mapped.ContactName != "" || mapped.ContactPhone != "" || len(mapped.ContactAttributes) > 0
if mapped.RequiresContact && needsContactWrite {
@@ -21,6 +21,7 @@ type mappedEvent struct {
ContactName string
ContactPhone string
ContactAttributes map[string]any
ContactAdditional map[string]any
ConversationAttrs map[string]any
ConversationStatus string
Typing *bool
@@ -64,6 +65,7 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour
mapped.Media = media
mapped.Subtype = subtype
mapped.Message.AdditionalAttributes["senderName"] = "商务通访客"
baseAttributes["swt"].(map[string]any)["operator_name"] = cleanText(operator)
case 3:
content, subtype, fallback, media := normalizeMessageContent(text)
message("outgoing", "text", content, chooseStrategy(fallback, "fallback_text", "native_message"))
@@ -124,7 +126,6 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour
case 7:
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true
mapped.ContactAttributes = parseEnvironment(text)
mapped.ContactName = visitorDisplayName(mapped.ContactAttributes)
case 8:
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "conversation_attributes", false, true
mapped.ConversationAttrs = parseSource(text)
@@ -132,8 +133,8 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour
case 11:
// Account presence is applied in the heartbeat transaction.
case 12:
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true
mapped.ConversationAttrs = map[string]any{"swt_operator_alias": cleanText(text)}
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true
mapped.ContactName = cleanText(text)
case 15:
content, subtype, fallback, media := normalizeMessageContent(text)
message("incoming", "text", firstText(content, "访客发送了文件"), "fallback_text")
@@ -143,6 +144,7 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour
}
case 26:
mapped.ContactName, mapped.ContactPhone = callbackIdentity(text)
mapped.RequiresContact = true
activity("访客请求电话回拨")
mapped.Strategy = "activity"
case 29:
@@ -158,7 +160,10 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour
case 35:
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true
mapped.ContactAttributes = map[string]any{"swt_third_party_source": truncate(cleanText(text), 2048)}
case 41, 61:
case 41:
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true
mapped.ConversationAttrs = map[string]any{"swt_outcome": cleanText(text)}
case 61:
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true
mapped.ContactName = cleanText(text)
case 52:
@@ -172,7 +177,6 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour
case 65:
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true
mapped.ContactAttributes = parseXSTProfile(text)
mapped.ContactName = visitorDisplayName(mapped.ContactAttributes)
case 66:
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true
mapped.ConversationAttrs = parseSearchSource(text)
@@ -438,16 +442,16 @@ func cleanText(value string) string {
}
func parseEnvironment(text string) map[string]any {
parts := strings.Fields(text)
indexes := map[int]string{0: "swt_ip", 1: "swt_ip_location", 2: "swt_isp", 7: "swt_resolution", 8: "swt_color_depth", 9: "swt_language", 10: "swt_timezone", 11: "swt_os", 13: "swt_browser", 14: "swt_browser_version"}
parts := strings.Split(text, " ")
indexes := map[int]string{0: "swt_ip", 1: "swt_ip_location", 4: "swt_environment_version", 7: "swt_resolution", 9: "swt_language", 16: "swt_timezone", 17: "swt_user_agent"}
result := map[string]any{}
for index, key := range indexes {
if index < len(parts) && parts[index] != "0" && parts[index] != "null" {
result[key] = truncate(decodeSWTValue(parts[index]), 512)
}
}
if len(parts) > 19 {
result["swt_user_agent"] = truncate(decodeSWTValue(strings.Join(parts[19:], " ")), 1024)
if userAgent, ok := result["swt_user_agent"].(string); ok && userAgent != "" {
setVisitorUserAgentAttributes(result, userAgent)
}
if location, ok := result["swt_ip_location"].(string); ok && location != "" {
result["city"] = location
@@ -455,6 +459,86 @@ func parseEnvironment(text string) map[string]any {
return result
}
func setVisitorUserAgentAttributes(attributes map[string]any, userAgent string) {
operatingSystem, device := parseVisitorUserAgent(userAgent)
if operatingSystem != "" {
attributes["swt_os"] = operatingSystem
}
if device != "" {
attributes["swt_device"] = device
}
}
func shangwutongLocationAttributes(attributes map[string]any) map[string]any {
location, _ := attributes["swt_ip_location"].(string)
location = strings.TrimSpace(location)
if location == "" {
return nil
}
return map[string]any{
"city": location,
"country": "China",
"country_code": "CN",
}
}
func parseVisitorUserAgent(userAgent string) (operatingSystem, device string) {
parts := strings.Split(userAgent, ";")
for index := range parts {
parts[index] = strings.TrimSpace(parts[index])
}
switch {
case strings.Contains(userAgent, "OpenHarmony"):
match := regexp.MustCompile(`OpenHarmony\s+([\d.]+)`).FindStringSubmatch(userAgent)
operatingSystem = "OpenHarmony"
if len(match) > 1 {
operatingSystem += " " + match[1]
}
device = firstText(firstEnvironmentPart(parts), "HarmonyOS 设备")
case strings.Contains(userAgent, "Android"):
match := regexp.MustCompile(`Android\s+([^;]+)`).FindStringSubmatch(userAgent)
operatingSystem = "Android"
if len(match) > 1 {
operatingSystem += " " + strings.TrimSpace(match[1])
}
for _, part := range parts {
if strings.Contains(part, " Build/") {
device = strings.TrimSpace(strings.SplitN(part, " Build/", 2)[0])
break
}
}
if device == "" {
device = firstText(last(parts), "Android 设备")
}
case strings.Contains(userAgent, "iPhone OS"):
match := regexp.MustCompile(`iPhone OS\s+([\d_]+)`).FindStringSubmatch(userAgent)
operatingSystem, device = "iOS", "iPhone"
if len(match) > 1 {
operatingSystem += " " + strings.ReplaceAll(match[1], "_", ".")
}
case strings.Contains(userAgent, "Windows NT 10.0"):
operatingSystem, device = "Windows 10/11", "Windows PC"
case strings.Contains(userAgent, "Windows"):
operatingSystem, device = "Windows", "Windows PC"
case strings.Contains(userAgent, "Mac OS X"):
match := regexp.MustCompile(`Mac OS X\s+([\d_]+)`).FindStringSubmatch(userAgent)
operatingSystem, device = "macOS", "Mac"
if len(match) > 1 {
operatingSystem += " " + strings.ReplaceAll(match[1], "_", ".")
}
case strings.Contains(userAgent, "Linux"):
operatingSystem, device = "Linux", "Linux 设备"
}
return operatingSystem, device
}
func firstEnvironmentPart(parts []string) string {
if len(parts) == 0 {
return ""
}
return parts[0]
}
func parseXSTProfile(text string) map[string]any {
result := map[string]any{"swt_xst_profile": truncate(cleanText(text), 2048)}
profiles := strings.Split(text, "|||||")
@@ -465,7 +549,7 @@ func parseXSTProfile(text string) map[string]any {
fields := map[int]string{
0: "swt_profile_channel", 1: "swt_visitor_nickname", 2: "swt_query_title",
3: "swt_query_word", 4: "swt_profile_location", 5: "swt_site_id",
8: "swt_traffic_mode", 10: "swt_resolution", 11: "swt_device",
8: "swt_traffic_mode", 10: "swt_resolution", 11: "swt_user_agent",
12: "swt_ip", 13: "swt_traffic_source",
}
for index, key := range fields {
@@ -480,34 +564,12 @@ func parseXSTProfile(text string) map[string]any {
if location, ok := result["swt_profile_location"].(string); ok && location != "" {
result["city"] = location
}
if userAgent, ok := result["swt_user_agent"].(string); ok && userAgent != "" {
setVisitorUserAgentAttributes(result, userAgent)
}
return result
}
func visitorDisplayName(attributes map[string]any) string {
for _, key := range []string{"swt_visitor_nickname", "swt_ip_location", "swt_profile_location", "swt_ip"} {
if value, ok := attributes[key].(string); ok && usableVisitorLabel(key, value) {
return truncate(strings.TrimSpace(value), 64)
}
}
return ""
}
func usableVisitorLabel(key, value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
if key != "swt_visitor_nickname" {
return true
}
// XST may put an opaque tracking token in the nickname slot. Never expose
// that token as the contact name; fall back to location or IP instead.
if len([]rune(value)) > 32 || regexp.MustCompile(`^[A-Za-z0-9_-]{24,}$`).MatchString(value) {
return false
}
return true
}
func decodeSWTValue(value string) string {
value = strings.TrimSpace(value)
if decoded, err := url.QueryUnescape(value); err == nil {
@@ -554,7 +616,7 @@ func callbackIdentity(text string) (string, string) {
if phone != "" && !strings.HasPrefix(phone, "+") {
phone = "+86" + strings.TrimPrefix(phone, "86")
}
name := strings.TrimSpace(strings.Replace(cleaned, phonePattern.FindString(cleaned), "", 1))
name := strings.Trim(strings.TrimSpace(strings.Replace(cleaned, phonePattern.FindString(cleaned), "", 1)), "|,:;-_")
return truncate(name, 255), phone
}
@@ -39,19 +39,57 @@ func TestMapInboundEventCoreStrategies(t *testing.T) {
}
}
func TestKind2UsesSequenceAsExternalMessageID(t *testing.T) {
mapped := mapInboundEvent(2, 98765, "hello", "", "", "swt:10:sid:2:98765:0", 10, time.Now())
func TestKind2UsesSequenceAsExternalMessageIDWithoutOverwritingContactName(t *testing.T) {
mapped := mapInboundEvent(2, 98765, "hello", "商务通客服名称", "", "swt:10:sid:2:98765:0", 10, time.Now())
if mapped.Message == nil || mapped.Message.ExternalSourceIDs["shangwutong"] != "98765" {
t.Fatalf("external source IDs = %#v", mapped.Message)
}
if mapped.ContactName != "" || mapped.Message.AdditionalAttributes["senderName"] != "商务通访客" {
t.Fatalf("visitor identity = %#v", mapped)
}
}
func TestKind2FallsBackToGenericNameWhenMessageHasNoVisitorName(t *testing.T) {
mapped := mapInboundEvent(2, 98765, "hello", "", "", "swt:10:sid:2:98765:0", 10, time.Now())
if mapped.ContactName != "" || mapped.Message.AdditionalAttributes["senderName"] != "商务通访客" {
t.Fatalf("visitor identity = %#v", mapped)
}
}
func TestKind12UpdatesContactNickname(t *testing.T) {
mapped := mapInboundEvent(12, 42, "贵州贵阳客人1219", "", "", "source", 10, time.Now())
if mapped.ContactName != "贵州贵阳客人1219" || !mapped.RequiresContact || mapped.RequiresConversation {
t.Fatalf("visitor nickname mapping = %#v", mapped)
}
}
func TestKind26UpdatesCurrentContactPhone(t *testing.T) {
mapped := mapInboundEvent(26, 42, "张三|13800138000", "", "", "source", 10, time.Now())
if mapped.ContactName != "张三" || mapped.ContactPhone != "+8613800138000" || !mapped.RequiresContact {
t.Fatalf("callback identity mapping = %#v", mapped)
}
}
func TestKind41StoresConversationOutcomeWithoutOverwritingContactName(t *testing.T) {
mapped := mapInboundEvent(41, 42, "留联", "", "", "source", 10, time.Now())
if mapped.ContactName != "" || !mapped.RequiresConversation || mapped.ConversationAttrs["swt_outcome"] != "留联" {
t.Fatalf("conversation outcome mapping = %#v", mapped)
}
}
func TestVisitorProfileMapsContactNameAndAttributes(t *testing.T) {
environment := mapInboundEvent(7, 1, "220.197.4.178 %e8%b4%b5%e5%b7%9e%e8%b4%b5%e9%98%b3 ISP 0 0 0 0 393x798 24 zh-CN +8 iPhone 0 Safari 18", "", "", "source", 10, time.Now())
if environment.ContactName != "贵州贵阳" {
environment := mapInboundEvent(7, 1, "220.197.4.178 %e8%b4%b5%e5%b7%9e%e8%b4%b5%e9%98%b3 5.6 1 1 393x798 zh-CN iPhone iPhone%3b+CPU+iPhone+OS+18_7+like+Mac+OS+X ", "", "", "source", 10, time.Now())
if environment.ContactName != "" {
t.Fatalf("environment contact name = %q", environment.ContactName)
}
if environment.ContactAttributes["swt_ip"] != "220.197.4.178" || environment.ContactAttributes["city"] != "贵州贵阳" {
if environment.ContactAttributes["swt_ip"] != "220.197.4.178" ||
environment.ContactAttributes["swt_ip_location"] != "贵州贵阳" ||
environment.ContactAttributes["city"] != "贵州贵阳" ||
environment.ContactAttributes["swt_resolution"] != "393x798" ||
environment.ContactAttributes["swt_environment_version"] != "5.6" ||
environment.ContactAttributes["swt_user_agent"] != "iPhone; CPU iPhone OS 18_7 like Mac OS X" ||
environment.ContactAttributes["swt_os"] != "iOS 18.7" ||
environment.ContactAttributes["swt_device"] != "iPhone" {
t.Fatalf("environment attributes = %#v", environment.ContactAttributes)
}
@@ -60,19 +98,42 @@ func TestVisitorProfileMapsContactNameAndAttributes(t *testing.T) {
"xst%7csbox%7czhinengzx", "医院", "fc", "1", "393x798", "iPhone%3b+CPU+iPhone+OS+18_7", "220.197.4.178", "百度搜索推广",
}, "\x1a")
profile := mapInboundEvent(65, 2, profileText, "", "", "source", 10, time.Now())
if profile.ContactName != "访客昵称" {
if profile.ContactName != "" {
t.Fatalf("profile contact name = %q", profile.ContactName)
}
if profile.ContactAttributes["swt_query_word"] != "甘油三酯高怎么办" || profile.ContactAttributes["swt_device"] != "iPhone; CPU iPhone OS 18_7" {
if profile.ContactAttributes["swt_query_word"] != "甘油三酯高怎么办" ||
profile.ContactAttributes["swt_user_agent"] != "iPhone; CPU iPhone OS 18_7" ||
profile.ContactAttributes["swt_device"] != "iPhone" ||
profile.ContactAttributes["swt_os"] != "iOS 18.7" {
t.Fatalf("profile attributes = %#v", profile.ContactAttributes)
}
profileText = strings.Join([]string{
"xst|sbox|zhinengzx", "TLnqPW6zPjDdPH9BPAc1nh7WPhfsuy79uH9-nHwBmy7WmHRKnHTvPjmsrjD1ns", "胆固醇高", "甘油三酯高怎么办", "贵州贵阳", "48989266",
}, "\x1a")
profile = mapInboundEvent(65, 3, profileText, "", "", "source", 10, time.Now())
if profile.ContactName != "贵州贵阳" {
t.Fatalf("opaque nickname fallback contact name = %q", profile.ContactName)
}
func TestParseVisitorUserAgent(t *testing.T) {
tests := []struct {
userAgent string
os string
device string
}{
{"Linux; Android 16; V2302A Build/BP2A.250605.031.A3; wv", "Android 16", "V2302A"},
{"Phone; OpenHarmony 6.1", "OpenHarmony 6.1", "Phone"},
{"iPhone; CPU iPhone OS 18_7 like Mac OS X", "iOS 18.7", "iPhone"},
{"Windows NT 10.0; Win64; x64", "Windows 10/11", "Windows PC"},
{"Macintosh; Intel Mac OS X 10_15_7", "macOS 10.15.7", "Mac"},
}
for _, test := range tests {
operatingSystem, device := parseVisitorUserAgent(test.userAgent)
if operatingSystem != test.os || device != test.device {
t.Fatalf("parseVisitorUserAgent(%q) = %q, %q", test.userAgent, operatingSystem, device)
}
}
}
func TestShangwutongLocationAttributesDefaultToChina(t *testing.T) {
attributes := shangwutongLocationAttributes(map[string]any{"swt_ip_location": "贵州贵阳"})
if attributes["country_code"] != "CN" || attributes["country"] != "China" || attributes["city"] != "贵州贵阳" {
t.Fatalf("location attributes = %#v", attributes)
}
}
@@ -96,12 +157,12 @@ func TestKnownKindMappingMatrix(t *testing.T) {
{3, "hello", "native_message", false}, {5, "unverified", "raw_only", true},
{7, "127.0.0.1 Beijing ISP 0 0 0 0 1920x1080 24 zh-CN +8 Windows 0 Chrome 149", "contact_attributes", false},
{8, "https://example.test 0 Landing 0 0 0 friendlink", "conversation_attributes", false},
{11, "3", "raw_only", true}, {12, "alias", "conversation_attributes", false},
{11, "3", "raw_only", true}, {12, "alias", "contact_attributes", false},
{15, "filemsg|name|https://media.example/file", "native_message", false},
{26, "张三|13800138000", "activity", false}, {29, "red", "conversation_attributes", false},
{30, "system", "activity", false}, {31, "guest_open_chat", "activity", false},
{34, "active", "conversation_attributes", false}, {35, "source", "contact_attributes", false},
{39, "unverified", "raw_only", true}, {41, "访客", "contact_attributes", false},
{39, "unverified", "raw_only", true}, {41, "留联", "conversation_attributes", false},
{52, "unverified#history", "raw_only", true}, {56, "web", "conversation_attributes", false},
{58, "left", "activity", false}, {61, "访客", "contact_attributes", false},
{65, "profile", "contact_attributes", false}, {66, "keyword|referrer|engine", "conversation_attributes", false},