From d00ea39f6cb74916934456d4214e3107fb413436 Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 13 Aug 2026 19:27:26 +0800 Subject: [PATCH] fix(shangwutong): map kind 24 contact cid (#12) Co-authored-by: Rogee --- .../shangwutong/internal/delivery/inbound.go | 2 +- .../internal/delivery/inbound_test.go | 23 ++++++++++++++++ .../shangwutong/internal/delivery/mapping.go | 17 +++++++++--- .../internal/delivery/mapping_test.go | 27 +++++++++++++++++++ .../internal/observability/metrics_test.go | 4 +++ .../shangwutong/internal/store/heartbeat.go | 2 +- .../internal/store/heartbeat_test.go | 4 +-- .../internal/swt/heartbeat_test.go | 10 +++++++ .../2026-08-13-shangwutong-kind24-cid.md | 22 +++++++++++++++ 9 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 docs/research/2026-08-13-shangwutong-kind24-cid.md diff --git a/channels/shangwutong/internal/delivery/inbound.go b/channels/shangwutong/internal/delivery/inbound.go index 6ae4999b..f808cd64 100644 --- a/channels/shangwutong/internal/delivery/inbound.go +++ b/channels/shangwutong/internal/delivery/inbound.go @@ -515,7 +515,7 @@ func (i *Inbound) recordMappingResult(event *dbgen.InboundEvent, mapped mappedEv func knownInboundKind(kind int64) bool { switch kind { - case -8, -5, -4, 0, 2, 3, 5, 7, 8, 11, 12, 15, 26, 29, 30, 31, 34, 35, 39, 41, 52, 56, 58, 61, 65, 66, 67, 71: + case -8, -5, -4, 0, 2, 3, 5, 7, 8, 11, 12, 15, 24, 26, 29, 30, 31, 34, 35, 39, 41, 52, 56, 58, 61, 65, 66, 67, 71: return true default: return false diff --git a/channels/shangwutong/internal/delivery/inbound_test.go b/channels/shangwutong/internal/delivery/inbound_test.go index 94ed9bc1..3e9cff53 100644 --- a/channels/shangwutong/internal/delivery/inbound_test.go +++ b/channels/shangwutong/internal/delivery/inbound_test.go @@ -70,6 +70,29 @@ func TestInboundKind52PersistsCIDWithoutCreatingResources(t *testing.T) { } } +func TestInboundKind24PersistsCIDWithoutCreatingResources(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + persistInboundEvent(t, database, account, swt.HeartbeatEvent{ + SessionID: "visitor", Kind: 24, Text: "cookie/123", SeqID: 43, + Timestamp: time.Now().Format(time.RFC3339Nano), RawLine: "visitor 24 [REDACTED] 43 timestamp", + }) + client := &inboundRecorder{} + worker, err := NewInbound(database, client, nil, 1) + if err != nil { + t.Fatal(err) + } + if worked, err := worker.processInbound(ctx); err != nil || !worked { + t.Fatalf("process kind=24 = %v, %v", worked, err) + } + if client.metadataUpdates != 1 || client.lastCID != "cookie/123" || client.lastSourceID != "visitor" || client.lastInboxID != account.GochatInboxID { + t.Fatalf("metadata updates = %#v", client) + } + if len(client.imports) != 0 { + t.Fatalf("kind=24 must not import a message: %#v", client.imports) + } +} + func TestInboundKind3ConfirmsUniqueOutboundEcho(t *testing.T) { ctx := context.Background() database, account := deliveryDatabase(t, ctx) diff --git a/channels/shangwutong/internal/delivery/mapping.go b/channels/shangwutong/internal/delivery/mapping.go index 276f5e6b..56f3ce10 100644 --- a/channels/shangwutong/internal/delivery/mapping.go +++ b/channels/shangwutong/internal/delivery/mapping.go @@ -43,6 +43,10 @@ type mediaReference struct { func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sourceID string, inboxID int64, fallbackTime time.Time) mappedEvent { mapped := mappedEvent{Strategy: "raw_only", RawOnly: true} + mapped.ContactCID = parseContactCID(kind, text) + if mapped.ContactCID != "" { + mapped.Strategy = "contact_attributes" + } baseAttributes := map[string]any{"swt": map[string]any{ "kind": kind, "seq_id": seqID, "raw_timestamp": rawTimestamp, "historical": false, "unparsed": false, }} @@ -167,8 +171,7 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour case 61: mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true mapped.ContactName = cleanText(text) - case 52: - mapped.ContactCID = parseHistoryCID(text) + case 24, 52: case 56: mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true mapped.ConversationAttrs = map[string]any{"swt_conversation_type": cleanText(text)} @@ -192,7 +195,15 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour return mapped } -func parseHistoryCID(text string) string { +func parseContactCID(kind int64, text string) string { + // Android HeartBeat maps kind=24 text and kind=52 history field 1 to + // ChatBean.cookies, which RenamedThread sends as changecname.aspx cid. + if kind == 24 { + return strings.TrimSpace(text) + } + if kind != 52 { + return "" + } for _, record := range strings.Split(text, "#") { fields := strings.Split(record, "|") if len(fields) > 5 && strings.TrimSpace(fields[1]) != "" { diff --git a/channels/shangwutong/internal/delivery/mapping_test.go b/channels/shangwutong/internal/delivery/mapping_test.go index 7b34da8f..40c800f9 100644 --- a/channels/shangwutong/internal/delivery/mapping_test.go +++ b/channels/shangwutong/internal/delivery/mapping_test.go @@ -70,6 +70,33 @@ func TestKind52ExtractsCIDWithoutTreatingHistoryAsMessages(t *testing.T) { } } +func TestCIDExtractionUsesOnlyVerifiedKinds(t *testing.T) { + tests := []struct { + kind int64 + text string + want string + }{ + {24, "cookie/123", "cookie/123"}, + {52, "a|history%2F123|x|y|z|name", "history/123"}, + } + for _, kind := range []int64{0, 1, 3, 6, 7, 8, 11, 23, 31, 65, 67, 71, 73, 82} { + tests = append(tests, struct { + kind int64 + text string + want string + }{kind, `{"cid":"must-not-parse","SWTCID":"must-not-parse"}`, ""}) + } + for _, test := range tests { + mapped := mapInboundEvent(test.kind, 42, test.text, "", "", "source", 10, time.Now()) + if mapped.ContactCID != test.want { + t.Fatalf("kind=%d ContactCID=%q, want %q", test.kind, mapped.ContactCID, test.want) + } + if test.want != "" && mapped.Strategy != "contact_attributes" { + t.Fatalf("kind=%d strategy=%q, want contact_attributes", test.kind, mapped.Strategy) + } + } +} + func TestKind26UpdatesCurrentContactPhone(t *testing.T) { mapped := mapInboundEvent(26, 42, "张三|13800138000", "", "", "source", 10, time.Now()) if mapped.ContactName != "张三" || mapped.ContactPhone != "+8613800138000" || !mapped.RequiresContact { diff --git a/channels/shangwutong/internal/observability/metrics_test.go b/channels/shangwutong/internal/observability/metrics_test.go index 7d4aca36..af569e7f 100644 --- a/channels/shangwutong/internal/observability/metrics_test.go +++ b/channels/shangwutong/internal/observability/metrics_test.go @@ -15,6 +15,7 @@ func TestMetricsRenderProductionSeriesWithoutHighCardinalityLabels(t *testing.T) metrics.Delivery("inbound", "delivered") metrics.StatusSync("success", "sent") metrics.Mapping(2, "native_message", "delivered") + metrics.Mapping(24, "contact_attributes", "delivered") metrics.Unknown(999) metrics.UnmappedRetraction("incoming") metrics.ContractError("gochat_to_connector", "invalid_signature") @@ -45,6 +46,9 @@ func TestMetricsRenderProductionSeriesWithoutHighCardinalityLabels(t *testing.T) t.Fatalf("high-cardinality label %q found in:\n%s", forbidden, payload) } } + if !strings.Contains(payload, `swt_connector_event_mapping_total{kind="24",strategy="contact_attributes",result="delivered"} 1`) { + t.Fatalf("redacted kind=24 mapping diagnostic missing from:\n%s", payload) + } } func TestMetricKindKeepsEveryDocumentedKindBounded(t *testing.T) { diff --git a/channels/shangwutong/internal/store/heartbeat.go b/channels/shangwutong/internal/store/heartbeat.go index 1eb14975..72d1de03 100644 --- a/channels/shangwutong/internal/store/heartbeat.go +++ b/channels/shangwutong/internal/store/heartbeat.go @@ -63,7 +63,7 @@ func (s *Store) PersistHeartbeat(ctx context.Context, account *dbgen.Account, ev func ignoredKind(kind int) bool { switch kind { - case -7, 1, 14, 24, 38, 44, 62: + case -7, 1, 14, 38, 44, 62: return true default: return false diff --git a/channels/shangwutong/internal/store/heartbeat_test.go b/channels/shangwutong/internal/store/heartbeat_test.go index b6ad7bdd..5bf05a02 100644 --- a/channels/shangwutong/internal/store/heartbeat_test.go +++ b/channels/shangwutong/internal/store/heartbeat_test.go @@ -48,12 +48,12 @@ func TestPersistHeartbeatStoresEventsBeforeCursorAndIsIdempotent(t *testing.T) { } func TestIgnoredKindMatrix(t *testing.T) { - for _, kind := range []int{-7, 1, 14, 24, 38, 44, 62} { + for _, kind := range []int{-7, 1, 14, 38, 44, 62} { if !ignoredKind(kind) { t.Fatalf("kind %d should be ignored", kind) } } - for _, kind := range []int{-8, -5, -4, 0, 2, 3, 11, 31, 52, 67, 71} { + for _, kind := range []int{-8, -5, -4, 0, 2, 3, 11, 24, 31, 52, 67, 71} { if ignoredKind(kind) { t.Fatalf("kind %d must remain deliverable", kind) } diff --git a/channels/shangwutong/internal/swt/heartbeat_test.go b/channels/shangwutong/internal/swt/heartbeat_test.go index b4be45c2..c05546a0 100644 --- a/channels/shangwutong/internal/swt/heartbeat_test.go +++ b/channels/shangwutong/internal/swt/heartbeat_test.go @@ -26,6 +26,16 @@ func TestParseHeartbeatLinePreservesTextSpaces(t *testing.T) { } } +func TestParseHeartbeatLineDecodesKind24CID(t *testing.T) { + event, err := ParseHeartbeatLine("visitor 24 cookie%2F123 43 639183484622475272") + if err != nil { + t.Fatal(err) + } + if event.Kind != 24 || event.Text != "cookie/123" || event.OpName != "" { + t.Fatalf("unexpected event: %#v", event) + } +} + func TestRetractionTargetIsTextNotEventSequence(t *testing.T) { event, err := ParseHeartbeatLine("sid -4 4360377 4360400 639183484622475272") if err != nil { diff --git a/docs/research/2026-08-13-shangwutong-kind24-cid.md b/docs/research/2026-08-13-shangwutong-kind24-cid.md new file mode 100644 index 00000000..b72e2bc7 --- /dev/null +++ b/docs/research/2026-08-13-shangwutong-kind24-cid.md @@ -0,0 +1,22 @@ +日期:2026-08-13 + +依赖:`reference/shang-wu-tong/reverse/swt-decompiled/`、H-47 脱敏真实 kind 统计。 + +## 结论 + +商务通实时入站的 `cid` 来自 `kind=24` 的 text,不需要等待 `kind=52` 历史批次,也不能用 `sid` 替代。 + +## 协议证据 + +- `HeartBeat.java:520` 将 `kind=24` 排除出普通事件分支,`:3458-3463` 直接把已解码 `text2` 写入 `ChatBean.cookies`。 +- `HeartBeat.java:566-578` 将 `kind=52` 历史记录第二字段 URL 解码后写入同一 `ChatBean.cookies`。 +- `ChatDetailFragmentMessage.java:780-860` 调用 `RenamedThread` 修改昵称。 +- `RenamedThread.java:35-48` 把 `ChatBean.cookies` 作为 `oc/changecname.aspx` 的 `cid` 表单字段。 + +因此 Connector 只接受 APK 已证实的 `kind=24` 和 `kind=52` CID 来源。`kind=65`/渠道 JSON 可能有同名字段,但属于渠道扩展协议,不作为通用来源;其他 kind 即使正文出现 `cid`、`SWTCID` 或 `sid` 也不得猜测。 + +## 脱敏真实样本 + +授权轮询仅保留 kind 分布:`0/1/3/6/7/8/11/23/24/31/65/67/71/73/82`,`kind=52` 为零。未保留访客正文、完整 SID、CID 或凭据。 + +该分布覆盖到 `kind=24`,说明旧实现把它标记为 ignored 是昵称回写无 CID 的直接根因。