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{