fix(shangwutong): 完善访客资料同步
This commit is contained in:
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user