fix: resolve mapped LID chats in chat output

This commit is contained in:
Peter Steinberger 2026-05-04 07:48:21 +01:00
parent 5613c07f79
commit 66c6d41ff6
No known key found for this signature in database
3 changed files with 247 additions and 1 deletions

View File

@ -27,6 +27,7 @@
- Auth: propagate QR channel setup errors and surface actionable QR pairing failures. (#100 — thanks @pmatheus)
- Build: fail cgo-disabled CLI builds at compile time instead of shipping a go-sqlite3 stub binary. (#194 — thanks @rajgopalv)
- Chats: resolve mapped historical `@lid` chat rows in `chats list/show` output. (#31, #89 — thanks @bhaskoro-muthohar and @alexph-dev)
- Groups: hide groups after `groups leave`, mark missing joined groups as left during refresh, and show them again if a later refresh reports membership. (#125, #129 — thanks @SeifBenayed and @ImLukeF)
- History: cap on-demand backfill at 500 messages per request and 100 requests per run.
- Messages: normalize device-specific `@s.whatsapp.net` JIDs before storing chats, contacts, and senders.

View File

@ -2,12 +2,20 @@ package main
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/store"
"go.mau.fi/whatsmeow/types"
)
func newChatsCmd(flags *rootFlags) *cobra.Command {
@ -40,6 +48,7 @@ func newChatsListCmd(flags *rootFlags) *cobra.Command {
if err != nil {
return err
}
chats = resolveStoredChats(ctx, a, chats)
if flags.asJSON {
return out.WriteJSON(os.Stdout, chats)
}
@ -81,7 +90,7 @@ func newChatsShowCmd(flags *rootFlags) *cobra.Command {
}
defer closeApp(a, lk)
c, err := a.DB().GetChat(jid)
c, err := getChatForDisplay(ctx, a, jid)
if err != nil {
return err
}
@ -95,3 +104,155 @@ func newChatsShowCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().StringVar(&jid, "jid", "", "chat JID")
return cmd
}
type chatDisplayResolver interface {
ResolveChatName(context.Context, types.JID, string) string
ResolveLIDToPN(context.Context, types.JID) types.JID
ResolvePNToLID(context.Context, types.JID) types.JID
}
func resolveStoredChats(ctx context.Context, a *app.App, chats []store.Chat) []store.Chat {
if len(chats) == 0 || !chatsNeedLIDResolution(chats) {
return chats
}
if _, err := os.Stat(filepath.Join(a.StoreDir(), "session.db")); err != nil {
return chats
}
if err := a.OpenWA(); err != nil {
return chats
}
return resolveStoredChatsWith(ctx, a.WA(), chats)
}
func chatsNeedLIDResolution(chats []store.Chat) bool {
for _, chat := range chats {
if strings.HasSuffix(strings.TrimSpace(chat.JID), "@"+types.HiddenUserServer) {
return true
}
}
return false
}
func resolveStoredChatsWith(ctx context.Context, resolver chatDisplayResolver, chats []store.Chat) []store.Chat {
out := make([]store.Chat, 0, len(chats))
seen := make(map[string]int, len(chats))
for _, chat := range chats {
chat = resolveStoredChatWith(ctx, resolver, chat)
if idx, ok := seen[chat.JID]; ok {
out[idx] = mergeDisplayChats(out[idx], chat)
continue
}
seen[chat.JID] = len(out)
out = append(out, chat)
}
sort.SliceStable(out, func(i, j int) bool {
return out[i].LastMessageTS.After(out[j].LastMessageTS)
})
return out
}
func resolveStoredChatWith(ctx context.Context, resolver chatDisplayResolver, chat store.Chat) store.Chat {
jid, err := types.ParseJID(strings.TrimSpace(chat.JID))
if err != nil || jid.Server != types.HiddenUserServer {
return chat
}
pn := resolver.ResolveLIDToPN(ctx, jid)
if pn.IsEmpty() || pn.Server != types.DefaultUserServer {
return chat
}
out := chat
out.JID = pn.ToNonAD().String()
if out.Kind == "" || out.Kind == "unknown" {
out.Kind = "dm"
}
if chatNameRank(out.Name, chat.JID) < 2 {
if name := strings.TrimSpace(resolver.ResolveChatName(ctx, pn, "")); name != "" {
out.Name = name
}
}
if strings.TrimSpace(out.Name) == "" || strings.TrimSpace(out.Name) == strings.TrimSpace(chat.JID) {
out.Name = out.JID
}
return out
}
func mergeDisplayChats(a, b store.Chat) store.Chat {
out := a
if b.LastMessageTS.After(out.LastMessageTS) {
out.LastMessageTS = b.LastMessageTS
}
if out.Kind == "" || out.Kind == "unknown" || b.Kind == "dm" {
out.Kind = b.Kind
}
if chatNameRank(b.Name, b.JID) > chatNameRank(out.Name, out.JID) {
out.Name = b.Name
}
return out
}
func chatNameRank(name, jid string) int {
name = strings.TrimSpace(name)
switch {
case name == "":
return 0
case name == strings.TrimSpace(jid), strings.Contains(name, "@"):
return 1
default:
return 2
}
}
func getChatForDisplay(ctx context.Context, a *app.App, rawJID string) (store.Chat, error) {
chat, err := a.DB().GetChat(rawJID)
if err == nil {
return resolveStoredChatForDisplay(ctx, a, chat), nil
}
if !errors.Is(err, sql.ErrNoRows) {
return store.Chat{}, err
}
chatJIDs := mappedChatJIDs(ctx, a, rawJID)
for _, chatJID := range chatJIDs {
if chatJID == rawJID {
continue
}
chat, err = a.DB().GetChat(chatJID)
if err == nil {
return resolveStoredChatForDisplay(ctx, a, chat), nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return store.Chat{}, err
}
}
return store.Chat{}, sql.ErrNoRows
}
func resolveStoredChatForDisplay(ctx context.Context, a *app.App, chat store.Chat) store.Chat {
return resolveStoredChats(ctx, a, []store.Chat{chat})[0]
}
func mappedChatJIDs(ctx context.Context, a *app.App, rawJID string) []string {
jid, err := types.ParseJID(strings.TrimSpace(rawJID))
if err != nil {
return []string{rawJID}
}
jids := []types.JID{jid}
if _, err := os.Stat(filepath.Join(a.StoreDir(), "session.db")); err != nil {
return jidStrings(jids)
}
if err := a.OpenWA(); err != nil {
return jidStrings(jids)
}
client := a.WA()
if client == nil {
return jidStrings(jids)
}
switch jid.Server {
case types.DefaultUserServer:
jids = append(jids, client.ResolvePNToLID(ctx, jid))
case types.HiddenUserServer:
jids = append(jids, client.ResolveLIDToPN(ctx, jid))
}
return jidStrings(jids)
}

84
cmd/wacli/chats_test.go Normal file
View File

@ -0,0 +1,84 @@
package main
import (
"context"
"testing"
"time"
"github.com/steipete/wacli/internal/store"
"go.mau.fi/whatsmeow/types"
)
type fakeChatResolver struct {
lidToPN map[types.JID]types.JID
names map[types.JID]string
}
func (f fakeChatResolver) ResolveChatName(ctx context.Context, chat types.JID, pushName string) string {
if name, ok := f.names[chat.ToNonAD()]; ok {
return name
}
return chat.String()
}
func (f fakeChatResolver) ResolveLIDToPN(ctx context.Context, jid types.JID) types.JID {
if pn, ok := f.lidToPN[jid.ToNonAD()]; ok {
pn.Device = jid.Device
return pn
}
return jid
}
func (f fakeChatResolver) ResolvePNToLID(ctx context.Context, jid types.JID) types.JID {
for lid, pn := range f.lidToPN {
if pn == jid.ToNonAD() {
lid.Device = jid.Device
return lid
}
}
return jid
}
func TestResolveStoredChatsMapsLIDRows(t *testing.T) {
lid := mustParseJID(t, "999123456789@lid")
pn := mustParseJID(t, "15551234567@s.whatsapp.net")
resolver := fakeChatResolver{
lidToPN: map[types.JID]types.JID{lid: pn},
names: map[types.JID]string{pn: "Alice"},
}
got := resolveStoredChatsWith(context.Background(), resolver, []store.Chat{{
JID: lid.String(),
Kind: "unknown",
Name: lid.String(),
LastMessageTS: time.Unix(10, 0),
}})
if len(got) != 1 {
t.Fatalf("len = %d, want 1: %+v", len(got), got)
}
if got[0].JID != pn.String() || got[0].Kind != "dm" || got[0].Name != "Alice" {
t.Fatalf("resolved chat = %+v", got[0])
}
}
func TestResolveStoredChatsMergesMappedDuplicates(t *testing.T) {
lid := mustParseJID(t, "999123456789@lid")
pn := mustParseJID(t, "15551234567@s.whatsapp.net")
resolver := fakeChatResolver{
lidToPN: map[types.JID]types.JID{lid: pn},
names: map[types.JID]string{pn: "Alice"},
}
old := time.Unix(10, 0)
newer := time.Unix(20, 0)
got := resolveStoredChatsWith(context.Background(), resolver, []store.Chat{
{JID: lid.String(), Kind: "unknown", Name: lid.String(), LastMessageTS: newer},
{JID: pn.String(), Kind: "dm", Name: "", LastMessageTS: old},
})
if len(got) != 1 {
t.Fatalf("len = %d, want 1: %+v", len(got), got)
}
if got[0].JID != pn.String() || got[0].Name != "Alice" || !got[0].LastMessageTS.Equal(newer) {
t.Fatalf("merged chat = %+v", got[0])
}
}