From aa0ee6be3276be39f6685967ee954ad81378662f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E7=B3=BB?= <5821137+eyaeya@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:12:56 +0800 Subject: [PATCH] feat: resolve group senders to room-specific nicknames --- AGENTS.md | 3 +- README.md | 8 +- cmd/wechat-cli/cli.go | 12 +- cmd/wechat-cli/context_tool.go | 4 +- cmd/wechat-cli/group_nickname.go | 553 ++++++++++++++++++++++++++ cmd/wechat-cli/group_nickname_test.go | 388 ++++++++++++++++++ cmd/wechat-cli/main.go | 22 +- cmd/wechat-cli/tools.go | 8 +- 8 files changed, 974 insertions(+), 24 deletions(-) create mode 100644 cmd/wechat-cli/group_nickname.go create mode 100644 cmd/wechat-cli/group_nickname_test.go diff --git a/AGENTS.md b/AGENTS.md index 14377e0..eaf1444 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,7 +250,8 @@ does not install a launchd watcher by default for the same reason. Agent-ready message rows include: `id(local_id/server_id_str/talker)`, `time`, `create_time`, `time_iso`, `sender`, -`sender_wxid`, `is_from_me`, `kind`, `text`, display-ready `images` / `videos` / +`sender_wxid`, optional `sender_group_nickname` / `sender_contact_display`, +`is_from_me`, `kind`, `text`, display-ready `images` / `videos` / `files` / `link` / `music` / `miniprogram` / `forward_chat` / `quote` / `transfer` / `red_packet` / `location` / `voice.transcript` / `solitaire`, and concise `warnings`. diff --git a/README.md b/README.md index c3bacc2..626d962 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,10 @@ jq -n --arg chat "聊天名" '{chat:$chat,limit:20}' | wechat-cli call-json time { "id": {"local_id": 123, "server_id_str": "9876543210", "talker": "xxx@chatroom"}, "time_iso": "2026-05-26T13:00:00+08:00", - "sender": "Alice", + "sender": "Room Nickname", + "sender_wxid": "wxid_alice", + "sender_group_nickname": "Room Nickname", + "sender_contact_display": "Alice", "is_from_me": false, "kind": "image", "text": "[图片]", @@ -158,6 +161,9 @@ jq -n --arg chat "聊天名" '{chat:$chat,limit:20}' | wechat-cli call-json time } ``` +群消息优先使用可可靠解析的群内昵称作为 `sender`。`sender_wxid` 保留稳定身份, +`sender_contact_display` 与 `sender_group_nickname` 仅在对应信息可可靠解析时返回。 + 默认只返回人能在微信里看到、且 agent 可直接使用的信息。raw XML、CDN key、协议码、不可读 `.dat` 与候选路径只在 debug/full 输出中出现。 ## 严格只读 diff --git a/cmd/wechat-cli/cli.go b/cmd/wechat-cli/cli.go index 454b8c6..da38293 100644 --- a/cmd/wechat-cli/cli.go +++ b/cmd/wechat-cli/cli.go @@ -821,11 +821,13 @@ func cliSearchMessageRow(row map[string]any, args map[string]any) map[string]any "local_id": row["local_id"], "talker": row["talker"], }), - "time": formatUnixLocal(createTime), - "time_iso": cliFormatUnixISO(createTime), - "create_time": row["create_time"], - "sender": firstNonEmpty(stringMapValue(row, "sender_display_name"), stringMapValue(row, "sender_wxid")), - "sender_wxid": row["sender_wxid"], + "time": formatUnixLocal(createTime), + "time_iso": cliFormatUnixISO(createTime), + "create_time": row["create_time"], + "sender": firstNonEmpty(stringMapValue(row, "sender_display_name"), stringMapValue(row, "sender_wxid")), + "sender_wxid": row["sender_wxid"], + "sender_group_nickname": row["sender_group_nickname"], + "sender_contact_display": row["sender_contact_display"], "chat": compactMap(map[string]any{ "talker": row["talker"], "display_name": row["talker_display_name"], diff --git a/cmd/wechat-cli/context_tool.go b/cmd/wechat-cli/context_tool.go index 82d3ab2..080e27f 100644 --- a/cmd/wechat-cli/context_tool.go +++ b/cmd/wechat-cli/context_tool.go @@ -309,9 +309,7 @@ func (s *server) finishContextRows(talker string, rows []wcdb.Row) { return } setContextTalker(talker, rows) - s.attachDisplayNames(rows, - [2]string{"talker", "talker_display_name"}, - [2]string{"sender_wxid", "sender_display_name"}) + s.attachMessageDisplayNames(rows) if selfWxid := s.selfWxid(); selfWxid != "" { for _, r := range rows { sw := rowString(r, "sender_wxid") diff --git a/cmd/wechat-cli/group_nickname.go b/cmd/wechat-cli/group_nickname.go new file mode 100644 index 0000000..28e67bb --- /dev/null +++ b/cmd/wechat-cli/group_nickname.go @@ -0,0 +1,553 @@ +package main + +import ( + "encoding/binary" + "encoding/xml" + "fmt" + "sort" + "strings" + "unicode/utf8" + + "github.com/r266-tech/wechat-cli/internal/wcdb" +) + +const ( + quoteMessageLocalType int64 = (57 << 32) | 49 + quoteNicknameScanLimit = 500 + roomQueryBatchSize = 200 + maxRoomDataBytes = 8 << 20 + maxRoomDataUserBytes = 16 << 10 + maxRoomDataMembers = 10000 + maxIdentityUsernameBytes = 512 + maxGroupNicknameBytes = 2048 + maxQuoteMessageContentBytes = 2 << 20 + maxGroupNicknameRoomsPerRequest = 32 + maxQuoteNicknameRowsPerRequest = 4000 +) + +// parseRoomDataNicknames decodes the useful subset of WeChat's RoomData +// protobuf stored in chat_room.ext_buffer: +// +// message RoomData { +// repeated RoomDataUser users = 1; +// } +// message RoomDataUser { +// string userName = 1; +// optional string displayName = 2; +// } +// +// It intentionally uses a small wire-format reader instead of pulling in a +// protobuf runtime for two string fields. Malformed or unknown fields are +// ignored best-effort and never make message reads fail. +func parseRoomDataNicknames(blob []byte) map[string]string { + out := make(map[string]string) + if len(blob) == 0 || len(blob) > maxRoomDataBytes { + return out + } + for len(blob) > 0 { + field, wire, value, rest, ok := consumeProtoField(blob) + if !ok { + break + } + blob = rest + if field != 1 || wire != 2 { + continue + } + if len(value) > maxRoomDataUserBytes { + continue + } + username, displayName := parseRoomDataUser(value) + if username != "" { + out[username] = displayName + if len(out) >= maxRoomDataMembers { + break + } + } + } + return out +} + +func parseRoomDataUser(blob []byte) (string, string) { + var username, displayName string + for len(blob) > 0 { + field, wire, value, rest, ok := consumeProtoField(blob) + if !ok { + break + } + blob = rest + if wire != 2 || !utf8.Valid(value) { + continue + } + switch field { + case 1: + if len(value) <= maxIdentityUsernameBytes { + username = strings.TrimSpace(string(value)) + } + case 2: + if len(value) <= maxGroupNicknameBytes { + displayName = strings.TrimSpace(string(value)) + } + } + } + return username, displayName +} + +func consumeProtoField(blob []byte) (field int, wire int, value, rest []byte, ok bool) { + key, keyLen := binary.Uvarint(blob) + if keyLen <= 0 || key == 0 { + return 0, 0, nil, nil, false + } + blob = blob[keyLen:] + field = int(key >> 3) + wire = int(key & 7) + switch wire { + case 0: + _, n := binary.Uvarint(blob) + if n <= 0 { + return 0, 0, nil, nil, false + } + return field, wire, nil, blob[n:], true + case 1: + if len(blob) < 8 { + return 0, 0, nil, nil, false + } + return field, wire, nil, blob[8:], true + case 2: + size, n := binary.Uvarint(blob) + if n <= 0 { + return 0, 0, nil, nil, false + } + blob = blob[n:] + if size > uint64(len(blob)) { + return 0, 0, nil, nil, false + } + end := int(size) + return field, wire, blob[:end], blob[end:], true + case 5: + if len(blob) < 4 { + return 0, 0, nil, nil, false + } + return field, wire, nil, blob[4:], true + default: + return 0, 0, nil, nil, false + } +} + +type groupNicknameCandidate struct { + name string + createTime int64 + sortSeq int64 + localID int64 +} + +type groupNicknameCandidates map[string]map[string]groupNicknameCandidate + +type quoteNicknameXML struct { + AppMsg struct { + ReferMsg struct { + ChatUsr string `xml:"chatusr"` + DisplayName string `xml:"displayname"` + FromUsr string `xml:"fromusr"` + } `xml:"refermsg"` + } `xml:"appmsg"` +} + +// quoteGroupNicknameCandidates extracts the identity evidence embedded in +// quoted-message XML. WeChat 4.x commonly stores it as: +// refermsg.fromusr=room@chatroom, refermsg.chatusr=member, displayname=群昵称. +// The carrying row's talker must exactly match fromusr; looser orientations +// are rejected so a quoted message cannot assign a nickname to the wrong room. +func quoteGroupNicknameCandidates(rows []wcdb.Row) groupNicknameCandidates { + out := make(groupNicknameCandidates) + for _, row := range rows { + parsed, _ := row["message_content_parsed"].(map[string]any) + refer, _ := parsed["refermsg"].(map[string]any) + if refer == nil { + continue + } + displayName := strings.TrimSpace(stringMapValue(refer, "displayname")) + if displayName == "" || len(displayName) > maxGroupNicknameBytes || !utf8.ValidString(displayName) { + continue + } + currentRoom := strings.TrimSpace(rowString(row, "talker")) + fromUser := strings.TrimSpace(stringMapValue(refer, "fromusr")) + chatUser := strings.TrimSpace(stringMapValue(refer, "chatusr")) + if !strings.HasSuffix(currentRoom, "@chatroom") || + fromUser != currentRoom || + chatUser == "" || + len(currentRoom) > maxIdentityUsernameBytes || + len(chatUser) > maxIdentityUsernameBytes || + strings.HasSuffix(chatUser, "@chatroom") { + continue + } + candidate := groupNicknameCandidate{ + name: displayName, + createTime: rowInt64(row, "create_time"), + sortSeq: rowInt64(row, "sort_seq"), + localID: rowInt64(row, "local_id"), + } + if out[currentRoom] == nil { + out[currentRoom] = make(map[string]groupNicknameCandidate) + } + previous, exists := out[currentRoom][chatUser] + if !exists || newerGroupNicknameCandidate(candidate, previous) { + out[currentRoom][chatUser] = candidate + } + } + return out +} + +// quoteGroupNicknameCandidatesFromContent parses only the three refermsg +// identity strings. It avoids the full recursive message parser for the +// bounded historical fallback scan. +func quoteGroupNicknameCandidatesFromContent(rows []wcdb.Row) groupNicknameCandidates { + identityRows := make([]wcdb.Row, 0, len(rows)) + for _, row := range rows { + content := rowString(row, "message_content") + if len(content) == 0 || len(content) > maxQuoteMessageContentBytes { + continue + } + var parsed quoteNicknameXML + if err := xml.Unmarshal([]byte(stripMsgPrefix(content)), &parsed); err != nil { + continue + } + refer := parsed.AppMsg.ReferMsg + if refer.ChatUsr == "" && refer.DisplayName == "" && refer.FromUsr == "" { + continue + } + identityRow := wcdb.Row{ + "talker": rowString(row, "talker"), + "create_time": rowInt64(row, "create_time"), + "sort_seq": rowInt64(row, "sort_seq"), + "local_id": rowInt64(row, "local_id"), + "message_content_parsed": map[string]any{ + "refermsg": map[string]any{ + "chatusr": refer.ChatUsr, + "displayname": refer.DisplayName, + "fromusr": refer.FromUsr, + }, + }, + } + identityRows = append(identityRows, identityRow) + } + return quoteGroupNicknameCandidates(identityRows) +} + +func newerGroupNicknameCandidate(candidate, previous groupNicknameCandidate) bool { + if candidate.createTime != previous.createTime { + return candidate.createTime > previous.createTime + } + if candidate.sortSeq != previous.sortSeq { + return candidate.sortSeq > previous.sortSeq + } + return candidate.localID > previous.localID +} + +func candidateNames(candidates groupNicknameCandidates) map[string]map[string]string { + out := make(map[string]map[string]string) + for room, members := range candidates { + for member, candidate := range members { + if candidate.name == "" { + continue + } + if out[room] == nil { + out[room] = make(map[string]string) + } + out[room][member] = candidate.name + } + } + return out +} + +func mergeNicknameCandidates(dst, src groupNicknameCandidates) { + for room, members := range src { + if dst[room] == nil { + dst[room] = make(map[string]groupNicknameCandidate) + } + for member, candidate := range members { + previous, exists := dst[room][member] + if !exists || newerGroupNicknameCandidate(candidate, previous) { + dst[room][member] = candidate + } + } + } +} + +func messageGroupMembers(rows []wcdb.Row) map[string]map[string]bool { + out := make(map[string]map[string]bool) + for _, row := range rows { + room := strings.TrimSpace(rowString(row, "talker")) + member := strings.TrimSpace(rowString(row, "sender_wxid")) + if !strings.HasSuffix(room, "@chatroom") || + member == "" || + len(room) > maxIdentityUsernameBytes || + len(member) > maxIdentityUsernameBytes || + strings.HasSuffix(member, "@chatroom") { + continue + } + if out[room] == nil && len(out) >= maxGroupNicknameRoomsPerRequest { + continue + } + if out[room] == nil { + out[room] = make(map[string]bool) + } + out[room][member] = true + } + return out +} + +func membersAbsentFromRoomData( + needed map[string]map[string]bool, + roomData map[string]map[string]string, +) map[string]map[string]bool { + out := make(map[string]map[string]bool) + for room, members := range needed { + for member := range members { + if _, knownInCurrentRoster := roomData[room][member]; knownInCurrentRoster { + continue + } + if out[room] == nil { + out[room] = make(map[string]bool) + } + out[room][member] = true + } + } + return out +} + +func mergeGroupNicknameMaps(dst, src map[string]map[string]string, overwrite bool) { + for room, members := range src { + if dst[room] == nil { + dst[room] = make(map[string]string) + } + for member, name := range members { + if name == "" || (!overwrite && dst[room][member] != "") { + continue + } + dst[room][member] = name + } + } +} + +func copyGroupNicknameMap(src map[string]map[string]string) map[string]map[string]string { + out := make(map[string]map[string]string) + mergeGroupNicknameMaps(out, src, true) + return out +} + +func mergeFallbackNicknameMaps(dst, src, roomData map[string]map[string]string) { + for room, members := range src { + for member, name := range members { + // Current roster presence is authoritative even when displayName is + // empty; otherwise an older quote could resurrect a cleared card. + if _, knownInCurrentRoster := roomData[room][member]; knownInCurrentRoster { + continue + } + if name == "" || dst[room][member] != "" { + continue + } + if dst[room] == nil { + dst[room] = make(map[string]string) + } + dst[room][member] = name + } + } +} + +// Current RoomData wins because a member may change their room nickname after +// older quoted messages have captured a previous value. +func groupNicknameNamesFromSources(rows []wcdb.Row, roomData, historical map[string]map[string]string) map[string]map[string]string { + names := copyGroupNicknameMap(roomData) + mergeFallbackNicknameMaps(names, historical, roomData) + mergeFallbackNicknameMaps(names, candidateNames(quoteGroupNicknameCandidates(rows)), roomData) + return names +} + +// applyGroupNicknames preserves the contact-level display string separately +// before making the room-specific nickname the primary sender display. +func applyGroupNicknames(rows []wcdb.Row, names map[string]map[string]string) { + for _, row := range rows { + room := rowString(row, "talker") + member := rowString(row, "sender_wxid") + if !strings.HasSuffix(room, "@chatroom") || member == "" { + continue + } + if contactDisplay := rowString(row, "sender_display_name"); contactDisplay != "" && + contactDisplay != member && + contactDisplay != rowString(row, "sender_group_nickname") { + row["sender_contact_display"] = contactDisplay + } + if groupNickname := strings.TrimSpace(names[room][member]); groupNickname != "" { + row["sender_group_nickname"] = groupNickname + row["sender_display_name"] = groupNickname + } + } +} + +func addOptionalGroupIdentity(out map[string]any, row wcdb.Row) { + if groupNickname := rowString(row, "sender_group_nickname"); groupNickname != "" { + out["sender_group_nickname"] = groupNickname + } + if contactDisplay := rowString(row, "sender_contact_display"); contactDisplay != "" { + out["sender_contact_display"] = contactDisplay + } +} + +func attachGroupNicknames( + rows []wcdb.Row, + roomDataLookup func(map[string]map[string]bool) map[string]map[string]string, + quoteLookup func(map[string]map[string]bool) map[string]map[string]string, +) { + needed := messageGroupMembers(rows) + if len(needed) == 0 { + return + } + + roomData := roomDataLookup(needed) + + // If contact.db is unavailable or unreadable, or the member is absent from + // current RoomData, scan recent quote rows from only the affected room(s). + // This remains a strict read-only, bounded fallback. + missing := membersAbsentFromRoomData(needed, roomData) + var historical map[string]map[string]string + if len(missing) > 0 { + historical = quoteLookup(missing) + } + names := groupNicknameNamesFromSources(rows, roomData, historical) + applyGroupNicknames(rows, names) +} + +func (s *server) attachMessageDisplayNames(rows []wcdb.Row) { + if len(rows) == 0 { + return + } + s.attachDisplayNames(rows, + [2]string{"talker", "talker_display_name"}, + [2]string{"sender_wxid", "sender_display_name"}) + attachGroupNicknames(rows, s.lookupRoomDataNicknames, s.lookupQuoteGroupNicknames) +} + +func (s *server) lookupRoomDataNicknames(needed map[string]map[string]bool) map[string]map[string]string { + if len(needed) == 0 { + return nil + } + db, err := s.openDB("contact", "contact.db") + if err != nil { + return nil + } + defer db.Close() + + rooms := make([]string, 0, len(needed)) + for room := range needed { + rooms = append(rooms, room) + } + sort.Strings(rooms) + if len(rooms) > maxGroupNicknameRoomsPerRequest { + rooms = rooms[:maxGroupNicknameRoomsPerRequest] + } + out := make(map[string]map[string]string) + for start := 0; start < len(rooms); start += roomQueryBatchSize { + end := start + roomQueryBatchSize + if end > len(rooms) { + end = len(rooms) + } + batch := rooms[start:end] + placeholders := make([]string, len(batch)) + args := make([]any, len(batch)) + for i, room := range batch { + placeholders[i] = "?" + args[i] = room + } + rows, queryErr := db.Query(fmt.Sprintf( + `SELECT username, ext_buffer FROM chat_room WHERE username IN (%s)`, + strings.Join(placeholders, ",")), args...) + if queryErr != nil { + return out + } + for _, row := range rows { + room := rowString(row, "username") + for member, name := range parseRoomDataNicknames(rowBytes(row, "ext_buffer")) { + if !needed[room][member] { + continue + } + if out[room] == nil { + out[room] = make(map[string]string) + } + out[room][member] = name + } + } + } + return out +} + +func (s *server) lookupQuoteGroupNicknames(needed map[string]map[string]bool) map[string]map[string]string { + if len(needed) == 0 { + return nil + } + allCandidates := make(groupNicknameCandidates) + rooms := make([]string, 0, len(needed)) + for room := range needed { + rooms = append(rooms, room) + } + sort.Strings(rooms) + if len(rooms) > maxGroupNicknameRoomsPerRequest { + rooms = rooms[:maxGroupNicknameRoomsPerRequest] + } + remainingRows := maxQuoteNicknameRowsPerRequest + for _, room := range rooms { + if remainingRows <= 0 { + break + } + tableName := "Msg_" + talkerHash(room) + shards, err := s.findMsgDBs(tableName) + if err != nil { + continue + } + for _, shard := range shards { + if remainingRows <= 0 { + break + } + queryLimit := minInt(quoteNicknameScanLimit, remainingRows) + rows, queryErr := shard.DB.Query(fmt.Sprintf( + `SELECT local_id, create_time, sort_seq, local_type, message_content + FROM %s WHERE local_type = ? + ORDER BY sort_seq DESC, local_id DESC LIMIT ?`, + quoteIdent(tableName)), quoteMessageLocalType, queryLimit) + if queryErr != nil { + continue + } + remainingRows -= len(rows) + boundedRows := rows[:0] + for _, row := range rows { + if size := len(rowBytes(row, "message_content")); size == 0 || size > maxQuoteMessageContentBytes { + continue + } + boundedRows = append(boundedRows, row) + } + boundedRows = decodeFields(boundedRows, "message_content") + decodedRows := boundedRows[:0] + for _, row := range boundedRows { + if content := rowString(row, "message_content"); len(content) == 0 || len(content) > maxQuoteMessageContentBytes { + continue + } + row["talker"] = room + decodedRows = append(decodedRows, row) + } + mergeNicknameCandidates(allCandidates, quoteGroupNicknameCandidatesFromContent(decodedRows)) + } + closeMsgDBs(shards) + } + + names := candidateNames(allCandidates) + for room, members := range names { + for member := range members { + if !needed[room][member] { + delete(members, member) + } + } + if len(members) == 0 { + delete(names, room) + } + } + return names +} diff --git a/cmd/wechat-cli/group_nickname_test.go b/cmd/wechat-cli/group_nickname_test.go new file mode 100644 index 0000000..103c4a5 --- /dev/null +++ b/cmd/wechat-cli/group_nickname_test.go @@ -0,0 +1,388 @@ +package main + +import ( + "encoding/binary" + "strings" + "testing" + + "github.com/r266-tech/wechat-cli/internal/wcdb" +) + +func testProtoVarint(field int, value uint64) []byte { + out := binary.AppendUvarint(nil, uint64(field<<3)) + return binary.AppendUvarint(out, value) +} + +func testProtoBytes(field int, value []byte) []byte { + out := binary.AppendUvarint(nil, uint64(field<<3|2)) + out = binary.AppendUvarint(out, uint64(len(value))) + return append(out, value...) +} + +func testRoomDataUser(username, displayName string) []byte { + out := testProtoBytes(1, []byte(username)) + if displayName != "" { + out = append(out, testProtoBytes(2, []byte(displayName))...) + } + return append(out, testProtoVarint(3, 1)...) +} + +func testQuoteRow(room, member, displayName string, createTime, sortSeq, localID int64) wcdb.Row { + return wcdb.Row{ + "talker": room, + "local_id": localID, + "create_time": createTime, + "sort_seq": sortSeq, + "message_content_parsed": map[string]any{ + "refermsg": map[string]any{ + "fromusr": room, + "chatusr": member, + "displayname": displayName, + }, + }, + } +} + +func TestParseRoomDataNicknames(t *testing.T) { + var blob []byte + blob = append(blob, testProtoVarint(5, 500)...) + blob = append(blob, testProtoBytes(1, testRoomDataUser("wxid_member_a", "Group Card A"))...) + blob = append(blob, testProtoBytes(1, testRoomDataUser("wxid_member_b", "Group Card B"))...) + blob = append(blob, testProtoBytes(1, testRoomDataUser("wxid_without_room_name", ""))...) + + got := parseRoomDataNicknames(blob) + if got["wxid_member_a"] != "Group Card A" || got["wxid_member_b"] != "Group Card B" { + t.Fatalf("RoomData nicknames = %#v", got) + } + if displayName, ok := got["wxid_without_room_name"]; !ok || displayName != "" { + t.Fatalf("member without a group nickname should retain empty roster presence: %#v", got) + } +} + +func TestParseRoomDataNicknamesBoundsAndMalformedData(t *testing.T) { + valid := testProtoBytes(1, testRoomDataUser("wxid_member_a", "Group Card A")) + malformedTail := append(append([]byte(nil), valid...), 0x0a, 0xff) + if got := parseRoomDataNicknames(malformedTail); got["wxid_member_a"] != "Group Card A" { + t.Fatalf("valid prefix was lost after malformed tail: %#v", got) + } + + invalidUTF8 := testProtoBytes(1, append(testProtoBytes(1, []byte("wxid_member_a")), testProtoBytes(2, []byte{0xff, 0xfe})...)) + if got := parseRoomDataNicknames(invalidUTF8); len(got) != 1 || got["wxid_member_a"] != "" { + t.Fatalf("invalid UTF-8 nickname should retain only member presence: %#v", got) + } + + if got := parseRoomDataNicknames(make([]byte, maxRoomDataBytes+1)); len(got) != 0 { + t.Fatalf("oversized RoomData should be rejected: %#v", got) + } + oversizedUser := testProtoBytes(1, make([]byte, maxRoomDataUserBytes+1)) + if got := parseRoomDataNicknames(oversizedUser); len(got) != 0 { + t.Fatalf("oversized RoomDataUser should be rejected: %#v", got) + } + oversizedUsername := testProtoBytes(1, testRoomDataUser(strings.Repeat("u", maxIdentityUsernameBytes+1), "Group Card A")) + if got := parseRoomDataNicknames(oversizedUsername); len(got) != 0 { + t.Fatalf("oversized username should be rejected: %#v", got) + } + oversizedNickname := testProtoBytes(1, testRoomDataUser("wxid_member_a", strings.Repeat("n", maxGroupNicknameBytes+1))) + if got := parseRoomDataNicknames(oversizedNickname); len(got) != 1 || got["wxid_member_a"] != "" { + t.Fatalf("oversized nickname should retain only member presence: %#v", got) + } +} + +func TestQuoteGroupNicknameCandidatesUsesCarryingRowOrder(t *testing.T) { + oldCarrier := testQuoteRow("room_a@chatroom", "wxid_member_a", "Group Card Old", 100, 1000, 10) + oldCarrier["message_content_parsed"].(map[string]any)["refermsg"].(map[string]any)["createtime"] = int64(9999) + newCarrier := testQuoteRow("room_a@chatroom", "wxid_member_a", "Group Card New", 200, 10, 20) + newCarrier["message_content_parsed"].(map[string]any)["refermsg"].(map[string]any)["createtime"] = int64(1) + newerSortSeq := testQuoteRow("room_a@chatroom", "wxid_member_b", "Group Card B New", 300, 20, 30) + olderSortSeq := testQuoteRow("room_a@chatroom", "wxid_member_b", "Group Card B Old", 300, 10, 40) + + got := candidateNames(quoteGroupNicknameCandidates([]wcdb.Row{oldCarrier, newCarrier, newerSortSeq, olderSortSeq})) + if got["room_a@chatroom"]["wxid_member_a"] != "Group Card New" { + t.Fatalf("refer.createtime incorrectly overrode carrying row time: %#v", got) + } + if got["room_a@chatroom"]["wxid_member_b"] != "Group Card B New" { + t.Fatalf("sort_seq tie-break was not applied: %#v", got) + } +} + +func TestQuoteGroupNicknameCandidatesRejectsAmbiguousIdentityChains(t *testing.T) { + valid := testQuoteRow("room_a@chatroom", "wxid_member_a", "Group Card A", 100, 1, 1) + wrongRoom := testQuoteRow("room_a@chatroom", "wxid_member_b", "Wrong Room", 100, 1, 2) + wrongRoom["message_content_parsed"].(map[string]any)["refermsg"].(map[string]any)["fromusr"] = "room_b@chatroom" + privateFromUser := testQuoteRow("room_a@chatroom", "wxid_member_c", "Private From", 100, 1, 3) + privateFromUser["message_content_parsed"].(map[string]any)["refermsg"].(map[string]any)["fromusr"] = "wxid_private" + memberIsRoom := testQuoteRow("room_a@chatroom", "room_b@chatroom", "Room Member", 100, 1, 4) + inverse := testQuoteRow("room_a@chatroom", "room_a@chatroom", "Inverse", 100, 1, 5) + inverseRefer := inverse["message_content_parsed"].(map[string]any)["refermsg"].(map[string]any) + inverseRefer["fromusr"] = "wxid_member_d" + inverseRefer["chatusr"] = "room_a@chatroom" + tooLongMember := testQuoteRow("room_a@chatroom", strings.Repeat("m", maxIdentityUsernameBytes+1), "Too Long Member", 100, 1, 6) + tooLongNickname := testQuoteRow("room_a@chatroom", "wxid_member_e", strings.Repeat("n", maxGroupNicknameBytes+1), 100, 1, 7) + + got := candidateNames(quoteGroupNicknameCandidates([]wcdb.Row{ + valid, wrongRoom, privateFromUser, memberIsRoom, inverse, tooLongMember, tooLongNickname, + })) + if len(got) != 1 || len(got["room_a@chatroom"]) != 1 || got["room_a@chatroom"]["wxid_member_a"] != "Group Card A" { + t.Fatalf("ambiguous identity evidence was accepted: %#v", got) + } +} + +func TestQuoteGroupNicknameCandidatesFromContentUsesTinyXMLParser(t *testing.T) { + content := `wxid_wrapper:` + "\n" + `Replyroom_a@chatroomwxid_member_aGroup Card A` + + strings.Repeat("ignored", 50) + `` + rows := []wcdb.Row{{ + "talker": "room_a@chatroom", + "local_id": int64(11), + "create_time": int64(100), + "sort_seq": int64(10), + "message_content": content, + }} + got := candidateNames(quoteGroupNicknameCandidatesFromContent(rows)) + if got["room_a@chatroom"]["wxid_member_a"] != "Group Card A" { + t.Fatalf("tiny XML parser did not extract strict refermsg identity: %#v", got) + } + + rows[0]["message_content"] = strings.Repeat("x", maxQuoteMessageContentBytes+1) + if got := quoteGroupNicknameCandidatesFromContent(rows); len(got) != 0 { + t.Fatalf("oversized quote content should be rejected: %#v", got) + } +} + +func TestGroupNicknameSourcePrecedenceAndHistoricalFallback(t *testing.T) { + quoteRow := testQuoteRow("room_a@chatroom", "wxid_member_a", "Group Card Quote", 100, 1, 1) + roomData := map[string]map[string]string{ + "room_a@chatroom": {"wxid_member_a": "Group Card Roster"}, + } + historical := map[string]map[string]string{ + "room_a@chatroom": { + "wxid_member_a": "Group Card Historical", + "wxid_member_b": "Group Card B Historical", + }, + } + got := groupNicknameNamesFromSources([]wcdb.Row{quoteRow}, roomData, historical) + if got["room_a@chatroom"]["wxid_member_a"] != "Group Card Roster" { + t.Fatalf("RoomData did not outrank quote evidence: %#v", got) + } + + newerHistorical := map[string]map[string]string{ + "room_a@chatroom": {"wxid_member_a": "Group Card Newer Scan"}, + } + gotWithoutRoomData := groupNicknameNamesFromSources([]wcdb.Row{quoteRow}, nil, newerHistorical) + if gotWithoutRoomData["room_a@chatroom"]["wxid_member_a"] != "Group Card Newer Scan" { + t.Fatalf("newer historical scan did not outrank the request row quote: %#v", gotWithoutRoomData) + } + + plainRow := wcdb.Row{ + "talker": "room_a@chatroom", + "sender_wxid": "wxid_member_b", + "sender_display_name": "Contact B", + } + namesAfterContactFailure := groupNicknameNamesFromSources([]wcdb.Row{plainRow}, nil, historical) + applyGroupNicknames([]wcdb.Row{plainRow}, namesAfterContactFailure) + if rowString(plainRow, "sender_display_name") != "Group Card B Historical" || + rowString(plainRow, "sender_group_nickname") != "Group Card B Historical" || + rowString(plainRow, "sender_contact_display") != "Contact B" { + t.Fatalf("historical fallback after missing contact data failed: %#v", plainRow) + } +} + +func TestApplyGroupNicknamesPreservesUnresolvedFallbacks(t *testing.T) { + raw := wcdb.Row{ + "talker": "room_a@chatroom", + "sender_wxid": "wxid_member_a", + "sender_display_name": "wxid_member_a", + } + contact := wcdb.Row{ + "talker": "room_a@chatroom", + "sender_wxid": "wxid_member_b", + "sender_display_name": "Contact B", + } + private := wcdb.Row{ + "talker": "wxid_private", + "sender_wxid": "wxid_private", + "sender_display_name": "Private Contact", + } + applyGroupNicknames([]wcdb.Row{raw, contact, private}, nil) + if rowString(raw, "sender_display_name") != "wxid_member_a" || rowString(raw, "sender_contact_display") != "" { + t.Fatalf("raw wxid fallback was mislabeled as contact display: %#v", raw) + } + rawAgent := agentMessage(raw) + if _, ok := rawAgent["sender_group_nickname"]; ok { + t.Fatalf("unresolved group agent output gained a group nickname: %#v", rawAgent) + } + rawLite := liteMessages([]wcdb.Row{copyRow(raw)}, "lite")[0] + if _, ok := rawLite["sender_group_nickname"]; ok { + t.Fatalf("unresolved group lite output gained a group nickname: %#v", rawLite) + } + rawSearch := cliSearchMessageRow(map[string]any(raw), map[string]any{}) + if _, ok := rawSearch["sender_group_nickname"]; ok { + t.Fatalf("unresolved group search output gained a group nickname: %#v", rawSearch) + } + rawMedia := map[string]any{} + addOptionalGroupIdentity(rawMedia, raw) + if len(rawMedia) != 0 { + t.Fatalf("unresolved group media identity gained optional fields: %#v", rawMedia) + } + if rowString(contact, "sender_display_name") != "Contact B" || rowString(contact, "sender_contact_display") != "Contact B" { + t.Fatalf("unresolved contact display was not preserved: %#v", contact) + } + if _, ok := private["sender_group_nickname"]; ok { + t.Fatalf("private row gained group fields: %#v", private) + } + privateAgent := agentMessage(private) + if _, ok := privateAgent["sender_group_nickname"]; ok { + t.Fatalf("private agent output gained a group nickname: %#v", privateAgent) + } + if _, ok := privateAgent["sender_contact_display"]; ok { + t.Fatalf("private agent output gained a group contact display: %#v", privateAgent) + } +} + +func TestAttachGroupNicknamesFallsBackAfterRoomDataFailure(t *testing.T) { + row := wcdb.Row{ + "talker": "room_a@chatroom", + "sender_wxid": "wxid_member_a", + "sender_display_name": "Contact A", + } + quoteCalled := false + attachGroupNicknames( + []wcdb.Row{row}, + func(needed map[string]map[string]bool) map[string]map[string]string { + if !needed["room_a@chatroom"]["wxid_member_a"] { + t.Fatalf("RoomData lookup received the wrong identity set: %#v", needed) + } + return nil + }, + func(missing map[string]map[string]bool) map[string]map[string]string { + quoteCalled = true + if !missing["room_a@chatroom"]["wxid_member_a"] { + t.Fatalf("quote lookup received the wrong missing identity set: %#v", missing) + } + return map[string]map[string]string{ + "room_a@chatroom": {"wxid_member_a": "Group Card A"}, + } + }, + ) + if !quoteCalled { + t.Fatal("quote fallback was not called after RoomData lookup failed") + } + if rowString(row, "sender_display_name") != "Group Card A" || + rowString(row, "sender_group_nickname") != "Group Card A" || + rowString(row, "sender_contact_display") != "Contact A" { + t.Fatalf("quote fallback did not preserve both identity levels: %#v", row) + } +} + +func TestAttachGroupNicknamesScansBeforeUsingRequestRowQuote(t *testing.T) { + row := testQuoteRow("room_a@chatroom", "wxid_member_a", "Group Card Old", 100, 1, 1) + row["sender_wxid"] = "wxid_member_a" + row["sender_display_name"] = "Contact A" + quoteCalled := false + attachGroupNicknames( + []wcdb.Row{row}, + func(map[string]map[string]bool) map[string]map[string]string { + return nil + }, + func(missing map[string]map[string]bool) map[string]map[string]string { + quoteCalled = true + if !missing["room_a@chatroom"]["wxid_member_a"] { + t.Fatalf("quote lookup received the wrong missing identity set: %#v", missing) + } + return map[string]map[string]string{ + "room_a@chatroom": {"wxid_member_a": "Group Card New"}, + } + }, + ) + if !quoteCalled { + t.Fatal("request row quote incorrectly prevented the historical scan") + } + if rowString(row, "sender_group_nickname") != "Group Card New" || + rowString(row, "sender_display_name") != "Group Card New" || + rowString(row, "sender_contact_display") != "Contact A" { + t.Fatalf("historical scan did not outrank the request row quote: %#v", row) + } +} + +func TestAttachGroupNicknamesSkipsFallbackWhenRoomDataResolvesMember(t *testing.T) { + row := wcdb.Row{ + "talker": "room_a@chatroom", + "sender_wxid": "wxid_member_a", + "sender_display_name": "Contact A", + } + attachGroupNicknames( + []wcdb.Row{row}, + func(map[string]map[string]bool) map[string]map[string]string { + return map[string]map[string]string{ + "room_a@chatroom": {"wxid_member_a": "Current Group Card"}, + } + }, + func(map[string]map[string]bool) map[string]map[string]string { + t.Fatal("quote fallback should not run when RoomData resolves the member") + return nil + }, + ) + if rowString(row, "sender_group_nickname") != "Current Group Card" || + rowString(row, "sender_contact_display") != "Contact A" { + t.Fatalf("RoomData identity was not applied: %#v", row) + } +} + +func TestAttachGroupNicknamesDoesNotResurrectClearedRoomNickname(t *testing.T) { + row := testQuoteRow("room_a@chatroom", "wxid_member_a", "Old Group Card", 100, 1, 1) + row["sender_wxid"] = "wxid_member_a" + row["sender_display_name"] = "Contact A" + attachGroupNicknames( + []wcdb.Row{row}, + func(map[string]map[string]bool) map[string]map[string]string { + return map[string]map[string]string{ + "room_a@chatroom": {"wxid_member_a": ""}, + } + }, + func(map[string]map[string]bool) map[string]map[string]string { + t.Fatal("quote fallback should not run for a member present in current RoomData") + return nil + }, + ) + if rowString(row, "sender_group_nickname") != "" || + rowString(row, "sender_display_name") != "Contact A" || + rowString(row, "sender_contact_display") != "Contact A" { + t.Fatalf("cleared room nickname was resurrected: %#v", row) + } +} + +func TestGroupNicknameFieldsSurviveAgentLiteAndSearchViews(t *testing.T) { + row := wcdb.Row{ + "talker": "room_a@chatroom", + "talker_display_name": "Synthetic Room", + "chat_type": "group", + "local_id": int64(1), + "create_time": int64(1776330000), + "create_time_human": "2026-04-14 21:00:00", + "sender_wxid": "wxid_member_a", + "sender_display_name": "Group Card A", + "sender_group_nickname": "Group Card A", + "sender_contact_display": "Contact A", + "kind_name": "text", + "content_summary": "Synthetic message", + "is_from_me": false, + } + + agent := agentMessage(copyRow(row)) + if agent["sender"] != "Group Card A" || + agent["sender_group_nickname"] != "Group Card A" || + agent["sender_contact_display"] != "Contact A" { + t.Fatalf("agent view lost group identity fields: %#v", agent) + } + + lite := liteMessages([]wcdb.Row{copyRow(row)}, "lite")[0] + if lite["sender_group_nickname"] != "Group Card A" || lite["sender_contact_display"] != "Contact A" { + t.Fatalf("lite view lost group identity fields: %#v", lite) + } + + search := cliSearchMessageRow(map[string]any(row), map[string]any{}) + if search["sender"] != "Group Card A" || + search["sender_group_nickname"] != "Group Card A" || + search["sender_contact_display"] != "Contact A" { + t.Fatalf("search view lost group identity fields: %#v", search) + } +} diff --git a/cmd/wechat-cli/main.go b/cmd/wechat-cli/main.go index 9697d67..5fb6955 100644 --- a/cmd/wechat-cli/main.go +++ b/cmd/wechat-cli/main.go @@ -1200,9 +1200,7 @@ func (s *server) queryLiveMessages(a map[string]any, order string) ([]wcdb.Row, } else { rows, page.HasMore = sliceRowsWithHasMore(rows, offset, limit) } - s.attachDisplayNames(rows, - [2]string{"talker", "talker_display_name"}, - [2]string{"sender_wxid", "sender_display_name"}) + s.attachMessageDisplayNames(rows) if selfWxid := s.selfWxid(); selfWxid != "" { for _, r := range rows { sw, _ := r["sender_wxid"].(string) @@ -1285,7 +1283,8 @@ func liteMessages(rows []wcdb.Row, mode string, includeDebugOpt ...bool) []wcdb. "talker": true, "talker_display_name": true, "chat_type": true, "local_id": true, "server_id": true, "server_id_str": true, "create_time": true, "create_time_human": true, - "sender_wxid": true, "sender_display_name": true, "is_from_me": true, + "sender_wxid": true, "sender_display_name": true, + "sender_group_nickname": true, "sender_contact_display": true, "is_from_me": true, "base_kind": true, "kind_name": true, "content_summary": true, "id": true, "display": true, "images": true, "videos": true, "files": true, "link": true, "music": true, "miniprogram": true, "forward_chat": true, "quote": true, @@ -1387,6 +1386,7 @@ func agentMessageWithIndex(r wcdb.Row, sourceIndex map[string]wcdb.Row, includeD "kind": rowString(r, "kind_name"), "text": agentMessageText(r), } + addOptionalGroupIdentity(out, r) if warnings := agentMessageWarnings(r); len(warnings) > 0 { out["warnings"] = warnings } @@ -3546,7 +3546,7 @@ func (s *server) toolMediaResources(a map[string]any) (any, error) { if err != nil { return nil, err } - s.attachDisplayNames(rows, [2]string{"talker", "talker_display_name"}, [2]string{"sender_wxid", "sender_display_name"}) + s.attachMessageDisplayNames(rows) return s.buildMediaResourceOutput(rows, getBoolDefault(a, "include_local_paths", true), includeDebugOutput(a)), nil } @@ -3578,6 +3578,7 @@ func (s *server) buildMediaResourceOutput(rows []wcdb.Row, includeLocalPaths boo "message_origin_source": rowInt64(r, "message_origin_source"), "resources": []map[string]any{}, } + addOptionalGroupIdentity(item, r) item["id"] = compactMap(map[string]any{ "local_id": item["local_id"], "server_id_str": item["server_id_str"], @@ -6618,10 +6619,6 @@ func (s *server) toolSearch(a map[string]any) (any, error) { if needsPostFilter && len(enrichmentWarnings) > 0 { return nil, fmt.Errorf("search post-filter completeness could not be guaranteed: %s", strings.Join(enrichmentWarnings, ", ")) } - s.attachDisplayNames(batch, - [2]string{"talker", "talker_display_name"}, - [2]string{"sender_wxid", "sender_display_name"}) - decorateMessageSearchRows(batch) batch = filterLiveSearchRows(batch, a, sender) rows = append(rows, batch...) scanned += rawCount @@ -6641,6 +6638,11 @@ func (s *server) toolSearch(a map[string]any) (any, error) { rows = nil } } + if limit > 0 && len(rows) > limit+1 { + rows = rows[:limit+1] + } + s.attachMessageDisplayNames(rows) + decorateMessageSearchRows(rows) return searchRowsResult(rows, complete && len(warnings) == 0, warnings), nil } @@ -8199,7 +8201,7 @@ func (s *server) liveMessageMeta(rows []wcdb.Row, sidCol, talkerCol string) map[ } closeMsgDBs(shards) } - s.attachDisplayNames(allMeta, [2]string{"sender_wxid", "sender_display_name"}) + s.attachMessageDisplayNames(allMeta) for _, mr := range allMeta { out[messagePairKey(rowString(mr, "talker"), rowInt64(mr, "server_id"))] = mr } diff --git a/cmd/wechat-cli/tools.go b/cmd/wechat-cli/tools.go index e4b3f0f..c46b98f 100644 --- a/cmd/wechat-cli/tools.go +++ b/cmd/wechat-cli/tools.go @@ -304,11 +304,11 @@ var toolDefs = []toolDef{ { Name: "messages", Description: "会话消息, 默认直接读取实时微信消息 DB, 不缓存聊天正文. talker 可传 wxid/xxx@chatroom; chat 可传昵称/备注/群名让 wechat-cli 用 metadata cache 自动解析. " + - "view=agent 返回给 agent 直接消费的 query/freshness/messages envelope; query 含 returned/limit/has_more 与 cursor.next_before_message/next_after_message, 用于稳定分页. messages[] 是低噪声 timeline 行: id(local_id/server_id_str/talker) / time / create_time(unix秒) / time_iso / sender / sender_wxid / is_from_me / kind / text / warnings, " + + "view=agent 返回给 agent 直接消费的 query/freshness/messages envelope; query 含 returned/limit/has_more 与 cursor.next_before_message/next_after_message, 用于稳定分页. messages[] 是低噪声 timeline 行: id(local_id/server_id_str/talker) / time / create_time(unix秒) / time_iso / sender / sender_wxid / sender_group_nickname / sender_contact_display / is_from_me / kind / text / warnings; 群聊 sender 优先使用群昵称, " + "并为非文本消息提供 display-ready 结构: images / videos / files / link / music / miniprogram / forward_chat / quote / transfer / red_packet / location / card / voice / video / sticker / solitaire / announcement / pat. " + "默认遵循微信 UI 可见语义: 图片/视频/文件给 agent 可直接读取的本机 path, 语音默认优先用 faster-whisper large-v3 返回本地 ASR transcript, raw SILK、不可读 .dat、CDN/aeskey、协议码和 raw XML 下沉到 debug/full/media_resources; 引用消息会扁平到 quote 并复用原消息可见 payload; 合并转发 item 使用 source_id 统一关联原消息, 媒体无法解析时给明确 warnings; 链接直接给 title/url/source/thumb_url. " + "fields=lite (默认) 返回: local_id / server_id / server_id_str / create_time / create_time_human / " + - "talker / talker_display_name / chat_type / sender_wxid / sender_display_name / is_from_me / base_kind / kind_name / content_summary " + + "talker / talker_display_name / chat_type / sender_wxid / sender_display_name / sender_group_nickname / sender_contact_display / is_from_me / base_kind / kind_name / content_summary " + "/ id / display / display-ready 非文本结构 / warnings (群聊已剥 'wxid:\\n' 前缀). " + "正常 agent 查询不需要 fields=full; 默认隐藏 media_resources/media_read_hints/CDN/aeskey/.dat 解码细节. 维护者诊断时才传 include_debug=true/debug=true 或 fields=full. 可传 include_media_paths=false 跳过媒体路径补齐. " + "若消息 XML 或引用消息(refermsg)里的真实图片 md5 能匹配本机 temp 里的 PNG/JPG 副本, media_read_hints 会优先给 direct_readable_local_paths 供 agent 直接读图; 引用图片带 source=message_refermsg / message_role=referenced_message. " + @@ -365,7 +365,7 @@ var toolDefs = []toolDef{ }, { Name: "chat_timeline", - Description: "面向 agent 展示/总结的高层聊天时间线工具, 是普通查消息的首选入口. 自动解析 chat, live 读取最近消息, 默认 order=desc + display_order=asc 展示最近窗口的聊天顺序. 返回对象包含 query / freshness / messages; 翻旧消息优先复用 query.cursor.next_before_message, 避免实时新增消息使 offset 漂移; messages 是低噪声 agent 行, 每条有稳定 id、time/create_time/time_iso、sender_wxid/is_from_me、display-ready 非文本结构和轻量 warnings, 默认隐藏调试噪音.", + Description: "面向 agent 展示/总结的高层聊天时间线工具, 是普通查消息的首选入口. 自动解析 chat, live 读取最近消息, 默认 order=desc + display_order=asc 展示最近窗口的聊天顺序. 返回对象包含 query / freshness / messages; 翻旧消息优先复用 query.cursor.next_before_message, 避免实时新增消息使 offset 漂移; messages 是低噪声 agent 行, 每条有稳定 id、time/create_time/time_iso、sender_wxid/is_from_me、sender_group_nickname/sender_contact_display (群聊 sender 优先群昵称)、display-ready 非文本结构和轻量 warnings, 默认隐藏调试噪音.", InputSchema: jsonSchema(props{ "talker": strProp("会话对象 (wxid 或 xxx@chatroom)"), "chat": strProp("会话显示名/备注/alias/群名; talker 为空时自动解析"), @@ -566,7 +566,7 @@ var toolDefs = []toolDef{ Name: "search", Description: "跨会话消息全文搜索, 默认直接读取微信 message_fts.db 和 Msg_ 分片, 不缓存聊天正文. metadata cache 只用于 chat/sender 名称解析. " + "字段: content (群聊已剥 'wxid:\\n' 前缀) / local_id / talker / talker_display_name / chat_type / " + - "create_time / sender_wxid / sender_display_name / base_kind / kind_name. " + + "create_time / sender_wxid / sender_display_name / sender_group_nickname / sender_contact_display / base_kind / kind_name; 群聊 sender_display_name 优先群昵称. " + "sender + base_kind/kind_name 来自 join 回所有包含 Msg_(talker) 的 message shard. " + "search_mode=fts/like/auto 保留兼容; 三种模式都使用微信 live FTS, 不做全局 LIKE 扫描.", InputSchema: jsonSchema(props{