fix(shangwutong): map kind 24 contact cid (#12)

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-13 19:27:26 +08:00
committed by GitHub
co-authored by rogee
parent a5ceb0af93
commit d00ea39f6c
9 changed files with 104 additions and 7 deletions
@@ -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
@@ -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)
@@ -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]) != "" {
@@ -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 {
@@ -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) {
@@ -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
@@ -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)
}
@@ -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 {
@@ -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 的直接根因。