fix: resolve mapped LID chats in chat output
This commit is contained in:
parent
5613c07f79
commit
66c6d41ff6
@ -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.
|
||||
|
||||
@ -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
84
cmd/wacli/chats_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user